From fe85ca2b5bd241ac44ef37c6c2976082e5f359b7 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 18 Jul 2024 17:30:01 -0600 Subject: [PATCH 001/395] scaffolding for genai solution prototype --- .../genai/bedrock_org/lambda/src/app.py | 152 +++++ .../bedrock_org/lambda/src/cfnresponse.py | 43 ++ .../bedrock_org/lambda/src/sra_bedrock.py | 0 .../bedrock_org/lambda/src/sra_config.py | 66 ++ .../genai/bedrock_org/lambda/src/sra_iam.py | 348 +++++++++++ .../bedrock_org/lambda/src/sra_lambda.py | 46 ++ .../bedrock_org/lambda/src/sra_ssm_params.py | 581 ++++++++++++++++++ 7 files changed, 1236 insertions(+) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_bedrock.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py new file mode 100644 index 000000000..a4528c51d --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -0,0 +1,152 @@ +import json +import os +import logging +import boto3 +import cfnresponse +from botocore.exceptions import ClientError +import sra_s3 +import sra_staging +import sra_ssm_params +import sra_iam +import sra_kms +import sra_dynamodb +import sra_sts +import sra_cfn + +# import sra_lambda + +# TODO(liamschn): Need to test with (and create) a CFN template +# TODO(liamschn): If dynamoDB sra_state table exists, use it +# TODO(liamschn): Where do we see dry-run data? Maybe S3 staging bucket file? The sra_state table? Another DynamoDB table? + +from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional + +if TYPE_CHECKING: + from mypy_boto3_ssm.type_defs import TagTypeDef + +LOGGER = logging.getLogger(__name__) +log_level: str = os.environ.get("LOG_LEVEL", "INFO") +LOGGER.setLevel(log_level) + +# Global vars +STAGING_BUCKET: str = "" +RESOURCE_TYPE: str = "" +STATE_TABLE: str = "sra_state" +SOLUTION_NAME: str = "sra-common-prerequisites" + +LAMBDA_START: str = "" +LAMBDA_FINISH: str = "" + +ACCOUNT: str = boto3.client("sts").get_caller_identity().get("Account") +REGION: str = os.environ.get("AWS_REGION") +CFN_RESOURCE_ID: str = "sra-s3-function" + +# dry run global variables +DRY_RUN: bool = True +DRY_RUN_DATA: dict = {} + +# Instantiate sra class objects +# todo(liamschn): can these files exist in some central location to be shared with other solutions? +ssm_params = sra_ssm_params.sra_ssm_params() +iam = sra_iam.sra_iam() +# kms = sra_kms.sra_kms() +# dynamodb = sra_dynamodb.sra_dynamodb() +sts = sra_sts.sra_sts() +# cfn = sra_cfn.sra_cfn() + + +def get_resource_parameters(event): + global DRY_RUN + global staging + + LOGGER.info("Getting resource params...") + # TODO(liamschn): what parameters do we need for this solution? + ssm_params.CONTROL_TOWER = event["ResourceProperties"]["CONTROL_TOWER"] + ssm_params.OTHER_REGIONS = event["ResourceProperties"]["OTHER_REGIONS"] + ssm_params.OTHER_SECURITY_ACCT = event["ResourceProperties"]["OTHER_SECURITY_ACCT"] + ssm_params.OTHER_LOG_ARCHIVE_ACCT = event["ResourceProperties"]["OTHER_LOG_ARCHIVE_ACCT"] + ssm_params.SRA_STAGING_BUCKET = event["ResourceProperties"]["SRA_STAGING_BUCKET"] + "-" + ACCOUNT + "-" + REGION + + sts.CONFIGURATION_ROLE = event["ResourceProperties"]["CONFIGURATION_ROLE"] + + # dry run parameter + if event["ResourceProperties"]["DRY_RUN"] == "true": + LOGGER.info("Dry run enabled...") + DRY_RUN = True + else: + LOGGER.info("Dry run disabled...") + DRY_RUN = False + + +def create_event(event, context): + event_info = {"Event": event} + LOGGER.info(event_info) + + # 0) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) + + # End + if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: + cfnresponse.send(event, context, cfnresponse.SUCCESS, data, CFN_RESOURCE_ID) + return CFN_RESOURCE_ID + + +def update_event(event, context): + # TODO(liamschn): handle CFN update events; maybe unnecessary + LOGGER.info("update event function") + # data = sra_s3.s3_resource_check() + # TODO(liamschn): update data dictionary + data = {"data": "no info"} + if RESOURCE_TYPE != "Other": + cfnresponse.send(event, context, cfnresponse.SUCCESS, data, CFN_RESOURCE_ID) + + +def delete_event(event, context): + LOGGER.info("delete event function") + if RESOURCE_TYPE != "Other": + cfnresponse.send(event, context, cfnresponse.SUCCESS, {"delete_operation": "succeeded deleting"}, CFN_RESOURCE_ID) + + +def lambda_handler(event, context): + global RESOURCE_TYPE + global LAMBDA_START + global LAMBDA_FINISH + LAMBDA_START = dynamodb.get_date_time() + LOGGER.info(event) + LOGGER.info({"boto3 version": boto3.__version__}) + try: + RESOURCE_TYPE = event["ResourceType"] + LOGGER.info(f"ResourceType: {RESOURCE_TYPE}") + get_resource_parameters(event) + if event["RequestType"] == "Create": + LOGGER.info("CREATE EVENT!!") + create_event(event, context) + if event["RequestType"] == "Update": + LOGGER.info("UPDATE EVENT!!") + update_event(event, context) + if event["RequestType"] == "Delete": + LOGGER.info("DELETE EVENT!!") + delete_event(event, context) + + except Exception: + LOGGER.exception("Unexpected!") + reason = f"See the details in CloudWatch Log Stream: '{context.log_group_name}'" + if RESOURCE_TYPE != "Other": + cfnresponse.send(event, context, cfnresponse.FAILED, {}, "sra-s3-lambda", reason=reason) + LAMBDA_FINISH = dynamodb.get_date_time() + return { + "statusCode": 500, + "lambda_start": LAMBDA_START, + "lambda_finish": LAMBDA_FINISH, + "body": "ERROR", + "dry_run": DRY_RUN, + "dry_run_data": DRY_RUN_DATA, + } + LAMBDA_FINISH = dynamodb.get_date_time() + return { + "statusCode": 200, + "lambda_start": LAMBDA_START, + "lambda_finish": LAMBDA_FINISH, + "body": "SUCCESS", + "dry_run": DRY_RUN, + "dry_run_data": DRY_RUN_DATA, + } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py new file mode 100644 index 000000000..826c6d6b6 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py @@ -0,0 +1,43 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 + +from __future__ import print_function +import urllib3 +import json + +SUCCESS = "SUCCESS" +FAILED = "FAILED" + +http = urllib3.PoolManager() + + +def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None): + responseUrl = event["ResponseURL"] + + print(responseUrl) + + responseBody = { + "Status": responseStatus, + "Reason": reason or "See the details in CloudWatch Log Stream: {}".format(context.log_stream_name), + "PhysicalResourceId": physicalResourceId or context.log_stream_name, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": noEcho, + "Data": responseData, + } + + json_responseBody = json.dumps(responseBody) + + print("Response body:") + print(json_responseBody) + + headers = {"content-type": "", "content-length": str(len(json_responseBody))} + + try: + response = http.request("PUT", responseUrl, headers=headers, body=json_responseBody) + print("Status code:", response.status) + + except Exception as e: + + print("send(..) failed executing http.request(..):", e) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_bedrock.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_bedrock.py new file mode 100644 index 000000000..e69de29bb diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py new file mode 100644 index 000000000..f618b4486 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -0,0 +1,66 @@ +"""Custom Resource to setup SRA Config resources in the organization. + +Version: 0.1 + +'bedrock_org' solution in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +import logging +import os +from time import sleep + +# import re +# from time import sleep +from typing import TYPE_CHECKING + +# , Literal, Optional, Sequence, Union + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +import urllib.parse +import json + +import cfnresponse + +if TYPE_CHECKING: + from mypy_boto3_cloudformation import CloudFormationClient + from mypy_boto3_organizations import OrganizationsClient + from mypy_boto3_iam.client import IAMClient + from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + + +class sra_config: + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + def put_organization_config_rule(): + """Put Organization Config Rule.""" + # Setup Boto3 Clients + org_client: OrganizationsClient = boto3.client("organizations") + + # Get the Organization ID + org_id: str = ( + org_client.describe_organization()["Organization"]["Id"] + ) + + # Put the Organization Config Rule + response = org_client.put_organization_config_rule( + OrganizationConfigRuleName="sra_config_rule", + OrganizationId=org_id, + ConfigRuleName="sra_config_rule", + ) + + # Log the response + sra_config.LOGGER.info(response) + + # Return the response + return response \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py new file mode 100644 index 000000000..403643526 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -0,0 +1,348 @@ +"""Custom Resource to setup SRA IAM resources in the management account. + +Version: 1.0 + +'common_prerequisites' solution in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +import logging +import os +from time import sleep + +# import re +# from time import sleep +from typing import TYPE_CHECKING + +# , Literal, Optional, Sequence, Union + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +import urllib.parse +import json + +import cfnresponse + +if TYPE_CHECKING: + from mypy_boto3_cloudformation import CloudFormationClient + from mypy_boto3_organizations import OrganizationsClient + from mypy_boto3_iam.client import IAMClient + from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + + +# TODO(liamschn): build execution role in management account +class sra_iam: + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + # Global Variables + STACKSET_NAME: str = "sra-stackset-execution-role" + STACKSET2_NAME: str = "sra-stackset-admin-role" + + RESOURCE_TYPE: str = "" + # CLOUDFORMATION_THROTTLE_PERIOD = 0.2 + # CLOUDFORMATION_PAGE_SIZE = 100 + SRA_STAGING_BUCKET: str = "" + UNEXPECTED = "Unexpected!" + # EMPTY_VALUE = "NONE" + BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) + SRA_SOLUTION_NAME = "sra-common-prerequisites" + CFN_RESOURCE_ID: str = "sra-iam-function" + CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" + SRA_EXECUTION_ROLE: str = "sra-execution" # todo(liamschn): parameterize this role name + SRA_STACKSET_ROLE: str = "sra-stackset" # todo(liamschn): parameterize this role name + SRA_EXECUTION_ROLE_STACKSET_ID: str = "" + SRA_STACKSET_POLICY_NAME: str = "sra-assume-role-access" + SRA_STACKSET_POLICY: dict = { + "Version": "2012-10-17", + "Statement": [ + {"Action": "sts:AssumeRole", "Resource": "arn:aws:iam::*:role/" + SRA_EXECUTION_ROLE, "Effect": "Allow", "Sid": "AssumeExecutionRole"} + ], + } + SRA_STACKSET_TRUST: dict = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Principal": {"Service": "cloudformation.amazonaws.com"}, "Action": "sts:AssumeRole"}], + } + + try: + MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) + CFN_CLIENT: CloudFormationClient = MANAGEMENT_ACCOUNT_SESSION.client("cloudformation", config=BOTO3_CONFIG) + IAM_CLIENT: IAMClient = MANAGEMENT_ACCOUNT_SESSION.client("iam", config=BOTO3_CONFIG) + STS_CLIENT = boto3.client("sts") + HOME_REGION = MANAGEMENT_ACCOUNT_SESSION.region_name + LOGGER.info(f"Detected home region: {HOME_REGION}") + S3_HOST_NAME = urllib.parse.urlparse(boto3.client("s3", region_name=HOME_REGION).meta.endpoint_url).hostname + MANAGEMENT_ACCOUNT = STS_CLIENT.get_caller_identity().get("Account") + LOGGER.info(f"Detected management account (current account): {MANAGEMENT_ACCOUNT}") + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + SRA_EXECUTION_TRUST: dict = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::" + MANAGEMENT_ACCOUNT + ":root"}, "Action": "sts:AssumeRole"}], + } + + # Configuration + CFN_CAPABILITIES = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"] + CFN_PARAMETERS = [ + {"ParameterKey": "pManagementAccountId", "ParameterValue": MANAGEMENT_ACCOUNT}, + # Add more parameters as needed + ] + ACCOUNT_IDS = [] # Will be filled with accounts in the root OU + ROOT_OU: str = "" + REGION_NAMES = ["us-east-1"] # only global region for iam + + # Organization service functions + def get_accounts_in_root_ou(self): + self.ACCOUNT_IDS = [] + self.ROOT_OU = self.ORG_CLIENT.list_roots()["Roots"][0]["Id"] + # root_ous = self.ORG_CLIENT.list_roots()["Roots"] + # for root_ou in root_ous: + # paginator = self.ORG_CLIENT.get_paginator("list_accounts_for_parent") + # for page in paginator.paginate(ParentId=root_ou["Id"]): + # for account in page["Accounts"]: + # self.ACCOUNT_IDS.append(account["Id"]) + for account in self.ORG_CLIENT.list_accounts()["Accounts"]: + if account["Status"] == "ACTIVE": + self.ACCOUNT_IDS.append(account["Id"]) + + # CloudFormation service functions + # TODO(liamschn): Move cloudformation functions into its own class module + def create_stack(self, parameters, capabilities, template_url, stack_name): + # todo(liamschn): instead of building via stack, build in python boto3 (both admin and execution roles) + response = self.CFN_CLIENT.create_stack( + StackName=stack_name, + TemplateURL=template_url, + Parameters=parameters, + Capabilities=capabilities, + ) + self.LOGGER.info(f"Stack {stack_name} creation initiated.") + return response + + def create_stack_set(self, parameters, capabilities, template_url, stack_set_name): + response = self.CFN_CLIENT.create_stack_set( + StackSetName=stack_set_name, + TemplateURL=template_url, + Parameters=parameters, + Capabilities=capabilities, + PermissionModel="SERVICE_MANAGED", + AutoDeployment={"Enabled": True, "RetainStacksOnAccountRemoval": False}, + ) + self.LOGGER.info(f"StackSet {stack_set_name} creation initiated.") + return response + + def create_stack_instances(self, root_ou_id, stack_set_name): + response = self.CFN_CLIENT.create_stack_instances( + StackSetName=stack_set_name, + DeploymentTargets={"OrganizationalUnitIds": [root_ou_id]}, + Regions=self.REGION_NAMES, + OperationPreferences={ + "FailureToleranceCount": 0, + "MaxConcurrentCount": 1, + }, + ) + self.LOGGER.info(f"Stack instances creation initiated for regions: {self.REGION_NAMES}.") + return response + + def list_stack_instances(self, stack_set_name): + response = self.CFN_CLIENT.list_stack_instances( + StackSetName=stack_set_name, + ) + return response + + def check_for_stack_set(self, stack_set_name) -> bool: + try: + response = self.CFN_CLIENT.describe_stack_set(StackSetName=stack_set_name) + self.SRA_EXECUTION_ROLE_STACKSET_ID = response["StackSet"]["StackSetId"] + return True + except self.CFN_CLIENT.exceptions.StackSetNotFoundException as error: + self.LOGGER.info(f"CloudFormation StackSet: {stack_set_name} not found.") + return False + + def wait_for_stack_instances(self, stack_set_name, retries: int = 30): # todo(liamschn): parameterize retries + self.LOGGER.info(f"Waiting for stack instances to complete for {stack_set_name} stackset...") + self.LOGGER.info({"Accounts": self.ACCOUNT_IDS}) + found_accounts = [] + while True: + self.LOGGER.info("Getting stack instances...") + paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") + found_all_accounts = True + response_iterator = paginator.paginate( + StackSetName=stack_set_name, + ) + for page in response_iterator: + self.LOGGER.info("Iterating through stack instances...") + for instance in page["Summaries"]: + if instance["Account"] in found_accounts: + continue + else: + found_accounts.append(instance["Account"]) + for account in self.ACCOUNT_IDS: + self.LOGGER.info("Checking for stack instance for all member accounts...") + if account != self.MANAGEMENT_ACCOUNT: + self.LOGGER.info(f"Checking for stack instance for {account} account...") + if account in found_accounts: + self.LOGGER.info(f"Stack instance for {account} account found.") + else: + self.LOGGER.info(f"Stack instance for {account} account not found.") + found_all_accounts = False + if found_all_accounts is True: + break + else: + self.LOGGER.info("All accounts not found. Waiting 10 seconds before retrying...") + # TODO(liamschn): need to add a maximum retry mechanism here + sleep(10) + ready = False + i = 0 + while ready is False: + ready = True + paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") + response_iterator = paginator.paginate( + StackSetName=stack_set_name, + ) + for page in response_iterator: + for instance in page["Summaries"]: + if instance["StackInstanceStatus"]["DetailedStatus"] != "SUCCEEDED": + self.LOGGER.info(f"Stack instance in {instance['Account']} shows {instance['StackInstanceStatus']['DetailedStatus']}") + ready = False + i += 1 + if i > retries: + self.LOGGER.info("Timed out! Please check cloudformation stackset and try again.") + raise Exception("Timed out waiting for stackset!") + if ready is False: + self.LOGGER.info("Waiting 10 seconds before retrying...") + sleep(10) + return + + # IAM service functions + def create_role(self, role_name: str, trust_policy: dict) -> CreateRoleResponseTypeDef: + """Create IAM role. + + Args: + session: boto3 session used by boto3 API calls + role_name: Name of the role to be created + trust_policy: Trust policy relationship for the role + + Returns: + Dictionary output of a successful CreateRole request + """ + self.LOGGER.info("Creating role %s.", role_name) + return self.IAM_CLIENT.create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy)) + + def create_policy(self, policy_name: str, policy_document: dict) -> CreatePolicyResponseTypeDef: + """Create IAM policy. + + Args: + session: boto3 session used by boto3 API calls + policy_name: Name of the policy to be created + policy_document: IAM policy document for the role + + Returns: + Dictionary output of a successful CreatePolicy request + """ + self.LOGGER.info("Creating policy %s.", policy_name) + return self.IAM_CLIENT.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(policy_document)) + + # def attach_policy(self, role_name: str, policy_name: str, policy_document: str) -> EmptyResponseMetadataTypeDef: + # """Attach policy to IAM role. + + # Args: + # session: boto3 session used by boto3 API calls + # role_name: Name of the role for policy to be attached to + # policy_name: Name of the policy to be attached + # policy_document: IAM policy document to be attached + + # Returns: + # Empty response metadata + # """ + + # self.LOGGER.info("Attaching policy to %s.", role_name) + # return self.IAM_CLIENT.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=policy_document) + + def attach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadataTypeDef: + """Attach policy to IAM role. + + Args: + session: boto3 session used by boto3 API calls + role_name: Name of the role for policy to be attached to + policy_name: Name of the policy to be attached + policy_document: IAM policy document to be attached + + Returns: + Empty response metadata + """ + + self.LOGGER.info("Attaching policy to %s.", role_name) + return self.IAM_CLIENT.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) + + def detach_policy(self, role_name: str, policy_name: str) -> EmptyResponseMetadataTypeDef: + """Detach IAM policy. + + Args: + session: boto3 session used by boto3 API calls + role_name: Name of the role for which the policy is removed from + policy_name: Name of the policy to be removed (detached) + + Returns: + Empty response metadata + """ + self.LOGGER.info("Detaching policy from %s.", role_name) + return self.IAM_CLIENT.delete_role_policy(RoleName=role_name, PolicyName=policy_name) + + def delete_policy(self, policy_arn: str) -> EmptyResponseMetadataTypeDef: + """Delete IAM Policy. + + Args: + session: boto3 session used by boto3 API calls + policy_arn: The Amazon Resource Name (ARN) of the policy to be deleted + + Returns: + Empty response metadata + """ + self.LOGGER.info("Deleting policy %s.", policy_arn) + return self.IAM_CLIENT.delete_policy(PolicyArn=policy_arn) + + def delete_role(self, role_name: str) -> EmptyResponseMetadataTypeDef: + """Delete IAM role. + + Args: + session: boto3 session used by boto3 API calls + role_name: Name of the role to be deleted + + Returns: + Empty response metadata + """ + self.LOGGER.info("Deleting role %s.", role_name) + return self.IAM_CLIENT.delete_role(RoleName=role_name) + + def check_iam_role_exists(self, role_name): + """ + Checks if an IAM role exists. + + Parameters: + - role_name (str): The name of the IAM role to check. + + Returns: + bool: True if the role exists, False otherwise. + """ + try: + self.IAM_CLIENT.get_role(RoleName=role_name) + self.LOGGER.info(f"The role '{role_name}' exists.") + return True + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + self.LOGGER.info(f"The role '{role_name}' does not exist.") + return False + else: + # Handle other possible exceptions (e.g., permission issues) + raise diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py new file mode 100644 index 000000000..8b97e8861 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -0,0 +1,46 @@ +"""Custom Resource to setup SRA Lambda resources in the organization. + +Version: 0.1 + +'bedrock_org' solution in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +import logging +import os +from time import sleep + +# import re +# from time import sleep +from typing import TYPE_CHECKING + +# , Literal, Optional, Sequence, Union + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +import urllib.parse +import json + +import cfnresponse + +if TYPE_CHECKING: + from mypy_boto3_cloudformation import CloudFormationClient + from mypy_boto3_organizations import OrganizationsClient + from mypy_boto3_iam.client import IAMClient + from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + + +class sra_lambda: + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + def create_function(account): + diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py new file mode 100644 index 000000000..2b3617daa --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -0,0 +1,581 @@ +"""Custom Resource to gather data and create SSM paramters in the management account. + +Version: 1.0 + +'common_prerequisites' solution in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +import logging +import os +import re +from time import sleep +from typing import TYPE_CHECKING, Literal, Optional, Sequence, Union + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError, EndpointConnectionError + +if TYPE_CHECKING: + from aws_lambda_typing.context import Context + from aws_lambda_typing.events import CloudFormationCustomResourceEvent + from mypy_boto3_cloudformation import CloudFormationClient + from mypy_boto3_organizations import OrganizationsClient + from mypy_boto3_ssm import SSMClient + from mypy_boto3_ssm.type_defs import TagTypeDef + + +class sra_ssm_params: + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + # Global Variables + CONTROL_TOWER: str = "" + OTHER_REGIONS: str = "" + OTHER_SECURITY_ACCT: str = "" + OTHER_LOG_ARCHIVE_ACCT: str = "" + RESOURCE_TYPE: str = "" + CLOUDFORMATION_THROTTLE_PERIOD = 0.2 + CLOUDFORMATION_PAGE_SIZE = 100 + SSM_DELETE_PARAMETERS_MAX = 10 + SRA_CONTROL_TOWER_SSM_PATH = "/sra/control-tower" + SRA_REGIONS_SSM_PATH = "/sra/regions" + SRA_ROOT_SSM_PATH = "/sra" + SRA_SSM_PARAMETERS = [ + "/sra/control-tower/root-organizational-unit-id", + "/sra/control-tower/organization-id", + "/sra/control-tower/management-account-id", + "/sra/control-tower/home-region", + "/sra/control-tower/audit-account-id", + "/sra/control-tower/log-archive-account-id", + "/sra/regions/enabled-regions", + "/sra/regions/enabled-regions-without-home-region", + "/sra/regions/customer-control-tower-regions", + "/sra/regions/customer-control-tower-regions-without-home-region", + "/sra/staging-s3-bucket-name", + ] + SRA_STAGING_BUCKET: str = "" + UNEXPECTED = "Unexpected!" + EMPTY_VALUE = "NONE" + BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) + SRA_SECURITY_ACCT: str = "" + SRA_ORG_ID: str = "" + SSM_SECURITY_ACCOUNT_ID: str = "" + SSM_LOG_ARCHIVE_ACCOUNT: str = "" + + try: + MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) + CFN_CLIENT: CloudFormationClient = MANAGEMENT_ACCOUNT_SESSION.client("cloudformation", config=BOTO3_CONFIG) + STS_CLIENT = boto3.client("sts") + HOME_REGION = MANAGEMENT_ACCOUNT_SESSION.region_name + LOGGER.info(f"Detected home region: {HOME_REGION}") + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error.") from None + + try: + MANAGEMENT_ACCOUNT = STS_CLIENT.get_caller_identity().get("Account") + LOGGER.info(f"Detected management account (current account): {MANAGEMENT_ACCOUNT}") + except ClientError as error: + if error.response["Error"]["Code"] == "ExpiredToken": + LOGGER.info(f"Error getting management account: {error.response['Error']['Code']}") + else: + LOGGER.exception(f"Unexpected error getting management account: {error.response['Error']['Code']}") + raise ValueError("Unexpected error.") from None + + def add_tags_to_ssm_parameter(self, ssm_client: SSMClient, resource_id: str, tags: Sequence[TagTypeDef]) -> None: + """Add tags to SSM parameter. + + Args: + ssm_client: Boto3 SSM client + resource_id: SSM parameter name + tags: Tags to apply to SSM parameter + """ + response = ssm_client.add_tags_to_resource(ResourceType="Parameter", ResourceId=resource_id, Tags=tags) + self.LOGGER.debug({"API_Call": "ssm:AddTagsToResource", "API_Response": response}) + + def create_ssm_parameter( + self, ssm_client: SSMClient, name: str, value: str, parameter_type: Union[Literal["String"], Literal["StringList"]] + ) -> None: + """Create SSM parameter. + + Args: + ssm_client: Boto3 SSM client + name: SSM parameter name + value: SSM parameter value + parameter_type: SSM parameter type + """ + if not value: + value = self.EMPTY_VALUE + response = ssm_client.put_parameter(Name=name, Value=value, Type=parameter_type, Overwrite=True) + self.LOGGER.debug({"API_Call": "ssm:PutParameter", "API_Response": response}) + + def delete_ssm_parameters(self, ssm_client: SSMClient, names: list) -> None: + """Delete SSM parameters. + + Args: + ssm_client: Boto3 SSM client + names: SSM parameter names + """ + response = ssm_client.delete_parameters(Names=names) + self.LOGGER.debug({"API_Call": "ssm:DeleteParameters", "API_Response": response}) + + def get_customer_control_tower_regions(self) -> list: # noqa: CCR001 + """Query 'AWSControlTowerBP-BASELINE-CLOUDWATCH' CloudFormation stack to identify customer regions. + + Returns: + Customer regions chosen in Control Tower + """ + paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") + customer_regions = [] + aws_account = "" + all_regions_identified = False + for page in paginator.paginate( + StackSetName="AWSControlTowerBP-BASELINE-CLOUDWATCH", PaginationConfig={"PageSize": self.CLOUDFORMATION_PAGE_SIZE} + ): + for instance in page["Summaries"]: + if not aws_account: + aws_account = instance["Account"] + customer_regions.append(instance["Region"]) + continue + if aws_account == instance["Account"]: + customer_regions.append(instance["Region"]) + continue + all_regions_identified = True + break + if all_regions_identified: + break + sleep(self.CLOUDFORMATION_THROTTLE_PERIOD) + + return customer_regions + + def get_customer_other_regions(self) -> list: # noqa: CCR001 + """Query [something else] to identify customer regions. + + Returns: + Customer regions chosen + """ + customer_regions = [] + for region in self.OTHER_REGIONS.split(","): + customer_regions.append(region) + + return customer_regions + + def get_enabled_regions(self) -> list: # noqa: CCR001 + """Query AWS account to identify enabled regions. + + Raises: + EndpointConnectionError: region is not valid. + + Returns: + Enabled regions + """ + default_available_regions = [] + for region in boto3.client("account").list_regions(RegionOptStatusContains=["ENABLED", "ENABLED_BY_DEFAULT"])["Regions"]: + default_available_regions.append(region["RegionName"]) + self.LOGGER.info({"Default_Available_Regions": default_available_regions}) + + enabled_regions = [] + disabled_regions = [] + region_session = boto3.Session() + for region in default_available_regions: + self.LOGGER.info(f"testing region: {region}") + try: + sts_client = region_session.client( + "sts", endpoint_url=f"https://sts.{region}.amazonaws.com", region_name=region, config=self.BOTO3_CONFIG + ) + sts_client.get_caller_identity() + enabled_regions.append(region) + except EndpointConnectionError: + self.LOGGER.error(f"Region: '{region}' is not valid.") + raise + except ClientError as error: + if error.response["Error"]["Code"] == "InvalidClientTokenId": + disabled_regions.append(region) + continue + raise + + self.LOGGER.info({"Disabled_Regions": disabled_regions}) + return enabled_regions + + def get_staging_bucket_ssm_parameter_info(self, path: str) -> dict: + """Get info needed to create the staging bucket SSM parameter. + + Args: + path: SSM parameter hierarchy path + + Returns: + Info needed to create SSM parameters and helper data for custom resource + """ + ssm_data: dict = {"info": []} + + ssm_data["info"].append( + { + "name": f"{path}/staging-s3-bucket-name", + "value": self.SRA_STAGING_BUCKET, + "parameter_type": "String", + "description": "staging bucket name parameter", + } + ) + ssm_data["helper"] = {"StagingBucketName": self.SRA_STAGING_BUCKET} + self.LOGGER.info(ssm_data["helper"]) + return ssm_data + + def get_org_ssm_parameter_info(self, path: str) -> dict: + """Query AWS Organizations, and get info needed to create the SSM parameters. + + Args: + path: SSM parameter hierarchy path + + Returns: + Info needed to create SSM parameters and helper data for custom resource + """ + ssm_data: dict = {"info": []} + org = self.ORG_CLIENT.describe_organization()["Organization"] + root_id = self.ORG_CLIENT.list_roots()["Roots"][0]["Id"] + + ssm_data["info"].append( + {"name": f"{path}/root-organizational-unit-id", "value": root_id, "parameter_type": "String", "description": "root ou parameter"} + ) + ssm_data["info"].append( + {"name": f"{path}/organization-id", "value": org["Id"], "parameter_type": "String", "description": "organization id parameter"} + ) + self.SRA_ORG_ID = org["Id"] + ssm_data["info"].append( + { + "name": f"{path}/management-account-id", + "value": org["MasterAccountId"], + "parameter_type": "String", + "description": "management account parameter", + } + ) + ssm_data["helper"] = { + "ManagementAccountId": org["MasterAccountId"], + "OrganizationId": org["Id"], + "RootOrganizationalUnitId": root_id, + } + self.LOGGER.info(ssm_data["helper"]) + return ssm_data + + def get_cloudformation_ssm_parameter_info(self, path: str) -> dict: # noqa: CCR001 + """Query AWS CloudFormation stacksets, and get info needed to create the SSM parameters from AWS control tower environments. + + Args: + path: SSM parameter hierarchy path + + Returns: + Info needed to create SSM parameters and helper data for custom resource + """ + ssm_data: dict = {"info": [], "helper": {}} + response = self.CFN_CLIENT.describe_stack_set(StackSetName="AWSControlTowerBP-BASELINE-CONFIG") + for parameter in response["StackSet"]["Parameters"]: + if parameter["ParameterKey"] == "HomeRegionName": + ssm_data["info"].append( + { + "name": f"{path}/home-region", + "value": parameter["ParameterValue"], + "parameter_type": "String", + "description": "home region parameter", + } + ) + ssm_data["helper"]["HomeRegion"] = parameter["ParameterValue"] + if parameter["ParameterKey"] == "SecurityAccountId": + ssm_data["info"].append( + { + "name": f"{path}/audit-account-id", + "value": parameter["ParameterValue"], + "parameter_type": "String", + "description": "security tooling account parameter", + } + ) + ssm_data["helper"]["AuditAccountId"] = parameter["ParameterValue"] + self.SRA_SECURITY_ACCT = parameter["ParameterValue"] + + paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") + for page in paginator.paginate(StackSetName="AWSControlTowerLoggingResources", PaginationConfig={"PageSize": self.CLOUDFORMATION_PAGE_SIZE}): + for instance in page["Summaries"]: + ssm_data["info"].append( + { + "name": f"{path}/log-archive-account-id", + "value": instance["Account"], + "parameter_type": "String", + "description": "log archive account parameter", + } + ) + ssm_data["helper"]["LogArchiveAccountId"] = instance["Account"] + sleep(self.CLOUDFORMATION_THROTTLE_PERIOD) + + self.LOGGER.info(ssm_data["helper"]) + return ssm_data + + def get_other_ssm_parameter_info(self, path: str) -> dict: # noqa: CCR001 + """Get info needed to create the SSM parameters for non-control tower environments. + + Args: + path: SSM parameter hierarchy path + + Returns: + Info needed to create SSM parameters and helper data for custom resource + """ + self.LOGGER.info("Not using AWS Control Tower...") + ssm_data: dict = {"info": [], "helper": {}} + # home region parameter + ssm_data["info"].append( + {"name": f"{path}/home-region", "value": self.HOME_REGION, "parameter_type": "String", "description": "home region parameter"} + ) + ssm_data["helper"]["HomeRegion"] = self.HOME_REGION + # security tooling account id parameter + ssm_data["info"].append( + { + "name": f"{path}/audit-account-id", + "value": self.OTHER_SECURITY_ACCT, + "parameter_type": "String", + "description": "security tooling account parameter", + } + ) + ssm_data["helper"]["AuditAccountId"] = self.OTHER_SECURITY_ACCT + self.SRA_SECURITY_ACCT = self.OTHER_SECURITY_ACCT + # log archive account id parameter + ssm_data["info"].append( + { + "name": f"{path}/log-archive-account-id", + "value": self.OTHER_LOG_ARCHIVE_ACCT, + "parameter_type": "String", + "description": "log archive account parameter", + } + ) + ssm_data["helper"]["LogArchiveAccountId"] = self.OTHER_LOG_ARCHIVE_ACCT + + self.LOGGER.info(ssm_data["helper"]) + return ssm_data + + def get_enabled_regions_ssm_parameter_info(self, home_region: str, path: str) -> dict: # noqa: CCR001 + """Query STS for enabled regions, and get info needed to create the SSM parameters. + + Args: + home_region: Control Tower home region + path: SSM parameter hierarchy path + + Returns: + Info needed to create SSM parameters and helper data for custom resource + """ + ssm_data: dict = {"info": []} + enabled_regions = self.get_enabled_regions() + enabled_regions_without_home_region = enabled_regions.copy() + enabled_regions_without_home_region.remove(home_region) + + ssm_data["info"].append( + { + "name": f"{path}/enabled-regions", + "value": ",".join(enabled_regions), + "parameter_type": "StringList", + "description": "all enabled regions parameter", + } + ) + ssm_data["info"].append( + { + "name": f"{path}/enabled-regions-without-home-region", + "value": ",".join(enabled_regions_without_home_region), + "parameter_type": "StringList", + "description": "all enabled regions without home region parameter", + } + ) + + ssm_data["helper"] = {"EnabledRegions": enabled_regions, "EnabledRegionsWithoutHomeRegion": enabled_regions_without_home_region} + self.LOGGER.info(ssm_data["helper"]) + return ssm_data + + def get_customer_control_tower_regions_ssm_parameter_info(self, home_region: str, path: str) -> dict: + """Query customer regions chosen in Control Tower, and get info needed to create the SSM parameters. + + Args: + home_region: Control Tower home region + path: SSM parameter hierarchy path + + Returns: + Info needed to create SSM parameters and helper data for custom resource + """ + self.LOGGER.info(home_region) + ssm_data: dict = {"info": []} + if self.CONTROL_TOWER == "true": + customer_regions = self.get_customer_control_tower_regions() + else: + customer_regions = self.get_customer_other_regions() + self.LOGGER.info(f"customer regions: {customer_regions}") + customer_regions_without_home_region = customer_regions.copy() + customer_regions_without_home_region.remove(home_region) + self.LOGGER.info(f"customer_regions_without_home_region: {customer_regions_without_home_region}") + + ssm_data["info"].append( + { + "name": f"{path}/customer-control-tower-regions", + "value": ",".join(customer_regions), + "parameter_type": "StringList", + "description": "governed regions parameter", + } + ) + ssm_data["info"].append( + { + "name": f"{path}/customer-control-tower-regions-without-home-region", + "value": ",".join(customer_regions_without_home_region), + "parameter_type": "StringList", + "description": "governed regions without home region parameter", + } + ) + + ssm_data["helper"] = { + "CustomerControlTowerRegions": customer_regions, + "CustomerControlTowerRegionsWithoutHomeRegion": customer_regions_without_home_region, + } + self.LOGGER.info(f"ssm_data helper: {ssm_data['helper']}") + return ssm_data + + def create_ssm_parameters_in_regions(self, ssm_parameters: list, tags: Sequence[TagTypeDef], regions: list) -> None: + """Create SSM parameters in regions. + + Args: + ssm_parameters: Info for the SSM parameters + tags: Tags to be applied to the SSM parameters + regions: Regions + """ + parameters_created = set() + for region in regions: + region_ssm_client: SSMClient = self.MANAGEMENT_ACCOUNT_SESSION.client("ssm", region_name=region, config=self.BOTO3_CONFIG) + for parameter in ssm_parameters: + ssm_param_found, ssm_param_value = self.get_ssm_parameter(self.MANAGEMENT_ACCOUNT_SESSION, region, parameter["name"]) + if ssm_param_found is False: + self.LOGGER.info(f"Creating SSM parameter '{parameter['name']}' with value '{parameter['value']}'...") + self.create_ssm_parameter( + region_ssm_client, name=parameter["name"], value=parameter["value"], parameter_type=parameter["parameter_type"] + ) + self.add_tags_to_ssm_parameter(region_ssm_client, resource_id=parameter["name"], tags=tags) + parameters_created.add(parameter["name"]) + else: + if ssm_param_value != parameter["value"]: + self.LOGGER.info(f"Updating SSM parameter '{parameter['name']}' with value '{parameter['value']}'...") + self.update_ssm_parameter(region_ssm_client, name=parameter["name"], value=parameter["value"]) + self.add_tags_to_ssm_parameter(region_ssm_client, resource_id=parameter["name"], tags=tags) + parameters_created.add(parameter["name"]) + self.LOGGER.info(f"Completed the creation of SSM Parameters for '{region}' region.") + self.LOGGER.info({"Created Parameters": list(parameters_created)}) + + def update_ssm_parameter(self, ssm_client, name, value): + """Update SSM parameter. + + Args: + ssm_client: SSM client + name: SSM parameter name + value: SSM parameter value + """ + try: + ssm_client.put_parameter( + Name=name, + Value=value, + Type="String", + Overwrite=True, + ) + except ClientError as error: + self.LOGGER.error(f"Error updating SSM parameter '{name}': {error}") + + def delete_ssm_parameters_in_regions(self, regions: list) -> None: # noqa: CCR001 + """Delete SSM parameters in regions. + + Args: + regions: Regions + """ + for region in regions: + region_ssm_client: SSMClient = self.MANAGEMENT_ACCOUNT_SESSION.client("ssm", region_name=region, config=self.BOTO3_CONFIG) + + parameters_to_delete = [] + count = 0 # noqa: SIM113 + for parameter in self.SRA_SSM_PARAMETERS: + count += 1 # noqa: SIM113 + if count <= self.SSM_DELETE_PARAMETERS_MAX: + parameters_to_delete.append(parameter) + if count == self.SSM_DELETE_PARAMETERS_MAX: + count = 0 + self.delete_ssm_parameters(region_ssm_client, parameters_to_delete) + parameters_to_delete = [] + if parameters_to_delete: + self.delete_ssm_parameters(region_ssm_client, parameters_to_delete) + + self.LOGGER.info(f"Completed the deletion of SSM Parameters for '{region}' region.") + self.LOGGER.info({"Deleted Parameters": self.SRA_SSM_PARAMETERS}) + + def parameter_pattern_validator(self, parameter_name: str, parameter_value: Optional[str], pattern: str) -> None: + """Validate CloudFormation Custom Resource Parameters. + + Args: + parameter_name: CloudFormation custom resource parameter name + parameter_value: CloudFormation custom resource parameter value + pattern: REGEX pattern to validate against. + + Raises: + ValueError: Parameter is missing + ValueError: Parameter does not follow the allowed pattern + """ + if not parameter_value: + raise ValueError(f"'{parameter_name}' parameter is missing.") + elif not re.match(pattern, parameter_value): + raise ValueError(f"'{parameter_name}' parameter with value of '{parameter_value}' does not follow the allowed pattern: {pattern}.") + + def get_validated_parameters(self, event: CloudFormationCustomResourceEvent) -> dict: + """Validate AWS CloudFormation parameters. + + Args: + event: event data + + Returns: + Validated parameters + """ + params = event["ResourceProperties"].copy() + self.parameter_pattern_validator("TAG_KEY", params["TAG_KEY"], pattern=r"^.{1,128}$") + self.parameter_pattern_validator("TAG_VALUE", params["TAG_VALUE"], pattern=r"^.{1,256}$") + + return params + + def get_ssm_parameter(self, session, region, parameter: str): + """Get SSM parameter value. + + Args: + session: boto3 session + region: region + parameter: parameter name + + Returns: + SSM parameter value + """ + self.LOGGER.info(f"Getting SSM parameter '{parameter}'...") + ssm_client: SSMClient = session.client("ssm", region_name=region, config=self.BOTO3_CONFIG) + + try: + response = ssm_client.get_parameter(Name=parameter) + except ClientError as e: + if e.response["Error"]["Code"] == "ParameterNotFound": + self.LOGGER.info(f"SSM parameter '{parameter}' not found.") + return False, "" + else: + self.LOGGER.info(f"Error getting SSM parameter '{parameter}': {e.response['Error']['Message']}") + return False, "" + self.LOGGER.info(f"SSM parameter '{parameter}' found.") + return True, response["Parameter"]["Value"] + + # def get_parameter_values(self): + # try: + # self.SSM_SECURITY_ACCOUNT_ID = self.get_ssm_parameter( + # self.MANAGEMENT_ACCOUNT_SESSION, self.HOME_REGION, "/sra/control-tower/audit-account-id" + # ) + # self.SSM_LOG_ARCHIVE_ACCOUNT_ID = self.get_ssm_parameter( + # self.MANAGEMENT_ACCOUNT_SESSION, self.HOME_REGION, "/sra/control-tower/log-archive-account-id" + # ) + # return True + # except Exception as e: + # self.LOGGER.info(f"Error getting SSM parameter values: {e}") + # return False From ff90dfbc6c1a42546175451af8fd3561d239fa76 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 23 Jul 2024 18:01:36 -0600 Subject: [PATCH 002/395] still working on scaffolding --- .../bedrock_org/lambda/src/sra_config.py | 41 +++- .../bedrock_org/lambda/src/sra_lambda.py | 44 +++- .../genai/bedrock_org/lambda/src/sra_s3.py | 101 ++++++++ .../bedrock_org/lambda/src/sra_staging.py | 225 ++++++++++++++++++ 4 files changed, 404 insertions(+), 7 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_staging.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index f618b4486..4a311a722 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -32,6 +32,7 @@ if TYPE_CHECKING: from mypy_boto3_cloudformation import CloudFormationClient from mypy_boto3_organizations import OrganizationsClient + from mypy_boto3_config import ConfigServiceClient from mypy_boto3_iam.client import IAMClient from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef @@ -42,18 +43,46 @@ class sra_config: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - def put_organization_config_rule(): - """Put Organization Config Rule.""" - # Setup Boto3 Clients - org_client: OrganizationsClient = boto3.client("organizations") + BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) + UNEXPECTED = "Unexpected!" + + try: + MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) + CONFIG_CLIENT: ConfigServiceClient = MANAGEMENT_ACCOUNT_SESSION.client("config", config=BOTO3_CONFIG) + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + + def get_organization_config_rules(self): + """Get Organization Config Rules.""" + # Get the Organization ID + org_id: str = ( + self.ORG_CLIENT.describe_organization()["Organization"]["Id"] + ) + # Get the Organization Config Rules + response = self.ORG_CLIENT.describe_organization_config_rules( + OrganizationConfigRuleNames=["sra_config_rule"], + OrganizationId=org_id, + ) + + # Log the response + sra_config.LOGGER.info(response) + + # Return the response + return response + + def put_organization_config_rule(self): + """Put Organization Config Rule.""" # Get the Organization ID org_id: str = ( - org_client.describe_organization()["Organization"]["Id"] + self.ORG_CLIENT.describe_organization()["Organization"]["Id"] ) # Put the Organization Config Rule - response = org_client.put_organization_config_rule( + response = self.ORG_CLIENT.put_organization_config_rule( OrganizationConfigRuleName="sra_config_rule", OrganizationId=org_id, ConfigRuleName="sra_config_rule", diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 8b97e8861..dec45413f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -32,6 +32,7 @@ if TYPE_CHECKING: from mypy_boto3_cloudformation import CloudFormationClient from mypy_boto3_organizations import OrganizationsClient + from mypy_boto3_lambda.client import LambdaClient from mypy_boto3_iam.client import IAMClient from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef @@ -42,5 +43,46 @@ class sra_lambda: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - def create_function(account): + BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) + UNEXPECTED = "Unexpected!" + + try: + MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + LAMBDA_CLIENT: LambdaClient = MANAGEMENT_ACCOUNT_SESSION.client("lambda", config=BOTO3_CONFIG) + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def find_lambda_function(self, function_name): + """Find Lambda Function.""" + try: + response = self.LAMBDA_CLIENT.get_function( + FunctionName=function_name + ) + return response + except ClientError as e: + if e.response['Error']['Code'] == 'ResourceNotFoundException': + return None + else: + self.LOGGER.error(e) + return None + + def create_lambda_function(self, code_zip_s3_url, role_arn, function_name, handler, runtime, timeout, memory_size): + """Create Lambda Function.""" + try: + response = self.LAMBDA_CLIENT.create_function( + FunctionName=function_name, + Runtime=runtime, + Handler=handler, + Role=role_arn, + Code={ + 'ZipFile': code_zip_s3_url + }, + Timeout=timeout, + MemorySize=memory_size + ) + return response + except ClientError as e: + self.LOGGER.error(e) + return None diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py new file mode 100644 index 000000000..5ae730d1f --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -0,0 +1,101 @@ +# type: ignore +"""Custom Resource to check to see if a resource exists. + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" +import logging +import os + +import boto3 +from botocore.client import ClientError +import json + + +class sra_s3: + S3_CLIENT = boto3.client("s3") + S3_RESOURCE = boto3.resource("s3") + + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + REGION: str = os.environ.get("AWS_REGION") + ORG_ID: str = boto3.client("organizations").describe_organization()["Organization"]["Id"] + PARTITION: str = boto3.session.Session().get_partition_for_region(REGION) + STAGING_BUCKET: str = "" + BUCKET_POLICY_TEMPLATE: dict = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowDeploymentRoleGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "arn:" + PARTITION + ":s3:::BUCKET_NAME/*", + "Condition": { + "ArnLike": { + "aws:PrincipalArn": [ + "arn:" + PARTITION + ":iam::*:role/AWSControlTowerExecution", + "arn:" + PARTITION + ":iam::*:role/stacksets-exec-*", + ] + } + }, + }, + { + "Sid": "DenyExternalPrincipals", + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": ["arn:" + PARTITION + ":s3:::BUCKET_NAME", "arn:" + PARTITION + ":s3:::BUCKET_NAME/*"], + "Condition": {"StringNotEquals": {"aws:PrincipalOrgID": ORG_ID}}, + }, + { + "Sid": "SecureTransport", + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": ["arn:" + PARTITION + ":s3:::BUCKET_NAME", "arn:" + PARTITION + ":s3:::BUCKET_NAME/*"], + "Condition": {"Bool": {"aws:SecureTransport": "false"}}, + }, + ], + } + + def query_for_s3_bucket(self, bucket): + try: + self.S3_RESOURCE.meta.client.head_bucket(Bucket=bucket) + return True + except ClientError: + return False + + def create_s3_bucket(self, bucket): + if self.REGION != "us-east-1": + create_bucket = self.S3_CLIENT.create_bucket( + ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, ObjectOwnership="BucketOwnerPreferred" + ) + else: + create_bucket = self.S3_CLIENT.create_bucket(ACL="private", Bucket=bucket, ObjectOwnership="BucketOwnerPreferred") + self.LOGGER.info(f"Bucket created: {create_bucket}") + self.apply_bucket_policy(bucket) + + def apply_bucket_policy(self, bucket): + self.LOGGER.info(self.BUCKET_POLICY_TEMPLATE) + for sid in self.BUCKET_POLICY_TEMPLATE["Statement"]: + if isinstance(sid["Resource"], list): + sid["Resource"] = list(map(lambda x: x.replace("BUCKET_NAME", bucket), sid["Resource"])) # noqa C417 + else: + sid["Resource"] = sid["Resource"].replace("BUCKET_NAME", bucket) + self.LOGGER.info(self.BUCKET_POLICY_TEMPLATE) + bucket_policy_response = self.S3_CLIENT.put_bucket_policy( + Bucket=bucket, + Policy=json.dumps(self.BUCKET_POLICY_TEMPLATE), + ) + self.LOGGER.info(bucket_policy_response) + + def s3_resource_check(self, bucket): + self.LOGGER.info(f"Checking for {bucket} s3 bucket...") + if self.query_for_s3_bucket(bucket) is False: + self.LOGGER.info(f"Bucket not found, creating {bucket} s3 bucket...") + self.create_s3_bucket(bucket) + + # todo(liamschn): parameter formatting validation diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_staging.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_staging.py new file mode 100644 index 000000000..035775966 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_staging.py @@ -0,0 +1,225 @@ +# import json +import logging +import urllib3 +from io import BytesIO +from zipfile import ZipFile +from zipfile import ZIP_DEFLATED +import os +import shutil +import subprocess # noqa S404 (best practice for calling pip from script) +import sys +import boto3 +from botocore.exceptions import NoCredentialsError + +# import zipfile +import shutil + +import pip + +# todo(liamschn): need to exclude "inline_" files from the staging process + + +class sra_staging: + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + # class attributes # todo(liamschn): make these parameters + REPO_ZIP_URL = "https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip" + REPO_BRANCH = REPO_ZIP_URL.split(".")[1].split("/")[len(REPO_ZIP_URL.split(".")[1].split("/")) - 1] + STAGING_UPLOAD_FOLDER = "/tmp/sra_staging_upload" + STAGING_TEMP_FOLDER = "/tmp/sra_temp" + SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" + + STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): set a default value?? + PIP_VERSION = pip.__version__ + URLLIB3_VERSION = urllib3.__version__ + + # class methods + def pip_install(self, requirements: str, package_temp_directory: str, individual: bool = False) -> None: + """Use pip to install package. + + Args: + requirements: requirements file or name of the package to install (see individual arg) + package_temp_directory: target directory where packages will be installed + individual: set to True if specifying a specific package + """ + self.LOGGER.info(f"...Downloading requirements ({requirements}) to {package_temp_directory} target folder") + if individual is False: + subprocess.check_call( # noqa S603 (trusted input from parameters passed) + [ + sys.executable, + "-m", + "pip", + "install", + "-r", + requirements, + "--upgrade", + "--target", + f"{package_temp_directory}", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + subprocess.check_call( # noqa S603 (trusted input from parameters passed) + [ + sys.executable, + "-m", + "pip", + "install", + requirements, + "--upgrade", + "--target", + f"{package_temp_directory}", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def zip_folder(self, path: str, zip_file: ZipFile, layer: bool = False) -> None: + """Create a zipped file from a folder. + + Args: + path: path to the file + zip_file: zipped file handle + layer: true if lambda layer, false otherwise + """ + self.LOGGER.info(f"Creating code zip file") + for root, dirs, files in os.walk(path): # noqa B007 (dirs variable required & unused) + for discovered_file in files: + if layer is False: + # LOGGER.info("Adding lambda code to zip file") + zip_file.write( + os.path.join(root, discovered_file), + os.path.relpath(os.path.join(root, discovered_file), path), + ) + else: + # LOGGER.info("Adding layer code to zip file") + zip_file.write( + os.path.join(root, discovered_file), + os.path.relpath(os.path.join(root, discovered_file), os.path.join(path, "..")), + ) + + def download_code_library(self, repo_zip_url): + self.LOGGER.info(f"Downloading code library from {repo_zip_url}") + http = urllib3.PoolManager() + repo_zip_file = http.request("GET", repo_zip_url) + self.LOGGER.info(f"HTTP status code: {repo_zip_file.status}") + zipfile = ZipFile(BytesIO(repo_zip_file.data)) + zipfile.extractall("/tmp") + self.LOGGER.info("Files extracted to /tmp") + self.LOGGER.info(f"tmp directory listing: {os.listdir('/tmp')}") + + def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, solutions_dir): + if os.path.exists(staging_upload_folder): + shutil.rmtree(staging_upload_folder) + if os.path.exists(staging_temp_folder): + shutil.rmtree(staging_temp_folder) + os.mkdir(staging_upload_folder) + os.mkdir(staging_temp_folder) + + service_folders = os.listdir(solutions_dir) + for service in service_folders: + service_dir = solutions_dir + "/" + service + if os.path.isdir(service_dir): + service_solutions_folders = sorted(os.listdir(service_dir)) + for solution in sorted(service_solutions_folders): + if os.path.isdir(os.path.join(service_dir, solution)): + self.LOGGER.info(f"Solution: {solution}") + # if solution != "inspector_org": # for debugging + # continue + source_files = os.path.join(service_dir, solution, "lambda/src") + + upload_folder_name = "/sra-" + solution.replace("_", "-") + os.mkdir(staging_temp_folder + upload_folder_name) + os.mkdir(staging_upload_folder + upload_folder_name) + + # lambda code + if os.path.exists(source_files) and os.path.exists(os.path.join(source_files, "requirements.txt")): + self.LOGGER.info(f"Downloading required packages for {solution} lambda...") + self.pip_install( + os.path.join(service_dir, solution, "lambda/src/requirements.txt"), + staging_temp_folder + upload_folder_name + "/lambda", + ) + for source_file in os.listdir(source_files): + if os.path.isdir(os.path.join(source_files, source_file)): + self.LOGGER.info(f"{source_file} is a directory, skipping...") + else: + shutil.copy(os.path.join(source_files, source_file), staging_temp_folder + upload_folder_name + "/lambda") + lambda_target_folder = staging_upload_folder + upload_folder_name + "/lambda_code" + self.LOGGER.info(f"Zipping lambda code for {solution} lambda to {lambda_target_folder}{upload_folder_name}.zip...") + os.mkdir(lambda_target_folder) + zip_file = ZipFile(f"{lambda_target_folder}/{upload_folder_name}.zip", "w", ZIP_DEFLATED) + self.zip_folder(f"{staging_temp_folder + upload_folder_name}/lambda", zip_file) + zip_file.close() + + # layer code + layer_files = os.path.join(service_dir, solution, "layer") + if os.path.exists(layer_files): + for package in os.listdir(layer_files): + self.LOGGER.info(f"Downloading required package ({package}) for {solution} lambda...") + self.pip_install(package, staging_temp_folder + upload_folder_name + "/layer/python", True) + layer_target_folder = staging_upload_folder + upload_folder_name + "/layer_code" + self.LOGGER.info(f"Zipping layer code for {solution} to {layer_target_folder}{upload_folder_name}.zip...") + os.mkdir(layer_target_folder) + zip_file = ZipFile(f"{layer_target_folder}/{upload_folder_name}-layer.zip", "w", ZIP_DEFLATED) + self.zip_folder(f"{staging_temp_folder + upload_folder_name}/layer/python", zip_file, True) + zip_file.close() + + # CloudFormation template code + cfn_template_files = os.path.join(service_dir, solution, "templates") + if os.path.exists(cfn_template_files): + cfn_templates_target_folder = staging_upload_folder + upload_folder_name + "/templates" + self.LOGGER.info(f"Copying CloudFormation templates for {solution} to {cfn_templates_target_folder}...") + os.mkdir(cfn_templates_target_folder) + + for cfn_template_file in os.listdir(cfn_template_files): + if os.path.isdir(os.path.join(cfn_template_files, cfn_template_file)): + self.LOGGER.info(f"{cfn_template_file} is a directory, skipping...") + else: + shutil.copy(os.path.join(cfn_template_files, cfn_template_file), cfn_templates_target_folder) + + # # Debugging output: + # stage_dir = "staged files:\n" + # temp_dir = "temp files:\n" + # for root, dirs, files in os.walk(os.path.join(staging_upload_folder, "sra-common-prerequisites")): + # for name in files: + # stage_dir = stage_dir + os.path.join(root, name) + "\n" + # # self.LOGGER.info(os.path.join(root, name)) + # for name in dirs: + # stage_dir = stage_dir + os.path.join(root, name) + "\n" + # # self.LOGGER.info(os.path.join(root, name)) + # for root, dirs, files in os.walk(os.path.join(staging_temp_folder, "sra-common-prerequisites")): + # for name in files: + # temp_dir = temp_dir + os.path.join(root, name) + "\n" + # # self.LOGGER.info(os.path.join(root, name)) + # for name in dirs: + # temp_dir = temp_dir + os.path.join(root, name) + "\n" + # # self.LOGGER.info(os.path.join(root, name)) + + # self.LOGGER.info(stage_dir) + # self.LOGGER.info(temp_dir) + + def stage_code_to_s3(self, directory_path, bucket_name, s3_path): + """ + Uploads the prepared code directory to the staging S3 bucket. + + :param directory_path: Local path to directory + :param bucket_name: Name of the S3 bucket + :param s3_path: S3 path where the directory will be uploaded + """ + s3_client = boto3.client("s3") + + for root, dirs, files in os.walk(directory_path): + for file in files: + local_path = os.path.join(root, file) + + relative_path = os.path.relpath(local_path, directory_path) + s3_file_path = relative_path + try: + s3_client.upload_file(local_path, bucket_name, s3_file_path) + except NoCredentialsError: + self.LOGGER.info("Credentials not available") + return From d61e9d20fd592884c18f90faccb359f04e8bece8 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 23 Jul 2024 21:32:59 -0600 Subject: [PATCH 003/395] more scaffolding --- .../lambda/rules/sra_check_iam_users.py | 118 ++++++++++++++++++ .../src/{sra_staging.py => sra_github.py} | 0 2 files changed, 118 insertions(+) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users.py rename aws_sra_examples/solutions/genai/bedrock_org/lambda/src/{sra_staging.py => sra_github.py} (100%) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users.py new file mode 100644 index 000000000..2cc2c954d --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users.py @@ -0,0 +1,118 @@ +import botocore +import boto3 +import json +import datetime +import logging +import os # maybe not needed for logging + +# Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). +ASSUME_ROLE_MODE = False +DEFAULT_RESOURCE_TYPE = 'AWS::::Account' + +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") + + +# This gets the client after assuming the Config service role +# either in the same AWS account or cross-account. +def get_client(service, event): + """Return the service boto client. It should be used instead of directly calling the client. + Keyword arguments: + service -- the service name used for calling the boto.client() + event -- the event variable given in the lambda handler + """ + if not ASSUME_ROLE_MODE: + return boto3.client(service) + credentials = get_assume_role_credentials(event["executionRoleArn"]) + return boto3.client(service, aws_access_key_id=credentials['AccessKeyId'], + aws_secret_access_key=credentials['SecretAccessKey'], + aws_session_token=credentials['SessionToken'] + ) + +def get_assume_role_credentials(role_arn): + sts_client = boto3.client('sts') + try: + assume_role_response = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="configLambdaExecution") + return assume_role_response['Credentials'] + except botocore.exceptions.ClientError as ex: + # Scrub error message for any internal account info leaks + if 'AccessDenied' in ex.response['Error']['Code']: + ex.response['Error']['Message'] = "AWS Config does not have permission to assume the IAM role." + else: + ex.response['Error']['Message'] = "InternalError" + ex.response['Error']['Code'] = "InternalError" + raise ex + +# Check whether the message is a ScheduledNotification or not. +def is_scheduled_notification(message_type): + return message_type == 'ScheduledNotification' + +def count_resource_types(applicable_resource_type, next_token, count): + resource_identifier = AWS_CONFIG_CLIENT.list_discovered_resources(resourceType=applicable_resource_type, nextToken=next_token) + updated = count + len(resource_identifier['resourceIdentifiers']); + return updated + +# Evaluates the configuration items in the snapshot and returns the compliance value to the handler. +def evaluate_compliance(max_count, actual_count): + return 'NON_COMPLIANT' if int(actual_count) > int(max_count) else 'COMPLIANT' + +def evaluate_parameters(rule_parameters): + if 'applicableResourceType' not in rule_parameters: + raise ValueError('The parameter with "applicableResourceType" as key must be defined.') + if not rule_parameters['applicableResourceType']: + raise ValueError('The parameter "applicableResourceType" must have a defined value.') + return rule_parameters + +# This generate an evaluation for config +def build_evaluation(resource_id, compliance_type, event, resource_type=DEFAULT_RESOURCE_TYPE, annotation=None): + """Form an evaluation as a dictionary. Usually suited to report on scheduled rules. + Keyword arguments: + resource_id -- the unique id of the resource to report + compliance_type -- either COMPLIANT, NON_COMPLIANT or NOT_APPLICABLE + event -- the event variable given in the lambda handler + resource_type -- the CloudFormation resource type (or AWS::::Account) to report on the rule (default DEFAULT_RESOURCE_TYPE) + annotation -- an annotation to be added to the evaluation (default None) + """ + eval_cc = {} + if annotation: + eval_cc['Annotation'] = annotation + eval_cc['ComplianceResourceType'] = resource_type + eval_cc['ComplianceResourceId'] = resource_id + eval_cc['ComplianceType'] = compliance_type + eval_cc['OrderingTimestamp'] = str(json.loads(event['invokingEvent'])['notificationCreationTime']) + return eval_cc + +def lambda_handler(event, context): + LOGGER.info(event) + global AWS_CONFIG_CLIENT + + evaluations = [] + rule_parameters = {} + resource_count = 0 + max_count = 0 + + invoking_event = json.loads(event['invokingEvent']) + if 'ruleParameters' in event: + rule_parameters = json.loads(event['ruleParameters']) + + valid_rule_parameters = evaluate_parameters(rule_parameters) + + compliance_value = 'NOT_APPLICABLE' + + AWS_CONFIG_CLIENT = get_client('config', event) + if is_scheduled_notification(invoking_event['messageType']): + result_resource_count = count_resource_types(valid_rule_parameters['applicableResourceType'], '', resource_count) + + if valid_rule_parameters.get('maxCount'): + max_count = valid_rule_parameters['maxCount'] + LOGGER.info(f"maxCount set to: {max_count} from rule parameter") + else: + LOGGER.info(f"maxCount set to: {max_count} as default") + + LOGGER.info(f"result resource count: {result_resource_count}") + compliance_value = evaluate_compliance(max_count, result_resource_count) + evaluations.append(build_evaluation(event['accountId'], compliance_value, event, resource_type=DEFAULT_RESOURCE_TYPE)) + response = AWS_CONFIG_CLIENT.put_evaluations(Evaluations=evaluations, ResultToken=event['resultToken']) \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_staging.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_github.py similarity index 100% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_staging.py rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_github.py From fc9c52d9b856057db796cb51e00b626133dd9506 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 29 Jul 2024 16:40:35 -0600 Subject: [PATCH 004/395] still working out mechanism to deploy rule code --- .../sra_check_iam_users.py | 0 .../lambda/src/{sra_github.py => sra_repo.py} | 98 ++++++++++++++----- 2 files changed, 72 insertions(+), 26 deletions(-) rename aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/{ => sra_check_iam_users}/sra_check_iam_users.py (100%) rename aws_sra_examples/solutions/genai/bedrock_org/lambda/src/{sra_github.py => sra_repo.py} (66%) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/sra_check_iam_users.py similarity index 100% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users.py rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/sra_check_iam_users.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_github.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py similarity index 66% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_github.py rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 035775966..615a5fef8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_github.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -8,32 +8,37 @@ import shutil import subprocess # noqa S404 (best practice for calling pip from script) import sys -import boto3 -from botocore.exceptions import NoCredentialsError +# import boto3 +# from botocore.exceptions import NoCredentialsError # import zipfile import shutil -import pip +# import pip # todo(liamschn): need to exclude "inline_" files from the staging process -class sra_staging: +class sra_repo: # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) # class attributes # todo(liamschn): make these parameters + REPO_RAW_FILE_URL_PREFIX = "https://raw.githubusercontent.com/liamschn/aws-security-reference-architecture-examples/sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/" + RULE_LAMBDA_FILES = {} + RULE_LAMBDA_FILES["sra_check_iam_users"] = "sra_check_iam_users.py" + REPO_BRANCH = REPO_RAW_FILE_URL_PREFIX.split("/")[5] + REPO_ZIP_URL = "https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip" REPO_BRANCH = REPO_ZIP_URL.split(".")[1].split("/")[len(REPO_ZIP_URL.split(".")[1].split("/")) - 1] STAGING_UPLOAD_FOLDER = "/tmp/sra_staging_upload" STAGING_TEMP_FOLDER = "/tmp/sra_temp" SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" - STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): set a default value?? - PIP_VERSION = pip.__version__ + STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): get from SSM parameter + # PIP_VERSION = pip.__version__ URLLIB3_VERSION = urllib3.__version__ # class methods @@ -78,6 +83,7 @@ def pip_install(self, requirements: str, package_temp_directory: str, individual stderr=subprocess.DEVNULL, ) + def zip_folder(self, path: str, zip_file: ZipFile, layer: bool = False) -> None: """Create a zipped file from a folder. @@ -102,6 +108,16 @@ def zip_folder(self, path: str, zip_file: ZipFile, layer: bool = False) -> None: os.path.relpath(os.path.join(root, discovered_file), os.path.join(path, "..")), ) + # def download_file(self, repo_url_prefix, repo_file, local_folder): + # self.LOGGER.info(f"Downloading {repo_file} file from {repo_url_prefix}") + # http = urllib3.PoolManager() + # # repo_code_file = http.request("GET", + # with open(f"/tmp/{local_folder}{repo_file}", 'wb') as out: + # repo_code_file = http.request("GET", repo_url_prefix + repo_file) + # self.LOGGER.info(f"HTTP status code: {repo_code_file.status}") + # shutil.copyfileobj(repo_code_file, out) + # self.LOGGER.info(f"/tmp/{local_folder} directory listing: {os.listdir('/tmp/' + local_folder)}") + def download_code_library(self, repo_zip_url): self.LOGGER.info(f"Downloading code library from {repo_zip_url}") http = urllib3.PoolManager() @@ -112,6 +128,56 @@ def download_code_library(self, repo_zip_url): self.LOGGER.info("Files extracted to /tmp") self.LOGGER.info(f"tmp directory listing: {os.listdir('/tmp')}") + def prepare_config_rules_for_staging(self, solution, staging_upload_folder, staging_temp_folder, solutions_dir): + # self.LOGGER.info(f"listing config rules for {solution}") + if os.path.exists(staging_upload_folder): + shutil.rmtree(staging_upload_folder) + if os.path.exists(staging_temp_folder): + shutil.rmtree(staging_temp_folder) + os.mkdir(staging_upload_folder) + os.mkdir(staging_temp_folder) + + service_folders = os.listdir(solutions_dir) + for service in service_folders: + service_dir = solutions_dir + "/" + service + if os.path.isdir(service_dir): + service_solutions_folders = sorted(os.listdir(service_dir)) + for solution in sorted(service_solutions_folders): + if os.path.isdir(os.path.join(service_dir, solution)): + self.LOGGER.info(f"Solution: {solution}") + if os.path.isdir(os.path.join(service_dir, solution, "rules")): # config rules folder + solution_config_rules = os.path.join(service_dir, solution, "rules") + config_rule_folders = sorted(os.listdir(solution_config_rules)) + for config_rule in sorted(config_rule_folders): + self.LOGGER.info(f"config rule: {config_rule} (in the {solution} solution)") + config_rule_source_files = os.path.join(solution_config_rules, config_rule) + upload_folder_name = "/sra-" + solution.replace("_", "-") + config_rule_upload_folder_name = "/" + config_rule.replace("_", "-") + os.mkdir(staging_temp_folder + upload_folder_name) + os.mkdir(staging_temp_folder + upload_folder_name + config_rule_upload_folder_name) + os.mkdir(staging_upload_folder + upload_folder_name) + os.mkdir(staging_upload_folder + upload_folder_name + config_rule_upload_folder_name) + + # lambda code + if os.path.exists(config_rule_source_files) and os.path.exists(os.path.join(config_rule_source_files, "requirements.txt")): + self.LOGGER.info(f"Downloading required packages for {solution} lambda...") + self.pip_install( + os.path.join(config_rule_source_files, "requirements.txt"), + staging_temp_folder + upload_folder_name + config_rule_upload_folder_name, + ) + for source_file in os.listdir(config_rule_source_files): + if os.path.isdir(os.path.join(config_rule_source_files, source_file)): + self.LOGGER.info(f"{source_file} is a directory, skipping...") + else: + shutil.copy(os.path.join(config_rule_source_files, source_file), staging_temp_folder + upload_folder_name + config_rule_upload_folder_name) + lambda_target_folder = staging_upload_folder + upload_folder_name + config_rule_upload_folder_name + "/lambda_code" + self.LOGGER.info(f"Zipping config rule code for {solution} / {config_rule} lambda to {lambda_target_folder}{config_rule_upload_folder_name}.zip...") + os.mkdir(lambda_target_folder) + zip_file = ZipFile(f"{lambda_target_folder}/{config_rule_upload_folder_name}.zip", "w", ZIP_DEFLATED) + self.zip_folder(f"{staging_temp_folder + upload_folder_name + config_rule_upload_folder_name}/lambda", zip_file) + zip_file.close() + + def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, solutions_dir): if os.path.exists(staging_upload_folder): shutil.rmtree(staging_upload_folder) @@ -181,26 +247,6 @@ def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, s else: shutil.copy(os.path.join(cfn_template_files, cfn_template_file), cfn_templates_target_folder) - # # Debugging output: - # stage_dir = "staged files:\n" - # temp_dir = "temp files:\n" - # for root, dirs, files in os.walk(os.path.join(staging_upload_folder, "sra-common-prerequisites")): - # for name in files: - # stage_dir = stage_dir + os.path.join(root, name) + "\n" - # # self.LOGGER.info(os.path.join(root, name)) - # for name in dirs: - # stage_dir = stage_dir + os.path.join(root, name) + "\n" - # # self.LOGGER.info(os.path.join(root, name)) - # for root, dirs, files in os.walk(os.path.join(staging_temp_folder, "sra-common-prerequisites")): - # for name in files: - # temp_dir = temp_dir + os.path.join(root, name) + "\n" - # # self.LOGGER.info(os.path.join(root, name)) - # for name in dirs: - # temp_dir = temp_dir + os.path.join(root, name) + "\n" - # # self.LOGGER.info(os.path.join(root, name)) - - # self.LOGGER.info(stage_dir) - # self.LOGGER.info(temp_dir) def stage_code_to_s3(self, directory_path, bucket_name, s3_path): """ From 7ab4e43c01a72375b0e685551b22ac3787e4a3b7 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 30 Jul 2024 10:13:36 -0600 Subject: [PATCH 005/395] experiment/prototype config rule code staging --- .../genai/bedrock_org/lambda/src/app.py | 47 +++-- .../bedrock_org/lambda/src/sra_dynamodb.py | 177 ++++++++++++++++++ .../genai/bedrock_org/lambda/src/sra_repo.py | 48 ++--- .../genai/bedrock_org/lambda/src/sra_s3.py | 24 +++ .../genai/bedrock_org/lambda/src/sra_sts.py | 122 ++++++++++++ 5 files changed, 376 insertions(+), 42 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a4528c51d..112190fbe 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -4,14 +4,13 @@ import boto3 import cfnresponse from botocore.exceptions import ClientError + import sra_s3 -import sra_staging +import sra_repo import sra_ssm_params import sra_iam -import sra_kms import sra_dynamodb import sra_sts -import sra_cfn # import sra_lambda @@ -29,7 +28,7 @@ LOGGER.setLevel(log_level) # Global vars -STAGING_BUCKET: str = "" +# STAGING_BUCKET: str = "" RESOURCE_TYPE: str = "" STATE_TABLE: str = "sra_state" SOLUTION_NAME: str = "sra-common-prerequisites" @@ -49,31 +48,34 @@ # todo(liamschn): can these files exist in some central location to be shared with other solutions? ssm_params = sra_ssm_params.sra_ssm_params() iam = sra_iam.sra_iam() -# kms = sra_kms.sra_kms() -# dynamodb = sra_dynamodb.sra_dynamodb() +dynamodb = sra_dynamodb.sra_dynamodb() sts = sra_sts.sra_sts() -# cfn = sra_cfn.sra_cfn() - +repo = sra_repo.sra_repo() +s3 = sra_s3.sra_s3() def get_resource_parameters(event): global DRY_RUN - global staging LOGGER.info("Getting resource params...") # TODO(liamschn): what parameters do we need for this solution? - ssm_params.CONTROL_TOWER = event["ResourceProperties"]["CONTROL_TOWER"] - ssm_params.OTHER_REGIONS = event["ResourceProperties"]["OTHER_REGIONS"] - ssm_params.OTHER_SECURITY_ACCT = event["ResourceProperties"]["OTHER_SECURITY_ACCT"] - ssm_params.OTHER_LOG_ARCHIVE_ACCT = event["ResourceProperties"]["OTHER_LOG_ARCHIVE_ACCT"] - ssm_params.SRA_STAGING_BUCKET = event["ResourceProperties"]["SRA_STAGING_BUCKET"] + "-" + ACCOUNT + "-" + REGION - - sts.CONFIGURATION_ROLE = event["ResourceProperties"]["CONFIGURATION_ROLE"] + # event["ResourceProperties"]["CONTROL_TOWER"] + repo.REPO_ZIP_URL = event["ResourceProperties"]["SRA_REPO_ZIP_URL"] + + sts.CONFIGURATION_ROLE = "sra-execution" + staging_bucket_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/staging-s3-bucket-name") + if staging_bucket_param[0] is True: + s3.STAGING_BUCKET = staging_bucket_param[1] + LOGGER.info(f"Successfully retrieved the SRA staging bucket parameter: {s3.STAGING_BUCKET}") + else: + LOGGER.info("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") + raise ValueError("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") from None - # dry run parameter if event["ResourceProperties"]["DRY_RUN"] == "true": + # dry run LOGGER.info("Dry run enabled...") DRY_RUN = True else: + # live run LOGGER.info("Dry run disabled...") DRY_RUN = False @@ -82,7 +84,16 @@ def create_event(event, context): event_info = {"Event": event} LOGGER.info(event_info) - # 0) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) + # 1) Stage config rule lambda code + if DRY_RUN is False: + LOGGER.info("Live run: downloading and staging the config rule code...") + repo.download_code_library(repo.REPO_ZIP_URL) + repo.prepare_config_rules_for_staging("bedrock_org", repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) + s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET, "/") + + + + # 2) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py new file mode 100644 index 000000000..67336462b --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -0,0 +1,177 @@ +import logging +import boto3 +from boto3.dynamodb.conditions import Key, Attr +import os +import random +import string +from datetime import datetime +from time import sleep +import botocore + + +class sra_dynamodb: + PROFILE = "default" + UNEXPECTED = "Unexpected!" + + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + def __init__(self, profile="default") -> None: + self.PROFILE = profile + try: + if self.PROFILE != "default": + self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session(profile_name=self.PROFILE) + else: + self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + + self.DYNAMODB_RESOURCE = self.MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + except Exception: + self.LOGGER.exception(self.UNEXPECTED) + raise ValueError("Unexpected error!") from None + + def create_table(self, table_name, dynamodb_client): + # Define table schema + key_schema = [ + {"AttributeName": "solution_name", "KeyType": "HASH"}, + {"AttributeName": "record_id", "KeyType": "RANGE"}, + ] # Hash key # Range key + attribute_definitions = [ + {"AttributeName": "solution_name", "AttributeType": "S"}, # String type + {"AttributeName": "record_id", "AttributeType": "S"}, # String type + ] + provisioned_throughput = {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} + + # Create table + try: + dynamodb_client.create_table( + TableName=table_name, KeySchema=key_schema, AttributeDefinitions=attribute_definitions, ProvisionedThroughput=provisioned_throughput + ) + self.LOGGER.info(f"{table_name} dynamodb table created successfully.") + except Exception as e: + self.LOGGER.info("Error creating table:", e) + # wait for the table to become active + while True: + wait_response = dynamodb_client.describe_table(TableName=table_name) + if wait_response["Table"]["TableStatus"] == "ACTIVE": + self.LOGGER.info(f"{table_name} dynamodb table is active") + break + else: + self.LOGGER.info(f"{table_name} dynamodb table is not active yet. Status is '{wait_response['Table']['TableStatus']}' Waiting...") + # TODO(liamschn): need to add a maximum retry mechanism here + sleep(5) + + def table_exists(self, table_name, dynamodb_client): + # Check if table exists + try: + dynamodb_client.describe_table(TableName=table_name) + self.LOGGER.info(f"{table_name} dynamodb table already exists...") + return True + except dynamodb_client.exceptions.ResourceNotFoundException: + self.LOGGER.info(f"{table_name} dynamodb table does not exist...") + return False + + def generate_id(self): + new_record_id = str("".join(random.choice(string.ascii_letters + string.digits + "-_") for ch in range(8))) + return new_record_id + + def get_date_time(self): + now = datetime.now() + return now.strftime("%Y%m%d%H%M%S") + + def insert_item(self, table_name, dynamodb_resource, solution_name): + table = dynamodb_resource.Table(table_name) + record_id = self.generate_id() + date_time = self.get_date_time() + response = table.put_item( + Item={ + "solution_name": solution_name, + "record_id": record_id, + "date_time": date_time, + } + ) + # self.LOGGER.info({"insert_record_response": response}) + return record_id, date_time + + def update_item(self, table_name, dynamodb_resource, solution_name, record_id, attributes_and_values): + table = dynamodb_resource.Table(table_name) + update_expression = "" + expression_attribute_values = {} + for attribute in attributes_and_values: + if update_expression == "": + update_expression = "set " + attribute + "=:" + attribute + else: + update_expression = update_expression + ", " + attribute + "=:" + attribute + expression_attribute_values[":" + attribute] = attributes_and_values[attribute] + # self.LOGGER.info(f"update expression: {update_expression}") + response = table.update_item( + Key={ + "solution_name": solution_name, + "record_id": record_id, + }, + UpdateExpression=update_expression, + ExpressionAttributeValues=expression_attribute_values, + ReturnValues="UPDATED_NEW", + ) + return response + + def find_item(self, table_name, dynamodb_resource, solution_name, additional_attributes): + table = dynamodb_resource.Table(table_name) + expression_attribute_values = {":solution_name": solution_name} + + filter_expression = " AND ".join([f"{attr} = :{attr}" for attr in additional_attributes.keys()]) + + expression_attribute_values.update({f":{attr}": value for attr, value in additional_attributes.items()}) + + query_params = {} + + query_params = { + "KeyConditionExpression": "solution_name = :solution_name", + "ExpressionAttributeValues": expression_attribute_values, + "FilterExpression": filter_expression, + } + + response = table.query(**query_params) + + if len(response["Items"]) > 1: + self.LOGGER.info( + f"Found more than one record that matched record id {response['Items'][0]['record_id']}. Review {table_name} dynamodb table to determine cause." + ) + elif len(response["Items"]) < 1: + return False, None + + return True, response["Items"][0] + + def get_unique_values_from_list(self, list_of_values): + unique_values = [] + for value in list_of_values: + if value not in unique_values: + unique_values.append(value) + return unique_values + + def get_distinct_solutions_and_accounts(self, table_name, dynamodb_resource): + table = dynamodb_resource.Table(table_name) + response = table.scan() + solution_names = [item["solution_name"] for item in response["Items"]] + solution_names = self.get_unique_values_from_list(solution_names) + accounts = [item["account"] for item in response["Items"]] + accounts = self.get_unique_values_from_list(accounts) + return solution_names, accounts + + def get_resources_for_solutions_by_account(self, table_name, dynamodb_resource, solutions, account): + table = dynamodb_resource.Table(table_name) + query_results = {} + for solution in solutions: + # expression_attribute_values = {":solution_name": solution} + # filter_expression = {":account": account} + + query_params = { + "KeyConditionExpression": "solution_name = :solution_name", + "ExpressionAttributeValues": {":solution_name": solution, ":account": account}, + "FilterExpression": "account = :account", + } + + response = table.query(**query_params) + self.LOGGER.info(f"response: {response}") + query_results[solution] = response + return query_results diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 615a5fef8..773b113de 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -14,7 +14,7 @@ # import zipfile import shutil -# import pip +import pip # todo(liamschn): need to exclude "inline_" files from the staging process @@ -26,10 +26,10 @@ class sra_repo: LOGGER.setLevel(log_level) # class attributes # todo(liamschn): make these parameters - REPO_RAW_FILE_URL_PREFIX = "https://raw.githubusercontent.com/liamschn/aws-security-reference-architecture-examples/sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/" - RULE_LAMBDA_FILES = {} - RULE_LAMBDA_FILES["sra_check_iam_users"] = "sra_check_iam_users.py" - REPO_BRANCH = REPO_RAW_FILE_URL_PREFIX.split("/")[5] + # REPO_RAW_FILE_URL_PREFIX = "https://raw.githubusercontent.com/liamschn/aws-security-reference-architecture-examples/sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/" + # RULE_LAMBDA_FILES = {} + # RULE_LAMBDA_FILES["sra_check_iam_users"] = "sra_check_iam_users.py" + # REPO_BRANCH = REPO_RAW_FILE_URL_PREFIX.split("/")[5] REPO_ZIP_URL = "https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip" REPO_BRANCH = REPO_ZIP_URL.split(".")[1].split("/")[len(REPO_ZIP_URL.split(".")[1].split("/")) - 1] @@ -38,7 +38,7 @@ class sra_repo: SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): get from SSM parameter - # PIP_VERSION = pip.__version__ + PIP_VERSION = pip.__version__ URLLIB3_VERSION = urllib3.__version__ # class methods @@ -248,24 +248,24 @@ def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, s shutil.copy(os.path.join(cfn_template_files, cfn_template_file), cfn_templates_target_folder) - def stage_code_to_s3(self, directory_path, bucket_name, s3_path): - """ - Uploads the prepared code directory to the staging S3 bucket. + # def stage_code_to_s3(self, directory_path, bucket_name, s3_path): + # """ + # Uploads the prepared code directory to the staging S3 bucket. - :param directory_path: Local path to directory - :param bucket_name: Name of the S3 bucket - :param s3_path: S3 path where the directory will be uploaded - """ - s3_client = boto3.client("s3") + # :param directory_path: Local path to directory + # :param bucket_name: Name of the S3 bucket + # :param s3_path: S3 path where the directory will be uploaded + # """ + # s3_client = boto3.client("s3") - for root, dirs, files in os.walk(directory_path): - for file in files: - local_path = os.path.join(root, file) + # for root, dirs, files in os.walk(directory_path): + # for file in files: + # local_path = os.path.join(root, file) - relative_path = os.path.relpath(local_path, directory_path) - s3_file_path = relative_path - try: - s3_client.upload_file(local_path, bucket_name, s3_file_path) - except NoCredentialsError: - self.LOGGER.info("Credentials not available") - return + # relative_path = os.path.relpath(local_path, directory_path) + # s3_file_path = relative_path + # try: + # s3_client.upload_file(local_path, bucket_name, s3_file_path) + # except NoCredentialsError: + # self.LOGGER.info("Credentials not available") + # return diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 5ae730d1f..98843e57e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -99,3 +99,27 @@ def s3_resource_check(self, bucket): self.create_s3_bucket(bucket) # todo(liamschn): parameter formatting validation + + + def stage_code_to_s3(self, directory_path, bucket_name, s3_path): + """ + Uploads the prepared code directory to the staging S3 bucket. + + :param directory_path: Local path to directory + :param bucket_name: Name of the S3 bucket + :param s3_path: S3 path where the directory will be uploaded + """ + # s3_client = boto3.client("s3") + + for root, dirs, files in os.walk(directory_path): + for file in files: + local_path = os.path.join(root, file) + + relative_path = os.path.relpath(local_path, directory_path) + s3_file_path = relative_path + try: + self.S3_CLIENT.upload_file(local_path, bucket_name, s3_file_path) + except NoCredentialsError: + self.LOGGER.info("Credentials not available") + return + self.LOGGER.info(f"Uploaded {local_path} to {bucket_name} {s3_file_path}") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py new file mode 100644 index 000000000..d0603c76e --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -0,0 +1,122 @@ +import logging +import os + +import boto3 +import botocore + + +class sra_sts: + PROFILE = "default" + + UNEXPECTED = "Unexpected!" + # TODO(liamschn): this needs to be made into an SSM parameter + CONFIGURATION_ROLE: str = "" + + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + def __init__(self, profile="default") -> None: + self.PROFILE = profile + print(f"STS PROFILE INFO: {self.PROFILE}") + + try: + if self.PROFILE != "default": + self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session(profile_name=self.PROFILE) + print(f"STS INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") + else: + print(f"STS PROFILE AGAIN: {self.PROFILE}") + self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + + self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") + self.HOME_REGION = self.MANAGEMENT_ACCOUNT_SESSION.region_name + self.LOGGER.info(f"STS detected home region: {self.HOME_REGION}") + # SM_HOST_NAME = urllib.parse.urlparse(boto3.client("secretsmanager", region_name=HOME_REGION).meta.endpoint_url).hostname + self.PARTITION: str = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) + # LOGGER.info(f"Detected management account (current account): {MANAGEMENT_ACCOUNT}") + except botocore.exceptions.ClientError as error: + if error.response["Error"]["Code"] == "ExpiredToken": + self.LOGGER.info("Token has expired, please re-run with proper credentials set.") + self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") + self.HOME_REGION = self.MANAGEMENT_ACCOUNT_SESSION.region_name + self.PARTITION: str = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) + + else: + self.LOGGER.info(f"Error: {error}") + raise error + + try: + self.MANAGEMENT_ACCOUNT = self.STS_CLIENT.get_caller_identity().get("Account") + except botocore.exceptions.NoCredentialsError: + self.LOGGER.info("No credentials found, please re-run with proper credentials set.") + except botocore.exceptions.ClientError as error: + if error.response["Error"]["Code"] == "ExpiredToken": + self.LOGGER.info("Token has expired, please re-run with proper credentials set.") + else: + self.LOGGER.info(f"Error: {error}") + raise error + + def assume_role(self, account, role_name, service, region_name): + """Get boto3 client assumed into an account for a specified service. + + Args: + account: aws account id + service: aws service + region_name: aws region + + Returns: + client: boto3 client + """ + print(f"ASSUME ROLE INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") + client = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") + sts_response = client.assume_role( + RoleArn="arn:" + self.PARTITION + ":iam::" + account + ":role/" + role_name, + RoleSessionName="SRA-AssumeCrossAccountRole", + DurationSeconds=900, + ) + + return self.MANAGEMENT_ACCOUNT_SESSION.client( + service, + region_name=region_name, + aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], + aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], + aws_session_token=sts_response["Credentials"]["SessionToken"], + ) + + def assume_role_resource(self, account, role_name, service, region_name): + """Get boto3 resource assumed into an account for a specified service. + + Args: + account: aws account id + service: aws service + region_name: aws region + + Returns: + client: boto3 client + """ + client = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") + sts_response = client.assume_role( + RoleArn="arn:" + self.PARTITION + ":iam::" + account + ":role/" + role_name, + RoleSessionName="SRA-AssumeCrossAccountRole", + DurationSeconds=900, + ) + + return self.MANAGEMENT_ACCOUNT_SESSION.resource( + service, + region_name=region_name, + aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], + aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], + aws_session_token=sts_response["Credentials"]["SessionToken"], + ) + + def get_lambda_execution_role(self): + try: + response = self.STS_CLIENT.get_caller_identity() + # self.LOGGER.info({"get_caller_identity": response}) + # response["UserId"], response["Account"] + return response["Arn"] + except Exception: + self.LOGGER.exception(self.UNEXPECTED) + raise ValueError("Unexpected error getting caller identity.") from None From 60d4482047cffa18ddd30ac0960b7a284f25f1ec Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 30 Jul 2024 10:27:26 -0600 Subject: [PATCH 006/395] confirmed that without requirements.txt, won't stage --- .../solutions/genai/bedrock_org/lambda/src/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt new file mode 100644 index 000000000..b9435de85 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt @@ -0,0 +1,2 @@ +#install latest +crhelper \ No newline at end of file From 35b4caca7d24de495920559387d788e92982189f Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 31 Jul 2024 12:57:18 -0600 Subject: [PATCH 007/395] prototype rule deployment --- .../genai/bedrock_org/lambda/src/app.py | 2 ++ .../genai/bedrock_org/lambda/src/sra_repo.py | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 112190fbe..8f86d53c3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -60,6 +60,8 @@ def get_resource_parameters(event): # TODO(liamschn): what parameters do we need for this solution? # event["ResourceProperties"]["CONTROL_TOWER"] repo.REPO_ZIP_URL = event["ResourceProperties"]["SRA_REPO_ZIP_URL"] + repo.REPO_BRANCH = repo.REPO_ZIP_URL.split(".")[1].split("/")[len(repo.REPO_ZIP_URL.split(".")[1].split("/")) - 1] + repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" sts.CONFIGURATION_ROLE = "sra-execution" staging_bucket_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/staging-s3-bucket-name") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 773b113de..1c5951173 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -33,9 +33,9 @@ class sra_repo: REPO_ZIP_URL = "https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip" REPO_BRANCH = REPO_ZIP_URL.split(".")[1].split("/")[len(REPO_ZIP_URL.split(".")[1].split("/")) - 1] + SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" STAGING_UPLOAD_FOLDER = "/tmp/sra_staging_upload" STAGING_TEMP_FOLDER = "/tmp/sra_temp" - SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): get from SSM parameter PIP_VERSION = pip.__version__ @@ -145,8 +145,8 @@ def prepare_config_rules_for_staging(self, solution, staging_upload_folder, stag for solution in sorted(service_solutions_folders): if os.path.isdir(os.path.join(service_dir, solution)): self.LOGGER.info(f"Solution: {solution}") - if os.path.isdir(os.path.join(service_dir, solution, "rules")): # config rules folder - solution_config_rules = os.path.join(service_dir, solution, "rules") + if os.path.isdir(os.path.join(service_dir, solution, "lambda/rules")): # config rules folder + solution_config_rules = os.path.join(service_dir, solution, "lambda/rules") config_rule_folders = sorted(os.listdir(solution_config_rules)) for config_rule in sorted(config_rule_folders): self.LOGGER.info(f"config rule: {config_rule} (in the {solution} solution)") @@ -154,29 +154,38 @@ def prepare_config_rules_for_staging(self, solution, staging_upload_folder, stag upload_folder_name = "/sra-" + solution.replace("_", "-") config_rule_upload_folder_name = "/" + config_rule.replace("_", "-") os.mkdir(staging_temp_folder + upload_folder_name) - os.mkdir(staging_temp_folder + upload_folder_name + config_rule_upload_folder_name) + os.mkdir(staging_temp_folder + upload_folder_name + "/rules") + config_rule_staging_folder_path = staging_temp_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name + os.mkdir(config_rule_staging_folder_path) os.mkdir(staging_upload_folder + upload_folder_name) - os.mkdir(staging_upload_folder + upload_folder_name + config_rule_upload_folder_name) + os.mkdir(staging_upload_folder + upload_folder_name + "/rules") + config_rule_upload_folder_path = staging_upload_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name + os.mkdir(config_rule_upload_folder_path) # lambda code if os.path.exists(config_rule_source_files) and os.path.exists(os.path.join(config_rule_source_files, "requirements.txt")): self.LOGGER.info(f"Downloading required packages for {solution} lambda...") self.pip_install( os.path.join(config_rule_source_files, "requirements.txt"), - staging_temp_folder + upload_folder_name + config_rule_upload_folder_name, + config_rule_staging_folder_path, ) for source_file in os.listdir(config_rule_source_files): if os.path.isdir(os.path.join(config_rule_source_files, source_file)): self.LOGGER.info(f"{source_file} is a directory, skipping...") else: shutil.copy(os.path.join(config_rule_source_files, source_file), staging_temp_folder + upload_folder_name + config_rule_upload_folder_name) - lambda_target_folder = staging_upload_folder + upload_folder_name + config_rule_upload_folder_name + "/lambda_code" + lambda_target_folder = config_rule_upload_folder_path self.LOGGER.info(f"Zipping config rule code for {solution} / {config_rule} lambda to {lambda_target_folder}{config_rule_upload_folder_name}.zip...") os.mkdir(lambda_target_folder) zip_file = ZipFile(f"{lambda_target_folder}/{config_rule_upload_folder_name}.zip", "w", ZIP_DEFLATED) - self.zip_folder(f"{staging_temp_folder + upload_folder_name + config_rule_upload_folder_name}/lambda", zip_file) + self.zip_folder(f"{config_rule_staging_folder_path}", zip_file) zip_file.close() - + # debug stuff: + else: + self.LOGGER.info(f"{os.path.join(service_dir, solution, "rules")} does not exist!") + if solution == "bedrock_org": + self.LOGGER.info(f"bedrock_org solution does not have config rules!") + self.LOGGER.info(f"bedrock_org directory listing: {os.listdir('/tmp/aws-security-reference-architecture-examples-sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda')}") def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, solutions_dir): if os.path.exists(staging_upload_folder): From 480d748b8795faa6d07cbbc85b39fada8ed4fbbb Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 1 Aug 2024 06:06:50 -0600 Subject: [PATCH 008/395] working on deploying lambdas --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 +++++- .../genai/bedrock_org/lambda/src/sra_repo.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 8f86d53c3..bec3817f9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -93,9 +93,13 @@ def create_event(event, context): repo.prepare_config_rules_for_staging("bedrock_org", repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET, "/") + # 2) Deploy lambda functions for config rules + # TODO(liamschn): solution should be a constant variable above + for rule in repo.CONFIG_RULES["bedrock_org"]: + LOGGER.info(f"Deploying lambda function for {rule} config rule...") + # 3) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) - # 2) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 1c5951173..28ceb5bf2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -37,6 +37,8 @@ class sra_repo: STAGING_UPLOAD_FOLDER = "/tmp/sra_staging_upload" STAGING_TEMP_FOLDER = "/tmp/sra_temp" + CONFIG_RULES: dict = {} + STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): get from SSM parameter PIP_VERSION = pip.__version__ URLLIB3_VERSION = urllib3.__version__ @@ -148,8 +150,10 @@ def prepare_config_rules_for_staging(self, solution, staging_upload_folder, stag if os.path.isdir(os.path.join(service_dir, solution, "lambda/rules")): # config rules folder solution_config_rules = os.path.join(service_dir, solution, "lambda/rules") config_rule_folders = sorted(os.listdir(solution_config_rules)) + self.CONFIG_RULES[solution] = [] for config_rule in sorted(config_rule_folders): self.LOGGER.info(f"config rule: {config_rule} (in the {solution} solution)") + self.CONFIG_RULES[solution].append(config_rule) config_rule_source_files = os.path.join(solution_config_rules, config_rule) upload_folder_name = "/sra-" + solution.replace("_", "-") config_rule_upload_folder_name = "/" + config_rule.replace("_", "-") @@ -159,7 +163,7 @@ def prepare_config_rules_for_staging(self, solution, staging_upload_folder, stag os.mkdir(config_rule_staging_folder_path) os.mkdir(staging_upload_folder + upload_folder_name) os.mkdir(staging_upload_folder + upload_folder_name + "/rules") - config_rule_upload_folder_path = staging_upload_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name + config_rule_upload_folder_path = staging_upload_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name os.mkdir(config_rule_upload_folder_path) # lambda code @@ -176,16 +180,16 @@ def prepare_config_rules_for_staging(self, solution, staging_upload_folder, stag shutil.copy(os.path.join(config_rule_source_files, source_file), staging_temp_folder + upload_folder_name + config_rule_upload_folder_name) lambda_target_folder = config_rule_upload_folder_path self.LOGGER.info(f"Zipping config rule code for {solution} / {config_rule} lambda to {lambda_target_folder}{config_rule_upload_folder_name}.zip...") - os.mkdir(lambda_target_folder) + # os.mkdir(lambda_target_folder) zip_file = ZipFile(f"{lambda_target_folder}/{config_rule_upload_folder_name}.zip", "w", ZIP_DEFLATED) self.zip_folder(f"{config_rule_staging_folder_path}", zip_file) zip_file.close() # debug stuff: else: self.LOGGER.info(f"{os.path.join(service_dir, solution, "rules")} does not exist!") - if solution == "bedrock_org": - self.LOGGER.info(f"bedrock_org solution does not have config rules!") - self.LOGGER.info(f"bedrock_org directory listing: {os.listdir('/tmp/aws-security-reference-architecture-examples-sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda')}") + # if solution == "bedrock_org": + # self.LOGGER.info(f"bedrock_org solution does not have config rules!") + # self.LOGGER.info(f"bedrock_org directory listing: {os.listdir('/tmp/aws-security-reference-architecture-examples-sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda')}") def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, solutions_dir): if os.path.exists(staging_upload_folder): From 41ef8159322629cc17a344966586e60b99e0547e Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 1 Aug 2024 15:22:47 -0600 Subject: [PATCH 009/395] working on IAM role --- .../genai/bedrock_org/lambda/src/app.py | 32 +++++++++++++++---- .../genai/bedrock_org/lambda/src/sra_repo.py | 15 ++++++--- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index bec3817f9..d2c48abc8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -11,6 +11,7 @@ import sra_iam import sra_dynamodb import sra_sts +import sra_lambda # import sra_lambda @@ -31,7 +32,8 @@ # STAGING_BUCKET: str = "" RESOURCE_TYPE: str = "" STATE_TABLE: str = "sra_state" -SOLUTION_NAME: str = "sra-common-prerequisites" +SOLUTION_NAME: str = "sra-bedrock-org" +# SOLUTION_DIR: str = "bedrock_org" LAMBDA_START: str = "" LAMBDA_FINISH: str = "" @@ -52,6 +54,7 @@ sts = sra_sts.sra_sts() repo = sra_repo.sra_repo() s3 = sra_s3.sra_s3() +lambdas = sra_lambda.sra_lambda() def get_resource_parameters(event): global DRY_RUN @@ -90,14 +93,31 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info("Live run: downloading and staging the config rule code...") repo.download_code_library(repo.REPO_ZIP_URL) - repo.prepare_config_rules_for_staging("bedrock_org", repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) + repo.prepare_config_rules_for_staging(repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET, "/") - # 2) Deploy lambda functions for config rules - # TODO(liamschn): solution should be a constant variable above - for rule in repo.CONFIG_RULES["bedrock_org"]: + # TODO(liamschn): move deployment code to another function + # TODO(liamschn): use STS to assume in to the delegated admin account for config + # 2) Deploy config rules + for rule in repo.CONFIG_RULES[SOLUTION_NAME]: + # 2a) Deploy execution role for custom config rule lambda + LOGGER.info(f"Deploying execution role for {rule} rule lambda") + iam_role_search = iam.check_iam_role_exists(rule) + if iam_role_search is False: + LOGGER.info(f"Creating {rule} IAM role") + else: + LOGGER.info(f"{rule} IAM role already exists.") + # 2b) Deploy lambda for custom config rule LOGGER.info(f"Deploying lambda function for {rule} config rule...") - + lambda_function_search = lambdas.find_lambda_function(rule) + if lambda_function_search == None: + LOGGER.info(f"{rule} lambda function not found. Creating...") + # https://sra-staging-891377138368-us-west-2.s3.us-west-2.amazonaws.com/sra-bedrock-org/rules/sra-check-iam-users/sra-check-iam-users.zip + lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule}/{rule}.zip" + LOGGER.info(f"Lambda file URL: {lambda_file_url}") + # lambdas.create_lambda_function(lambda_file_url, ) + else: + LOGGER.info(f"{rule} already exists. Search result: {lambda_function_search}") # 3) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 28ceb5bf2..925c1cc8e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -130,7 +130,7 @@ def download_code_library(self, repo_zip_url): self.LOGGER.info("Files extracted to /tmp") self.LOGGER.info(f"tmp directory listing: {os.listdir('/tmp')}") - def prepare_config_rules_for_staging(self, solution, staging_upload_folder, staging_temp_folder, solutions_dir): + def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_folder, solutions_dir): # self.LOGGER.info(f"listing config rules for {solution}") if os.path.exists(staging_upload_folder): shutil.rmtree(staging_upload_folder) @@ -150,13 +150,17 @@ def prepare_config_rules_for_staging(self, solution, staging_upload_folder, stag if os.path.isdir(os.path.join(service_dir, solution, "lambda/rules")): # config rules folder solution_config_rules = os.path.join(service_dir, solution, "lambda/rules") config_rule_folders = sorted(os.listdir(solution_config_rules)) - self.CONFIG_RULES[solution] = [] for config_rule in sorted(config_rule_folders): self.LOGGER.info(f"config rule: {config_rule} (in the {solution} solution)") - self.CONFIG_RULES[solution].append(config_rule) config_rule_source_files = os.path.join(solution_config_rules, config_rule) - upload_folder_name = "/sra-" + solution.replace("_", "-") - config_rule_upload_folder_name = "/" + config_rule.replace("_", "-") + solution_name = "sra-" + solution.replace("_", "-") + upload_folder_name = "/" + solution_name + rule_name = config_rule.replace("_", "-") + config_rule_upload_folder_name = "/" + rule_name + if solution_name in self.CONFIG_RULES: + self.CONFIG_RULES[solution_name].append(config_rule) + else: + self.CONFIG_RULES[solution_name] = [config_rule] os.mkdir(staging_temp_folder + upload_folder_name) os.mkdir(staging_temp_folder + upload_folder_name + "/rules") config_rule_staging_folder_path = staging_temp_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name @@ -190,6 +194,7 @@ def prepare_config_rules_for_staging(self, solution, staging_upload_folder, stag # if solution == "bedrock_org": # self.LOGGER.info(f"bedrock_org solution does not have config rules!") # self.LOGGER.info(f"bedrock_org directory listing: {os.listdir('/tmp/aws-security-reference-architecture-examples-sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda')}") + self.LOGGER.info(f"All config rules: {self.CONFIG_RULES}") def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, solutions_dir): if os.path.exists(staging_upload_folder): From 0c3f0388c7176b927b9abaa74e6c18df72bc6299 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 1 Aug 2024 21:40:03 -0600 Subject: [PATCH 010/395] build/check for iam role/policies; attach policies --- .../genai/bedrock_org/lambda/src/app.py | 51 +++++++++- .../genai/bedrock_org/lambda/src/sra_iam.py | 95 ++++++++++++++++++- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index d2c48abc8..5840845b9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -98,15 +98,58 @@ def create_event(event, context): # TODO(liamschn): move deployment code to another function # TODO(liamschn): use STS to assume in to the delegated admin account for config + # TODO(liamschn): ensure ACCOUNT id is the delegated admin account id # 2) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: # 2a) Deploy execution role for custom config rule lambda - LOGGER.info(f"Deploying execution role for {rule} rule lambda") - iam_role_search = iam.check_iam_role_exists(rule) + rule_lambda_name = rule.replace("_", "-") + LOGGER.info(f"Deploying execution role for {rule_lambda_name} rule lambda") + iam_role_search = iam.check_iam_role_exists(rule_lambda_name) if iam_role_search is False: - LOGGER.info(f"Creating {rule} IAM role") + if DRY_RUN is False: + LOGGER.info(f"Creating {rule_lambda_name} IAM role") + iam.create_role(rule_lambda_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"]) + else: + LOGGER.info(f"DRY_RUN: Creating {rule} IAM role") else: - LOGGER.info(f"{rule} IAM role already exists.") + LOGGER.info(f"{rule_lambda_name} IAM role already exists.") + + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("ACCOUNT_ID", ACCOUNT) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("PARTITION", sts.PARTITION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("REGION", sts.HOME_REGION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("ACCOUNT_ID", ACCOUNT) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("PARTITION", sts.PARTITION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("REGION", sts.HOME_REGION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("CONFIG_RULE_NAME", rule) + LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]}") + + policy_arn = f"arn:aws:iam::{ACCOUNT}:policy/{rule_lambda_name}-lamdba-basic-execution" + iam_policy_search = iam.check_iam_policy_exists(policy_arn) + if iam_policy_search is False: + if DRY_RUN is False: + LOGGER.info(f"Creating {rule_lambda_name}-lamdba-basic-execution IAM policy") + iam.create_policy(f"{rule_lambda_name}-lamdba-basic-execution", iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]) + else: + LOGGER.info(f"DRY _RUN: Creating {rule_lambda_name}-lamdba-basic-execution IAM policy") + else: + LOGGER.info(f"{rule_lambda_name}-lamdba-basic-execution IAM policy already exists") + + policy_attach_search1 = iam.check_iam_policy_attached(rule_lambda_name, policy_arn) + if policy_attach_search1 is False: + if DRY_RUN is False: + LOGGER.info(f"Attaching {rule_lambda_name}-lamdba-basic-execution policy to {rule_lambda_name} IAM role") + iam.attach_policy(rule_lambda_name, policy_arn) + else: + LOGGER.info(f"DRY_RUN: attaching {rule_lambda_name}-lamdba-basic-execution policy to {rule_lambda_name} IAM role") + + policy_attach_search1 = iam.check_iam_policy_attached(rule_lambda_name, "arn:aws:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") + if policy_attach_search1 is False: + if DRY_RUN is False: + LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_lambda_name} IAM role") + iam.attach_policy(rule_lambda_name, "arn:aws:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") + else: + LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_lambda_name} IAM role") + # 2b) Deploy lambda for custom config rule LOGGER.info(f"Deploying lambda function for {rule} config rule...") lambda_function_search = lambdas.find_lambda_function(rule) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 403643526..4a1b3aa00 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -2,7 +2,7 @@ Version: 1.0 -'common_prerequisites' solution in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples +IAM module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 @@ -67,11 +67,53 @@ class sra_iam: {"Action": "sts:AssumeRole", "Resource": "arn:aws:iam::*:role/" + SRA_EXECUTION_ROLE, "Effect": "Allow", "Sid": "AssumeExecutionRole"} ], } + + SRA_POLICY_DOCUMENTS: dict = { + "sra-lambda-basic-execution": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "logs:CreateLogGroup", + "Resource": "arn:PARTITION:logs:REGION:ACCOUNT_ID:*" + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:PARTITION:logs:REGION:ACCOUNT_ID:log-group:/aws/lambda/CONFIG_RULE_NAME:*" + } + ] + }, + } + + # TODO(liamschn): move stackset trust document to SRA_TRUST_DOCUMENTS variable SRA_STACKSET_TRUST: dict = { "Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": {"Service": "cloudformation.amazonaws.com"}, "Action": "sts:AssumeRole"}], } + SRA_TRUST_DOCUMENTS: dict = { + "sra-config-rule": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + }, + "sra-logs": { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Principal": {"Service": "logs.amazonaws.com"}, "Action": "sts:AssumeRole"} + ], + }, + } + try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) @@ -93,6 +135,7 @@ class sra_iam: } # Configuration + # TODO(liamschn): move CFN params to cfn module CFN_CAPABILITIES = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"] CFN_PARAMETERS = [ {"ParameterKey": "pManagementAccountId", "ParameterValue": MANAGEMENT_ACCOUNT}, @@ -250,7 +293,7 @@ def create_policy(self, policy_name: str, policy_document: dict) -> CreatePolicy Returns: Dictionary output of a successful CreatePolicy request """ - self.LOGGER.info("Creating policy %s.", policy_name) + self.LOGGER.info(f"Creating {policy_name} IAM policy") return self.IAM_CLIENT.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(policy_document)) # def attach_policy(self, role_name: str, policy_name: str, policy_document: str) -> EmptyResponseMetadataTypeDef: @@ -346,3 +389,51 @@ def check_iam_role_exists(self, role_name): else: # Handle other possible exceptions (e.g., permission issues) raise + + def check_iam_policy_exists(self, policy_arn): + """ + Checks if an IAM policy exists. + + Parameters: + - policy_arn (str): The Amazon Resource Name (ARN) of the IAM policy to check. + + Returns: + bool: True if the policy exists, False otherwise. + """ + self.LOGGER.info(f"Checking if policy '{policy_arn}' exists.") + try: + result = self.IAM_CLIENT.get_policy(PolicyArn=policy_arn) + self.LOGGER.info(f"Result: {result}") + self.LOGGER.info(f"The policy '{policy_arn}' exists.") + return True + # Handle other possible exceptions (e.g., permission issues) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + self.LOGGER.info(f"The policy '{policy_arn}' does not exist.") + return False + else: + raise ValueError(f"Unexpected error: {error}") from None + + def check_iam_policy_attached(self, role_name, policy_arn): + """ + Checks if an IAM policy is attached to an IAM role. + + Parameters: + - role_name (str): The name of the IAM role. + - policy_arn (str): The Amazon Resource Name (ARN) of the IAM policy. + + Returns: + bool: True if the policy is attached, False otherwise. + """ + try: + response = self.IAM_CLIENT.list_attached_role_policies(RoleName=role_name) + attached_policies = response["AttachedPolicies"] + for policy in attached_policies: + if policy["PolicyArn"] == policy_arn: + self.LOGGER.info(f"The policy '{policy_arn}' is attached to the role '{role_name}'.") + return True + self.LOGGER.info(f"The policy '{policy_arn}' is not attached to the role '{role_name}'.") + return False + except ClientError as error: + self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") + raise \ No newline at end of file From 647181efbdecf6c490d8e8d1bf61a4bb477c4fe5 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sat, 3 Aug 2024 10:48:08 -0600 Subject: [PATCH 011/395] adding sns fanout - not working yet; formatting --- .../sra_check_iam_users.py | 77 +++++---- .../genai/bedrock_org/lambda/src/app.py | 90 +++++++---- .../bedrock_org/lambda/src/cfnresponse.py | 1 - .../bedrock_org/lambda/src/sra_config.py | 11 +- .../genai/bedrock_org/lambda/src/sra_iam.py | 151 +++++++++--------- .../bedrock_org/lambda/src/sra_lambda.py | 15 +- .../genai/bedrock_org/lambda/src/sra_repo.py | 4 +- .../genai/bedrock_org/lambda/src/sra_s3.py | 3 +- .../genai/bedrock_org/lambda/src/sra_sns.py | 0 .../bedrock_org/lambda/src/sra_ssm_params.py | 2 + 10 files changed, 192 insertions(+), 162 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/sra_check_iam_users.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/sra_check_iam_users.py index 2cc2c954d..b5e77a269 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/sra_check_iam_users.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/sra_check_iam_users.py @@ -1,13 +1,13 @@ -import botocore +import botocore import boto3 import json import datetime import logging -import os # maybe not needed for logging +import os # maybe not needed for logging # Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). ASSUME_ROLE_MODE = False -DEFAULT_RESOURCE_TYPE = 'AWS::::Account' +DEFAULT_RESOURCE_TYPE = "AWS::::Account" # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -27,45 +27,53 @@ def get_client(service, event): if not ASSUME_ROLE_MODE: return boto3.client(service) credentials = get_assume_role_credentials(event["executionRoleArn"]) - return boto3.client(service, aws_access_key_id=credentials['AccessKeyId'], - aws_secret_access_key=credentials['SecretAccessKey'], - aws_session_token=credentials['SessionToken'] - ) + return boto3.client( + service, + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], + ) + def get_assume_role_credentials(role_arn): - sts_client = boto3.client('sts') + sts_client = boto3.client("sts") try: assume_role_response = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="configLambdaExecution") - return assume_role_response['Credentials'] + return assume_role_response["Credentials"] except botocore.exceptions.ClientError as ex: # Scrub error message for any internal account info leaks - if 'AccessDenied' in ex.response['Error']['Code']: - ex.response['Error']['Message'] = "AWS Config does not have permission to assume the IAM role." + if "AccessDenied" in ex.response["Error"]["Code"]: + ex.response["Error"]["Message"] = "AWS Config does not have permission to assume the IAM role." else: - ex.response['Error']['Message'] = "InternalError" - ex.response['Error']['Code'] = "InternalError" + ex.response["Error"]["Message"] = "InternalError" + ex.response["Error"]["Code"] = "InternalError" raise ex + # Check whether the message is a ScheduledNotification or not. def is_scheduled_notification(message_type): - return message_type == 'ScheduledNotification' + return message_type == "ScheduledNotification" + def count_resource_types(applicable_resource_type, next_token, count): resource_identifier = AWS_CONFIG_CLIENT.list_discovered_resources(resourceType=applicable_resource_type, nextToken=next_token) - updated = count + len(resource_identifier['resourceIdentifiers']); + updated = count + len(resource_identifier["resourceIdentifiers"]) return updated + # Evaluates the configuration items in the snapshot and returns the compliance value to the handler. def evaluate_compliance(max_count, actual_count): - return 'NON_COMPLIANT' if int(actual_count) > int(max_count) else 'COMPLIANT' + return "NON_COMPLIANT" if int(actual_count) > int(max_count) else "COMPLIANT" + def evaluate_parameters(rule_parameters): - if 'applicableResourceType' not in rule_parameters: + if "applicableResourceType" not in rule_parameters: raise ValueError('The parameter with "applicableResourceType" as key must be defined.') - if not rule_parameters['applicableResourceType']: + if not rule_parameters["applicableResourceType"]: raise ValueError('The parameter "applicableResourceType" must have a defined value.') return rule_parameters + # This generate an evaluation for config def build_evaluation(resource_id, compliance_type, event, resource_type=DEFAULT_RESOURCE_TYPE, annotation=None): """Form an evaluation as a dictionary. Usually suited to report on scheduled rules. @@ -78,13 +86,14 @@ def build_evaluation(resource_id, compliance_type, event, resource_type=DEFAULT_ """ eval_cc = {} if annotation: - eval_cc['Annotation'] = annotation - eval_cc['ComplianceResourceType'] = resource_type - eval_cc['ComplianceResourceId'] = resource_id - eval_cc['ComplianceType'] = compliance_type - eval_cc['OrderingTimestamp'] = str(json.loads(event['invokingEvent'])['notificationCreationTime']) + eval_cc["Annotation"] = annotation + eval_cc["ComplianceResourceType"] = resource_type + eval_cc["ComplianceResourceId"] = resource_id + eval_cc["ComplianceType"] = compliance_type + eval_cc["OrderingTimestamp"] = str(json.loads(event["invokingEvent"])["notificationCreationTime"]) return eval_cc + def lambda_handler(event, context): LOGGER.info(event) global AWS_CONFIG_CLIENT @@ -94,25 +103,25 @@ def lambda_handler(event, context): resource_count = 0 max_count = 0 - invoking_event = json.loads(event['invokingEvent']) - if 'ruleParameters' in event: - rule_parameters = json.loads(event['ruleParameters']) + invoking_event = json.loads(event["invokingEvent"]) + if "ruleParameters" in event: + rule_parameters = json.loads(event["ruleParameters"]) valid_rule_parameters = evaluate_parameters(rule_parameters) - compliance_value = 'NOT_APPLICABLE' + compliance_value = "NOT_APPLICABLE" - AWS_CONFIG_CLIENT = get_client('config', event) - if is_scheduled_notification(invoking_event['messageType']): - result_resource_count = count_resource_types(valid_rule_parameters['applicableResourceType'], '', resource_count) + AWS_CONFIG_CLIENT = get_client("config", event) + if is_scheduled_notification(invoking_event["messageType"]): + result_resource_count = count_resource_types(valid_rule_parameters["applicableResourceType"], "", resource_count) - if valid_rule_parameters.get('maxCount'): - max_count = valid_rule_parameters['maxCount'] + if valid_rule_parameters.get("maxCount"): + max_count = valid_rule_parameters["maxCount"] LOGGER.info(f"maxCount set to: {max_count} from rule parameter") else: LOGGER.info(f"maxCount set to: {max_count} as default") LOGGER.info(f"result resource count: {result_resource_count}") compliance_value = evaluate_compliance(max_count, result_resource_count) - evaluations.append(build_evaluation(event['accountId'], compliance_value, event, resource_type=DEFAULT_RESOURCE_TYPE)) - response = AWS_CONFIG_CLIENT.put_evaluations(Evaluations=evaluations, ResultToken=event['resultToken']) \ No newline at end of file + evaluations.append(build_evaluation(event["accountId"], compliance_value, event, resource_type=DEFAULT_RESOURCE_TYPE)) + response = AWS_CONFIG_CLIENT.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 5840845b9..668c3117e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -18,6 +18,7 @@ # TODO(liamschn): Need to test with (and create) a CFN template # TODO(liamschn): If dynamoDB sra_state table exists, use it # TODO(liamschn): Where do we see dry-run data? Maybe S3 staging bucket file? The sra_state table? Another DynamoDB table? +# TODO(liamschn): add parameter validation from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional @@ -56,6 +57,7 @@ s3 = sra_s3.sra_s3() lambdas = sra_lambda.sra_lambda() + def get_resource_parameters(event): global DRY_RUN @@ -96,7 +98,7 @@ def create_event(event, context): repo.prepare_config_rules_for_staging(repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET, "/") - # TODO(liamschn): move deployment code to another function + # TODO(liamschn): move iam deployment code to another function with parameters for reusability # TODO(liamschn): use STS to assume in to the delegated admin account for config # TODO(liamschn): ensure ACCOUNT id is the delegated admin account id # 2) Deploy config rules @@ -114,16 +116,26 @@ def create_event(event, context): else: LOGGER.info(f"{rule_lambda_name} IAM role already exists.") - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("ACCOUNT_ID", ACCOUNT) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("PARTITION", sts.PARTITION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("REGION", sts.HOME_REGION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("ACCOUNT_ID", ACCOUNT) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("PARTITION", sts.PARTITION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("REGION", sts.HOME_REGION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("CONFIG_RULE_NAME", rule) - LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]}") - - policy_arn = f"arn:aws:iam::{ACCOUNT}:policy/{rule_lambda_name}-lamdba-basic-execution" + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][0]["Resource"].replace("ACCOUNT_ID", ACCOUNT) + # iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("PARTITION", sts.PARTITION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][0]["Resource"].replace("REGION", sts.HOME_REGION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][1]["Resource"].replace("ACCOUNT_ID", ACCOUNT) + # iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("PARTITION", sts.PARTITION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][1]["Resource"].replace("REGION", sts.HOME_REGION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][1]["Resource"].replace("CONFIG_RULE_NAME", rule) + LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") + + policy_arn = f"arn:{sts.PARTITION}:iam::{ACCOUNT}:policy/{rule_lambda_name}-lamdba-basic-execution" iam_policy_search = iam.check_iam_policy_exists(policy_arn) if iam_policy_search is False: if DRY_RUN is False: @@ -135,18 +147,20 @@ def create_event(event, context): LOGGER.info(f"{rule_lambda_name}-lamdba-basic-execution IAM policy already exists") policy_attach_search1 = iam.check_iam_policy_attached(rule_lambda_name, policy_arn) - if policy_attach_search1 is False: + if policy_attach_search1 is False: if DRY_RUN is False: LOGGER.info(f"Attaching {rule_lambda_name}-lamdba-basic-execution policy to {rule_lambda_name} IAM role") iam.attach_policy(rule_lambda_name, policy_arn) else: LOGGER.info(f"DRY_RUN: attaching {rule_lambda_name}-lamdba-basic-execution policy to {rule_lambda_name} IAM role") - policy_attach_search1 = iam.check_iam_policy_attached(rule_lambda_name, "arn:aws:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") - if policy_attach_search1 is False: + policy_attach_search1 = iam.check_iam_policy_attached( + rule_lambda_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole" + ) + if policy_attach_search1 is False: if DRY_RUN is False: LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_lambda_name} IAM role") - iam.attach_policy(rule_lambda_name, "arn:aws:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") + iam.attach_policy(rule_lambda_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") else: LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_lambda_name} IAM role") @@ -163,7 +177,6 @@ def create_event(event, context): LOGGER.info(f"{rule} already exists. Search result: {lambda_function_search}") # 3) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) - # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: cfnresponse.send(event, context, cfnresponse.SUCCESS, data, CFN_RESOURCE_ID) @@ -186,6 +199,19 @@ def delete_event(event, context): cfnresponse.send(event, context, cfnresponse.SUCCESS, {"delete_operation": "succeeded deleting"}, CFN_RESOURCE_ID) +def process_sns_records(records: list) -> None: + """Process SNS records. + + Args: + records: list of SNS event records + """ + for record in records: + sns_info = record["Sns"] + LOGGER.info(f"SNS INFO: {sns_info}") + message = json.loads(sns_info["Message"]) + # deploy_config_rule(message["AccountId"], message["ConfigRuleName"], message["Regions"]) + + def lambda_handler(event, context): global RESOURCE_TYPE global LAMBDA_START @@ -194,18 +220,28 @@ def lambda_handler(event, context): LOGGER.info(event) LOGGER.info({"boto3 version": boto3.__version__}) try: - RESOURCE_TYPE = event["ResourceType"] - LOGGER.info(f"ResourceType: {RESOURCE_TYPE}") + if "ResourceType" in event: + RESOURCE_TYPE = event["ResourceType"] + LOGGER.info(f"ResourceType: {RESOURCE_TYPE}") + else: + LOGGER.info("ResourceType not found in event.") get_resource_parameters(event) - if event["RequestType"] == "Create": - LOGGER.info("CREATE EVENT!!") - create_event(event, context) - if event["RequestType"] == "Update": - LOGGER.info("UPDATE EVENT!!") - update_event(event, context) - if event["RequestType"] == "Delete": - LOGGER.info("DELETE EVENT!!") - delete_event(event, context) + if "Records" not in event and "RequestType" not in event: + raise ValueError( + f"The event did not include Records or RequestType. Review CloudWatch logs '{context.log_group_name}' for details." + ) from None + elif "Records" in event and event["Records"][0]["EventSource"] == "aws:sns": + process_sns_records(event["Records"]) + elif "RequestType" in event: + if event["RequestType"] == "Create": + LOGGER.info("CREATE EVENT!!") + create_event(event, context) + elif event["RequestType"] == "Update": + LOGGER.info("UPDATE EVENT!!") + update_event(event, context) + if event["RequestType"] == "Delete": + LOGGER.info("DELETE EVENT!!") + delete_event(event, context) except Exception: LOGGER.exception("Unexpected!") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py index 826c6d6b6..4efff0a0c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py @@ -39,5 +39,4 @@ def send(event, context, responseStatus, responseData, physicalResourceId=None, print("Status code:", response.status) except Exception as e: - print("send(..) failed executing http.request(..):", e) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 4a311a722..d486a2a4a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -53,14 +53,11 @@ class sra_config: except Exception: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - def get_organization_config_rules(self): """Get Organization Config Rules.""" # Get the Organization ID - org_id: str = ( - self.ORG_CLIENT.describe_organization()["Organization"]["Id"] - ) + org_id: str = self.ORG_CLIENT.describe_organization()["Organization"]["Id"] # Get the Organization Config Rules response = self.ORG_CLIENT.describe_organization_config_rules( @@ -77,9 +74,7 @@ def get_organization_config_rules(self): def put_organization_config_rule(self): """Put Organization Config Rule.""" # Get the Organization ID - org_id: str = ( - self.ORG_CLIENT.describe_organization()["Organization"]["Id"] - ) + org_id: str = self.ORG_CLIENT.describe_organization()["Organization"]["Id"] # Put the Organization Config Rule response = self.ORG_CLIENT.put_organization_config_rule( @@ -92,4 +87,4 @@ def put_organization_config_rule(self): sra_config.LOGGER.info(response) # Return the response - return response \ No newline at end of file + return response diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 4a1b3aa00..4212ed292 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -61,6 +61,30 @@ class sra_iam: SRA_STACKSET_ROLE: str = "sra-stackset" # todo(liamschn): parameterize this role name SRA_EXECUTION_ROLE_STACKSET_ID: str = "" SRA_STACKSET_POLICY_NAME: str = "sra-assume-role-access" + + try: + MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) + CFN_CLIENT: CloudFormationClient = MANAGEMENT_ACCOUNT_SESSION.client("cloudformation", config=BOTO3_CONFIG) + IAM_CLIENT: IAMClient = MANAGEMENT_ACCOUNT_SESSION.client("iam", config=BOTO3_CONFIG) + STS_CLIENT = boto3.client("sts") + HOME_REGION = MANAGEMENT_ACCOUNT_SESSION.region_name + LOGGER.info(f"Detected home region: {HOME_REGION}") + S3_HOST_NAME = urllib.parse.urlparse(boto3.client("s3", region_name=HOME_REGION).meta.endpoint_url).hostname + MANAGEMENT_ACCOUNT = STS_CLIENT.get_caller_identity().get("Account") + PARTITION: str = MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(HOME_REGION) + LOGGER.info(f"Detected management account (current account): {MANAGEMENT_ACCOUNT}") + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + SRA_EXECUTION_TRUST: dict = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Principal": {"AWS": "arn:" + PARTITION + ":iam::" + MANAGEMENT_ACCOUNT + ":root"}, "Action": "sts:AssumeRole"} + ], + } + SRA_STACKSET_POLICY: dict = { "Version": "2012-10-17", "Statement": [ @@ -72,20 +96,13 @@ class sra_iam: "sra-lambda-basic-execution": { "Version": "2012-10-17", "Statement": [ + {"Effect": "Allow", "Action": "logs:CreateLogGroup", "Resource": "arn:" + PARTITION + ":logs:REGION:ACCOUNT_ID:*"}, { "Effect": "Allow", - "Action": "logs:CreateLogGroup", - "Resource": "arn:PARTITION:logs:REGION:ACCOUNT_ID:*" + "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:" + PARTITION + ":logs:REGION:ACCOUNT_ID:log-group:/aws/lambda/CONFIG_RULE_NAME:*", }, - { - "Effect": "Allow", - "Action": [ - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": "arn:PARTITION:logs:REGION:ACCOUNT_ID:log-group:/aws/lambda/CONFIG_RULE_NAME:*" - } - ] + ], }, } @@ -108,32 +125,10 @@ class sra_iam: }, "sra-logs": { "Version": "2012-10-17", - "Statement": [ - {"Effect": "Allow", "Principal": {"Service": "logs.amazonaws.com"}, "Action": "sts:AssumeRole"} - ], + "Statement": [{"Effect": "Allow", "Principal": {"Service": "logs.amazonaws.com"}, "Action": "sts:AssumeRole"}], }, } - try: - MANAGEMENT_ACCOUNT_SESSION = boto3.Session() - ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) - CFN_CLIENT: CloudFormationClient = MANAGEMENT_ACCOUNT_SESSION.client("cloudformation", config=BOTO3_CONFIG) - IAM_CLIENT: IAMClient = MANAGEMENT_ACCOUNT_SESSION.client("iam", config=BOTO3_CONFIG) - STS_CLIENT = boto3.client("sts") - HOME_REGION = MANAGEMENT_ACCOUNT_SESSION.region_name - LOGGER.info(f"Detected home region: {HOME_REGION}") - S3_HOST_NAME = urllib.parse.urlparse(boto3.client("s3", region_name=HOME_REGION).meta.endpoint_url).hostname - MANAGEMENT_ACCOUNT = STS_CLIENT.get_caller_identity().get("Account") - LOGGER.info(f"Detected management account (current account): {MANAGEMENT_ACCOUNT}") - except Exception: - LOGGER.exception(UNEXPECTED) - raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - - SRA_EXECUTION_TRUST: dict = { - "Version": "2012-10-17", - "Statement": [{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::" + MANAGEMENT_ACCOUNT + ":root"}, "Action": "sts:AssumeRole"}], - } - # Configuration # TODO(liamschn): move CFN params to cfn module CFN_CAPABILITIES = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"] @@ -391,49 +386,49 @@ def check_iam_role_exists(self, role_name): raise def check_iam_policy_exists(self, policy_arn): - """ - Checks if an IAM policy exists. - - Parameters: - - policy_arn (str): The Amazon Resource Name (ARN) of the IAM policy to check. - - Returns: - bool: True if the policy exists, False otherwise. - """ - self.LOGGER.info(f"Checking if policy '{policy_arn}' exists.") - try: - result = self.IAM_CLIENT.get_policy(PolicyArn=policy_arn) - self.LOGGER.info(f"Result: {result}") - self.LOGGER.info(f"The policy '{policy_arn}' exists.") - return True - # Handle other possible exceptions (e.g., permission issues) - except ClientError as error: - if error.response["Error"]["Code"] == "NoSuchEntity": - self.LOGGER.info(f"The policy '{policy_arn}' does not exist.") - return False - else: - raise ValueError(f"Unexpected error: {error}") from None + """ + Checks if an IAM policy exists. - def check_iam_policy_attached(self, role_name, policy_arn): - """ - Checks if an IAM policy is attached to an IAM role. - - Parameters: - - role_name (str): The name of the IAM role. - - policy_arn (str): The Amazon Resource Name (ARN) of the IAM policy. - - Returns: - bool: True if the policy is attached, False otherwise. - """ - try: - response = self.IAM_CLIENT.list_attached_role_policies(RoleName=role_name) - attached_policies = response["AttachedPolicies"] - for policy in attached_policies: - if policy["PolicyArn"] == policy_arn: - self.LOGGER.info(f"The policy '{policy_arn}' is attached to the role '{role_name}'.") - return True - self.LOGGER.info(f"The policy '{policy_arn}' is not attached to the role '{role_name}'.") + Parameters: + - policy_arn (str): The Amazon Resource Name (ARN) of the IAM policy to check. + + Returns: + bool: True if the policy exists, False otherwise. + """ + self.LOGGER.info(f"Checking if policy '{policy_arn}' exists.") + try: + result = self.IAM_CLIENT.get_policy(PolicyArn=policy_arn) + self.LOGGER.info(f"Result: {result}") + self.LOGGER.info(f"The policy '{policy_arn}' exists.") + return True + # Handle other possible exceptions (e.g., permission issues) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + self.LOGGER.info(f"The policy '{policy_arn}' does not exist.") return False - except ClientError as error: - self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") - raise \ No newline at end of file + else: + raise ValueError(f"Unexpected error: {error}") from None + + def check_iam_policy_attached(self, role_name, policy_arn): + """ + Checks if an IAM policy is attached to an IAM role. + + Parameters: + - role_name (str): The name of the IAM role. + - policy_arn (str): The Amazon Resource Name (ARN) of the IAM policy. + + Returns: + bool: True if the policy is attached, False otherwise. + """ + try: + response = self.IAM_CLIENT.list_attached_role_policies(RoleName=role_name) + attached_policies = response["AttachedPolicies"] + for policy in attached_policies: + if policy["PolicyArn"] == policy_arn: + self.LOGGER.info(f"The policy '{policy_arn}' is attached to the role '{role_name}'.") + return True + self.LOGGER.info(f"The policy '{policy_arn}' is not attached to the role '{role_name}'.") + return False + except ClientError as error: + self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") + raise diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index dec45413f..0b4c14822 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -2,7 +2,7 @@ Version: 0.1 -'bedrock_org' solution in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples +LAMBDA module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 @@ -56,12 +56,10 @@ class sra_lambda: def find_lambda_function(self, function_name): """Find Lambda Function.""" try: - response = self.LAMBDA_CLIENT.get_function( - FunctionName=function_name - ) + response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) return response except ClientError as e: - if e.response['Error']['Code'] == 'ResourceNotFoundException': + if e.response["Error"]["Code"] == "ResourceNotFoundException": return None else: self.LOGGER.error(e) @@ -75,14 +73,11 @@ def create_lambda_function(self, code_zip_s3_url, role_arn, function_name, handl Runtime=runtime, Handler=handler, Role=role_arn, - Code={ - 'ZipFile': code_zip_s3_url - }, + Code={"ZipFile": code_zip_s3_url}, Timeout=timeout, - MemorySize=memory_size + MemorySize=memory_size, ) return response except ClientError as e: self.LOGGER.error(e) return None - diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 925c1cc8e..4f196873e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -190,8 +190,8 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f zip_file.close() # debug stuff: else: - self.LOGGER.info(f"{os.path.join(service_dir, solution, "rules")} does not exist!") - # if solution == "bedrock_org": + self.LOGGER.info(f"{os.path.join(service_dir, solution, 'rules')} does not exist!") + # if solution == "bedrock_org":: # self.LOGGER.info(f"bedrock_org solution does not have config rules!") # self.LOGGER.info(f"bedrock_org directory listing: {os.listdir('/tmp/aws-security-reference-architecture-examples-sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda')}") self.LOGGER.info(f"All config rules: {self.CONFIG_RULES}") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 98843e57e..355623ea5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -100,7 +100,6 @@ def s3_resource_check(self, bucket): # todo(liamschn): parameter formatting validation - def stage_code_to_s3(self, directory_path, bucket_name, s3_path): """ Uploads the prepared code directory to the staging S3 bucket. @@ -122,4 +121,4 @@ def stage_code_to_s3(self, directory_path, bucket_name, s3_path): except NoCredentialsError: self.LOGGER.info("Credentials not available") return - self.LOGGER.info(f"Uploaded {local_path} to {bucket_name} {s3_file_path}") \ No newline at end of file + self.LOGGER.info(f"Uploaded {local_path} to {bucket_name} {s3_file_path}") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py new file mode 100644 index 000000000..e69de29bb diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py index 2b3617daa..28c3087db 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -60,6 +60,8 @@ class sra_ssm_params: "/sra/regions/customer-control-tower-regions-without-home-region", "/sra/staging-s3-bucket-name", ] + # todo(liamschn): in the common prerequisite solution add an sra execution/configuration role parameter + SRA_STAGING_BUCKET: str = "" UNEXPECTED = "Unexpected!" EMPTY_VALUE = "NONE" From b6875b4b553053479d27412957ca40036f6ebe5e Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sun, 4 Aug 2024 09:40:55 -0600 Subject: [PATCH 012/395] updates to sns module --- .../genai/bedrock_org/lambda/src/sra_sns.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index e69de29bb..5a95cea54 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -0,0 +1,77 @@ +"""Custom Resource to setup SRA Lambda resources in the organization. + +Version: 0.1 + +SNS module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +import logging +import os +from time import sleep + +from typing import TYPE_CHECKING + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +import sra_sts + +if TYPE_CHECKING: + from mypy_boto3_sns.client import SNSClient + + +class sra_sns: + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) + UNEXPECTED = "Unexpected!" + + try: + MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + SNS_CLIENT: SNSClient = MANAGEMENT_ACCOUNT_SESSION.client("sns", config=BOTO3_CONFIG) + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + sts = sra_sts.sra_sts() + + def find_sns_topic(self, topic_name: str) -> str: + """Find SNS Topic ARN.""" + try: + response = self.SNS_CLIENT.get_topic_attributes(TopicArn=f"arn:{self.sts.PARTITION}:sns:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:{topic_name}") + return response['Attributes']['TopicArn'] + except ClientError as e: + if e.response['Error']['Code'] == 'NotFoundException': + self.LOGGER.error(f"SNS Topic '{topic_name}' not found.") + return None + else: + raise ValueError(f"Error finding SNS topic: {e}") from None + + def create_sns_topic(self, topic_name: str, solution_name: str) -> str: + """Create SNS Topic.""" + try: + response = self.SNS_CLIENT.create_topic(Name=topic_name, Attributes={'DisplayName': topic_name}, Tags=[{'Key': 'sra-solution', 'Value': solution_name}]) + topic_arn = response['TopicArn'] + self.LOGGER.info(f"SNS Topic '{topic_name}' created with ARN: {topic_arn}") + return topic_arn + except ClientError as e: + raise ValueError(f"Error creating SNS topic: {e}") from None + + def create_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) -> None: + """Create SNS Subscription.""" + try: + self.SNS_CLIENT.subscribe(TopicArn=topic_arn, Protocol=protocol, Endpoint=endpoint) + self.LOGGER.info(f"SNS Subscription created for {endpoint} on topic {topic_arn}") + sleep(5) # Wait for subscription to be created + return None + except ClientError as e: + raise ValueError(f"Error creating SNS subscription: {e}") from None \ No newline at end of file From 005637a14dc9adeaccbbb055e42f9645d72516ef Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 14 Aug 2024 17:34:34 -0600 Subject: [PATCH 013/395] adding sns fanout; not working --- .../genai/bedrock_org/lambda/src/app.py | 41 ++++++++++++++-- .../bedrock_org/lambda/src/sra_lambda.py | 41 +++++++++++++--- .../genai/bedrock_org/lambda/src/sra_repo.py | 2 +- .../genai/bedrock_org/lambda/src/sra_sns.py | 47 +++++++++++++++---- 4 files changed, 110 insertions(+), 21 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 668c3117e..74e07714a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -12,6 +12,7 @@ import sra_dynamodb import sra_sts import sra_lambda +import sra_sns # import sra_lambda @@ -56,7 +57,7 @@ repo = sra_repo.sra_repo() s3 = sra_s3.sra_s3() lambdas = sra_lambda.sra_lambda() - +sns = sra_sns.sra_sns() def get_resource_parameters(event): global DRY_RUN @@ -97,13 +98,35 @@ def create_event(event, context): repo.download_code_library(repo.REPO_ZIP_URL) repo.prepare_config_rules_for_staging(repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET, "/") + else: + LOGGER.info(f"DRY_RUN: Downloading code library from {repo.REPO_ZIP_URL}") + LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") + LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") + + # 2) Deploy SNS topic for fanout configuration operations + topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") + if topic_search is None: + if DRY_RUN is False: + LOGGER.info(f"Creating {SOLUTION_NAME}-configuration SNS topic") + topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) + LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") + # TODO(liamschn): search for permissions on lambda before adding the policy + lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) + LOGGER.info(f"Subscribing {context.invoked_function_arn} to {topic_arn}") + sns.create_sns_subscription(topic_arn, "lambda", context.invoked_function_arn) + else: + LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") + LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") + LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {topic_arn}") + else: + LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") # TODO(liamschn): move iam deployment code to another function with parameters for reusability # TODO(liamschn): use STS to assume in to the delegated admin account for config # TODO(liamschn): ensure ACCOUNT id is the delegated admin account id - # 2) Deploy config rules + # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: - # 2a) Deploy execution role for custom config rule lambda + # 3a) Deploy execution role for custom config rule lambda rule_lambda_name = rule.replace("_", "-") LOGGER.info(f"Deploying execution role for {rule_lambda_name} rule lambda") iam_role_search = iam.check_iam_role_exists(rule_lambda_name) @@ -164,7 +187,7 @@ def create_event(event, context): else: LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_lambda_name} IAM role") - # 2b) Deploy lambda for custom config rule + # 3b) Deploy lambda for custom config rule LOGGER.info(f"Deploying lambda function for {rule} config rule...") lambda_function_search = lambdas.find_lambda_function(rule) if lambda_function_search == None: @@ -175,7 +198,7 @@ def create_event(event, context): # lambdas.create_lambda_function(lambda_file_url, ) else: LOGGER.info(f"{rule} already exists. Search result: {lambda_function_search}") - # 3) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) + # 4) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: @@ -211,6 +234,14 @@ def process_sns_records(records: list) -> None: message = json.loads(sns_info["Message"]) # deploy_config_rule(message["AccountId"], message["ConfigRuleName"], message["Regions"]) +def deploy_config_rule(account_id: str, config_rule_name: str, regions: list) -> None: + """Deploy config rule. + + Args: + account_id: AWS account ID + config_rule_name: config rule name + regions: list of regions to deploy the config rule + """ def lambda_handler(event, context): global RESOURCE_TYPE diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 0b4c14822..223c8510c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -24,17 +24,17 @@ from botocore.config import Config from botocore.exceptions import ClientError -import urllib.parse -import json +# import urllib.parse +# import json -import cfnresponse +# import cfnresponse if TYPE_CHECKING: - from mypy_boto3_cloudformation import CloudFormationClient - from mypy_boto3_organizations import OrganizationsClient + # from mypy_boto3_cloudformation import CloudFormationClient + # from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_lambda.client import LambdaClient - from mypy_boto3_iam.client import IAMClient - from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + # from mypy_boto3_iam.client import IAMClient + # from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef class sra_lambda: @@ -81,3 +81,30 @@ def create_lambda_function(self, code_zip_s3_url, role_arn, function_name, handl except ClientError as e: self.LOGGER.error(e) return None + + def get_permissions(self, function_name): + """Get Lambda Function Permissions.""" + try: + response = self.LAMBDA_CLIENT.get_policy(FunctionName=function_name) + return response + except ClientError as e: + if e.response["Error"]["Code"] == "ResourceNotFoundException": + return None + else: + self.LOGGER.error(e) + return None + + def put_permissions(self, function_name, statement_id, principal, action, source_arn): + """Put Lambda Function Permissions.""" + try: + response = self.LAMBDA_CLIENT.add_permission( + FunctionName=function_name, + StatementId=statement_id, + Action=action, + Principal=principal, + SourceArn=source_arn, + ) + return response + except ClientError as e: + self.LOGGER.error(e) + return None \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 4f196873e..88714050d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -191,7 +191,7 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f # debug stuff: else: self.LOGGER.info(f"{os.path.join(service_dir, solution, 'rules')} does not exist!") - # if solution == "bedrock_org":: + # if solution == "bedrock_org": # self.LOGGER.info(f"bedrock_org solution does not have config rules!") # self.LOGGER.info(f"bedrock_org directory listing: {os.listdir('/tmp/aws-security-reference-architecture-examples-sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda')}") self.LOGGER.info(f"All config rules: {self.CONFIG_RULES}") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 5a95cea54..c69f80580 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -26,6 +26,7 @@ from mypy_boto3_sns.client import SNSClient +# TODO(liamschn): kms key for sns topic class sra_sns: # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -46,26 +47,56 @@ class sra_sns: def find_sns_topic(self, topic_name: str) -> str: """Find SNS Topic ARN.""" - try: - response = self.SNS_CLIENT.get_topic_attributes(TopicArn=f"arn:{self.sts.PARTITION}:sns:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:{topic_name}") - return response['Attributes']['TopicArn'] + try: + response = self.SNS_CLIENT.get_topic_attributes( + TopicArn=f"arn:{self.sts.PARTITION}:sns:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:{topic_name}" + ) + return response["Attributes"]["TopicArn"] except ClientError as e: - if e.response['Error']['Code'] == 'NotFoundException': + if e.response["Error"]["Code"] == "NotFoundException": self.LOGGER.error(f"SNS Topic '{topic_name}' not found.") return None else: raise ValueError(f"Error finding SNS topic: {e}") from None - + def create_sns_topic(self, topic_name: str, solution_name: str) -> str: """Create SNS Topic.""" try: - response = self.SNS_CLIENT.create_topic(Name=topic_name, Attributes={'DisplayName': topic_name}, Tags=[{'Key': 'sra-solution', 'Value': solution_name}]) - topic_arn = response['TopicArn'] + response = self.SNS_CLIENT.create_topic( + Name=topic_name, + Attributes={"DisplayName": topic_name, + "KmsMasterKeyId": f"arn:{self.sts.PARTITION}:kms:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:alias/aws/sns"}, + Tags=[{"Key": "sra-solution", "Value": solution_name}] + ) + topic_arn = response["TopicArn"] self.LOGGER.info(f"SNS Topic '{topic_name}' created with ARN: {topic_arn}") return topic_arn except ClientError as e: raise ValueError(f"Error creating SNS topic: {e}") from None + def delete_sns_topic(self, topic_arn: str) -> None: + """Delete SNS Topic.""" + try: + self.SNS_CLIENT.delete_topic(TopicArn=topic_arn) + self.LOGGER.info(f"SNS Topic '{topic_arn}' deleted") + return None + except ClientError as e: + raise ValueError(f"Error deleting SNS topic: {e}") from None + + def find_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) -> bool: + """Find SNS Subscription.""" + try: + response = self.SNS_CLIENT.get_subscription_attributes( + SubscriptionArn=f"arn:{self.sts.PARTITION}:sns:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:{topic_arn}:{protocol}:{endpoint}" + ) + return True + except ClientError as e: + if e.response["Error"]["Code"] == "NotFoundException": + self.LOGGER.error(f"SNS Subscription for {endpoint} not found on topic {topic_arn}.") + return False + else: + raise ValueError(f"Error finding SNS subscription: {e}") from None + def create_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) -> None: """Create SNS Subscription.""" try: @@ -74,4 +105,4 @@ def create_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) sleep(5) # Wait for subscription to be created return None except ClientError as e: - raise ValueError(f"Error creating SNS subscription: {e}") from None \ No newline at end of file + raise ValueError(f"Error creating SNS subscription: {e}") from None From 15d474dd816068acdc6042d23bb4dea51dae6479 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 15 Aug 2024 00:40:46 -0600 Subject: [PATCH 014/395] minor mod to sns code for fix --- .../solutions/genai/bedrock_org/lambda/src/sra_sns.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index c69f80580..c81e5f5e0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -54,6 +54,9 @@ def find_sns_topic(self, topic_name: str) -> str: return response["Attributes"]["TopicArn"] except ClientError as e: if e.response["Error"]["Code"] == "NotFoundException": + self.LOGGER.error(f"SNS Topic '{topic_name}' not found exception.") + return None + elif e.response["Error"]["Code"] == "NotFound": self.LOGGER.error(f"SNS Topic '{topic_name}' not found.") return None else: From ed820a2853caabefbb82b2bfcc4715a85e6aa74e Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 15 Aug 2024 18:13:42 -0600 Subject: [PATCH 015/395] working on creating lambda --- .../{sra_check_iam_users.py => app.py} | 0 .../solutions/genai/bedrock_org/lambda/src/app.py | 10 ++++++---- .../solutions/genai/bedrock_org/lambda/src/sra_iam.py | 8 ++++---- 3 files changed, 10 insertions(+), 8 deletions(-) rename aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/{sra_check_iam_users.py => app.py} (100%) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/sra_check_iam_users.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/app.py similarity index 100% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/sra_check_iam_users.py rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/app.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 74e07714a..ab458b7d0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -104,6 +104,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") # 2) Deploy SNS topic for fanout configuration operations + # TODO(liamschn): analyze again if sns is needed for this solution topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is None: if DRY_RUN is False: @@ -126,18 +127,19 @@ def create_event(event, context): # TODO(liamschn): ensure ACCOUNT id is the delegated admin account id # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: - # 3a) Deploy execution role for custom config rule lambda + # 3a) Deploy IAM execution role for custom config rule lambda rule_lambda_name = rule.replace("_", "-") LOGGER.info(f"Deploying execution role for {rule_lambda_name} rule lambda") iam_role_search = iam.check_iam_role_exists(rule_lambda_name) - if iam_role_search is False: + if iam_role_search[0] is False: if DRY_RUN is False: LOGGER.info(f"Creating {rule_lambda_name} IAM role") - iam.create_role(rule_lambda_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"]) + role_arn = iam.create_role(rule_lambda_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"])["Role"]["Arn"] else: LOGGER.info(f"DRY_RUN: Creating {rule} IAM role") else: LOGGER.info(f"{rule_lambda_name} IAM role already exists.") + role_arn = iam_role_search[1] iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" @@ -195,7 +197,7 @@ def create_event(event, context): # https://sra-staging-891377138368-us-west-2.s3.us-west-2.amazonaws.com/sra-bedrock-org/rules/sra-check-iam-users/sra-check-iam-users.zip lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule}/{rule}.zip" LOGGER.info(f"Lambda file URL: {lambda_file_url}") - # lambdas.create_lambda_function(lambda_file_url, ) + lambdas.create_lambda_function(lambda_file_url, role_arn, rule, "app.lambda_handler", "python3.12", 900, 512) else: LOGGER.info(f"{rule} already exists. Search result: {lambda_function_search}") # 4) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 4212ed292..710ac22f7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -374,16 +374,16 @@ def check_iam_role_exists(self, role_name): bool: True if the role exists, False otherwise. """ try: - self.IAM_CLIENT.get_role(RoleName=role_name) + response = self.IAM_CLIENT.get_role(RoleName=role_name) self.LOGGER.info(f"The role '{role_name}' exists.") - return True + return True, response["Role"]["Arn"] except ClientError as error: if error.response["Error"]["Code"] == "NoSuchEntity": self.LOGGER.info(f"The role '{role_name}' does not exist.") - return False + return False, None else: # Handle other possible exceptions (e.g., permission issues) - raise + raise ValueError(f"Error performing get_role operation: {error}") from None def check_iam_policy_exists(self, policy_arn): """ From 63605f8c1f0f8a5f1e3ef1347ff3aeec20dab559 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 15 Aug 2024 19:11:57 -0600 Subject: [PATCH 016/395] tested creating lambda --- .../genai/bedrock_org/lambda/src/app.py | 58 ++++++++++--------- .../bedrock_org/lambda/src/sra_lambda.py | 4 +- .../genai/bedrock_org/lambda/src/sra_repo.py | 41 +++++++++---- 3 files changed, 64 insertions(+), 39 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ab458b7d0..17dfb26b7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -59,6 +59,7 @@ lambdas = sra_lambda.sra_lambda() sns = sra_sns.sra_sns() + def get_resource_parameters(event): global DRY_RUN @@ -128,17 +129,17 @@ def create_event(event, context): # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: # 3a) Deploy IAM execution role for custom config rule lambda - rule_lambda_name = rule.replace("_", "-") - LOGGER.info(f"Deploying execution role for {rule_lambda_name} rule lambda") - iam_role_search = iam.check_iam_role_exists(rule_lambda_name) + rule_name = rule.replace("_", "-") + LOGGER.info(f"Deploying execution role for {rule_name} rule lambda") + iam_role_search = iam.check_iam_role_exists(rule_name) if iam_role_search[0] is False: if DRY_RUN is False: - LOGGER.info(f"Creating {rule_lambda_name} IAM role") - role_arn = iam.create_role(rule_lambda_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"])["Role"]["Arn"] + LOGGER.info(f"Creating {rule_name} IAM role") + role_arn = iam.create_role(rule_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"])["Role"]["Arn"] else: LOGGER.info(f"DRY_RUN: Creating {rule} IAM role") else: - LOGGER.info(f"{rule_lambda_name} IAM role already exists.") + LOGGER.info(f"{rule_name} IAM role already exists.") role_arn = iam_role_search[1] iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ @@ -157,49 +158,50 @@ def create_event(event, context): ][1]["Resource"].replace("REGION", sts.HOME_REGION) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" - ][1]["Resource"].replace("CONFIG_RULE_NAME", rule) + ][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") - policy_arn = f"arn:{sts.PARTITION}:iam::{ACCOUNT}:policy/{rule_lambda_name}-lamdba-basic-execution" + policy_arn = f"arn:{sts.PARTITION}:iam::{ACCOUNT}:policy/{rule_name}-lamdba-basic-execution" iam_policy_search = iam.check_iam_policy_exists(policy_arn) if iam_policy_search is False: if DRY_RUN is False: - LOGGER.info(f"Creating {rule_lambda_name}-lamdba-basic-execution IAM policy") - iam.create_policy(f"{rule_lambda_name}-lamdba-basic-execution", iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]) + LOGGER.info(f"Creating {rule_name}-lamdba-basic-execution IAM policy") + iam.create_policy(f"{rule_name}-lamdba-basic-execution", iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]) else: - LOGGER.info(f"DRY _RUN: Creating {rule_lambda_name}-lamdba-basic-execution IAM policy") + LOGGER.info(f"DRY _RUN: Creating {rule_name}-lamdba-basic-execution IAM policy") else: - LOGGER.info(f"{rule_lambda_name}-lamdba-basic-execution IAM policy already exists") + LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") - policy_attach_search1 = iam.check_iam_policy_attached(rule_lambda_name, policy_arn) + policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) if policy_attach_search1 is False: if DRY_RUN is False: - LOGGER.info(f"Attaching {rule_lambda_name}-lamdba-basic-execution policy to {rule_lambda_name} IAM role") - iam.attach_policy(rule_lambda_name, policy_arn) + LOGGER.info(f"Attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") + iam.attach_policy(rule_name, policy_arn) else: - LOGGER.info(f"DRY_RUN: attaching {rule_lambda_name}-lamdba-basic-execution policy to {rule_lambda_name} IAM role") + LOGGER.info(f"DRY_RUN: attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") policy_attach_search1 = iam.check_iam_policy_attached( - rule_lambda_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole" + rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole" ) if policy_attach_search1 is False: if DRY_RUN is False: - LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_lambda_name} IAM role") - iam.attach_policy(rule_lambda_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") + LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") + iam.attach_policy(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") else: - LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_lambda_name} IAM role") + LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") # 3b) Deploy lambda for custom config rule - LOGGER.info(f"Deploying lambda function for {rule} config rule...") - lambda_function_search = lambdas.find_lambda_function(rule) + LOGGER.info(f"Deploying lambda function for {rule_name} config rule...") + lambda_function_search = lambdas.find_lambda_function(rule_name) if lambda_function_search == None: - LOGGER.info(f"{rule} lambda function not found. Creating...") - # https://sra-staging-891377138368-us-west-2.s3.us-west-2.amazonaws.com/sra-bedrock-org/rules/sra-check-iam-users/sra-check-iam-users.zip - lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule}/{rule}.zip" + LOGGER.info(f"{rule_name} lambda function not found. Creating...") + lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" LOGGER.info(f"Lambda file URL: {lambda_file_url}") - lambdas.create_lambda_function(lambda_file_url, role_arn, rule, "app.lambda_handler", "python3.12", 900, 512) + lambdas.create_lambda_function( + s3.STAGING_BUCKET, f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip", role_arn, rule_name, "app.lambda_handler", "python3.12", 900, 512 + ) else: - LOGGER.info(f"{rule} already exists. Search result: {lambda_function_search}") + LOGGER.info(f"{rule_name} already exists. Search result: {lambda_function_search}") # 4) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) # End @@ -236,6 +238,7 @@ def process_sns_records(records: list) -> None: message = json.loads(sns_info["Message"]) # deploy_config_rule(message["AccountId"], message["ConfigRuleName"], message["Regions"]) + def deploy_config_rule(account_id: str, config_rule_name: str, regions: list) -> None: """Deploy config rule. @@ -245,6 +248,7 @@ def deploy_config_rule(account_id: str, config_rule_name: str, regions: list) -> regions: list of regions to deploy the config rule """ + def lambda_handler(event, context): global RESOURCE_TYPE global LAMBDA_START diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 223c8510c..4f009163f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -65,7 +65,7 @@ def find_lambda_function(self, function_name): self.LOGGER.error(e) return None - def create_lambda_function(self, code_zip_s3_url, role_arn, function_name, handler, runtime, timeout, memory_size): + def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function_name, handler, runtime, timeout, memory_size): """Create Lambda Function.""" try: response = self.LAMBDA_CLIENT.create_function( @@ -73,7 +73,7 @@ def create_lambda_function(self, code_zip_s3_url, role_arn, function_name, handl Runtime=runtime, Handler=handler, Role=role_arn, - Code={"ZipFile": code_zip_s3_url}, + Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, Timeout=timeout, MemorySize=memory_size, ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 88714050d..3d1f5574d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -8,6 +8,7 @@ import shutil import subprocess # noqa S404 (best practice for calling pip from script) import sys + # import boto3 # from botocore.exceptions import NoCredentialsError @@ -85,7 +86,6 @@ def pip_install(self, requirements: str, package_temp_directory: str, individual stderr=subprocess.DEVNULL, ) - def zip_folder(self, path: str, zip_file: ZipFile, layer: bool = False) -> None: """Create a zipped file from a folder. @@ -113,7 +113,7 @@ def zip_folder(self, path: str, zip_file: ZipFile, layer: bool = False) -> None: # def download_file(self, repo_url_prefix, repo_file, local_folder): # self.LOGGER.info(f"Downloading {repo_file} file from {repo_url_prefix}") # http = urllib3.PoolManager() - # # repo_code_file = http.request("GET", + # # repo_code_file = http.request("GET", # with open(f"/tmp/{local_folder}{repo_file}", 'wb') as out: # repo_code_file = http.request("GET", repo_url_prefix + repo_file) # self.LOGGER.info(f"HTTP status code: {repo_code_file.status}") @@ -147,7 +147,7 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f for solution in sorted(service_solutions_folders): if os.path.isdir(os.path.join(service_dir, solution)): self.LOGGER.info(f"Solution: {solution}") - if os.path.isdir(os.path.join(service_dir, solution, "lambda/rules")): # config rules folder + if os.path.isdir(os.path.join(service_dir, solution, "lambda/rules")): # config rules folder solution_config_rules = os.path.join(service_dir, solution, "lambda/rules") config_rule_folders = sorted(os.listdir(solution_config_rules)) for config_rule in sorted(config_rule_folders): @@ -163,29 +163,51 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f self.CONFIG_RULES[solution_name] = [config_rule] os.mkdir(staging_temp_folder + upload_folder_name) os.mkdir(staging_temp_folder + upload_folder_name + "/rules") - config_rule_staging_folder_path = staging_temp_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name + config_rule_staging_folder_path = ( + staging_temp_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name + ) os.mkdir(config_rule_staging_folder_path) os.mkdir(staging_upload_folder + upload_folder_name) os.mkdir(staging_upload_folder + upload_folder_name + "/rules") - config_rule_upload_folder_path = staging_upload_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name + config_rule_upload_folder_path = ( + staging_upload_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name + ) os.mkdir(config_rule_upload_folder_path) - + self.LOGGER.info(f"DEBUG: config_rule_staging_folder_path: {config_rule_staging_folder_path}") + self.LOGGER.info(f"DEBUG: config_rule_upload_folder_path: {config_rule_upload_folder_path}") # lambda code - if os.path.exists(config_rule_source_files) and os.path.exists(os.path.join(config_rule_source_files, "requirements.txt")): + if os.path.exists(config_rule_source_files) and os.path.exists( + os.path.join(config_rule_source_files, "requirements.txt") + ): self.LOGGER.info(f"Downloading required packages for {solution} lambda...") self.pip_install( os.path.join(config_rule_source_files, "requirements.txt"), config_rule_staging_folder_path, ) for source_file in os.listdir(config_rule_source_files): + self.LOGGER.info(f"source_file: {source_file}") if os.path.isdir(os.path.join(config_rule_source_files, source_file)): self.LOGGER.info(f"{source_file} is a directory, skipping...") else: - shutil.copy(os.path.join(config_rule_source_files, source_file), staging_temp_folder + upload_folder_name + config_rule_upload_folder_name) + shutil.copy( + os.path.join(config_rule_source_files, source_file), + config_rule_staging_folder_path, + ) + self.LOGGER.info(f"DEBUG: Copied {source_file} to {config_rule_staging_folder_path}") + # DEBUG code: + self.LOGGER.info(f"DEBUG: listdir = {os.listdir(config_rule_staging_folder_path)}") + self.LOGGER.info(f"DEBUG: isdir = {os.path.isdir(config_rule_staging_folder_path)}") + for dest_file in os.listdir(config_rule_staging_folder_path): + self.LOGGER.info(f"DEBUG: listing {dest_file} in {config_rule_staging_folder_path}") lambda_target_folder = config_rule_upload_folder_path - self.LOGGER.info(f"Zipping config rule code for {solution} / {config_rule} lambda to {lambda_target_folder}{config_rule_upload_folder_name}.zip...") + self.LOGGER.info( + f"Zipping config rule code for {solution} / {config_rule} lambda to {lambda_target_folder}{config_rule_upload_folder_name}.zip..." + ) # os.mkdir(lambda_target_folder) zip_file = ZipFile(f"{lambda_target_folder}/{config_rule_upload_folder_name}.zip", "w", ZIP_DEFLATED) + self.LOGGER.info( + f"DEBUG: Zipping {config_rule_staging_folder_path} folder in to {lambda_target_folder}/{config_rule_upload_folder_name}.zip" + ) self.zip_folder(f"{config_rule_staging_folder_path}", zip_file) zip_file.close() # debug stuff: @@ -265,7 +287,6 @@ def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, s else: shutil.copy(os.path.join(cfn_template_files, cfn_template_file), cfn_templates_target_folder) - # def stage_code_to_s3(self, directory_path, bucket_name, s3_path): # """ # Uploads the prepared code directory to the staging S3 bucket. From 989d21d66a96ba5129226d5c3ca74445f409409b Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 16 Aug 2024 11:00:45 -0600 Subject: [PATCH 017/395] custom config rule deploys --- .../genai/bedrock_org/lambda/src/app.py | 41 ++++++++++++- .../bedrock_org/lambda/src/sra_config.py | 60 ++++++++++++++++++- .../bedrock_org/lambda/src/sra_lambda.py | 15 +++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 17dfb26b7..24c1adef2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -13,6 +13,7 @@ import sra_sts import sra_lambda import sra_sns +import sra_config # import sra_lambda @@ -58,6 +59,7 @@ s3 = sra_s3.sra_s3() lambdas = sra_lambda.sra_lambda() sns = sra_sns.sra_sns() +config = sra_config.sra_config() def get_resource_parameters(event): @@ -197,12 +199,45 @@ def create_event(event, context): LOGGER.info(f"{rule_name} lambda function not found. Creating...") lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" LOGGER.info(f"Lambda file URL: {lambda_file_url}") - lambdas.create_lambda_function( - s3.STAGING_BUCKET, f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip", role_arn, rule_name, "app.lambda_handler", "python3.12", 900, 512 + lambda_create = lambdas.create_lambda_function( + s3.STAGING_BUCKET, + f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip", + role_arn, + rule_name, + "app.lambda_handler", + "python3.12", + 900, + 512, ) + lambda_arn = lambda_create["FunctionArn"] else: LOGGER.info(f"{rule_name} already exists. Search result: {lambda_function_search}") - # 4) Deploy IAM user config rule (requires config solution [config_org for orgs or config_mgmt for ct]) + lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] + # 3c) Deploy the config rule (requires config solution [config_org for orgs or config_mgmt for ct]) + LOGGER.info(f"Deploying {rule_name} config rule...") + config_rule_search = config.find_config_rule(rule_name) + if config_rule_search[0] is False: + if DRY_RUN is False: + LOGGER.info(f"Creating Config policy permissions for {rule_name} lambda function") + # TODO(liamschn): search for permissions on lambda before adding the policy + lambdas.put_permissions(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", f"arn:aws:lambda:us-west-2:{ACCOUNT}:function:{rule_name}") + LOGGER.info(f"Creating {rule_name} config rule") + # TODO(liamschn): Determine if we need to add a description for the config rules + # TODO(liamschn): Determine what we will do for input parameters variable in the config rule create function + config.create_config_rule( + rule_name, + lambda_arn, + "One_Hour", + "CUSTOM_LAMBDA", + rule_name, + {"applicableResourceType": "AWS::IAM::User", "maxCount": "0"}, + "DETECTIVE", + ) + else: + LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function") + LOGGER.info(f"DRY_RUN: Creating {rule_name} config rule") + else: + LOGGER.info(f"{rule_name} config rule already exists.") # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index d486a2a4a..015338a6d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -2,7 +2,7 @@ Version: 0.1 -'bedrock_org' solution in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples +Config module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 @@ -88,3 +88,61 @@ def put_organization_config_rule(self): # Return the response return response + + def find_config_rule(self, rule_name): + """Get Config Rule.""" + # Get the Config Rule + try: + + response = self.CONFIG_CLIENT.describe_config_rules( + ConfigRuleNames=[ + rule_name, + ], + ) + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchConfigRuleException": + self.LOGGER.info(f"No such config rule: {rule_name}") + return False, None + else: + self.LOGGER.info(f"Unexpected error: {e}") + raise e + # Log the response + self.LOGGER.info(f"Config rule {rule_name} already exists: {response}") + return True, response + + + def create_config_rule(self, rule_name, lambda_arn, max_frequency, owner, description, input_params, eval_mode, scope={}): + """Create Config Rule.""" + # Create the Config Rule + response = self.CONFIG_CLIENT.put_config_rule( + ConfigRule={ + "ConfigRuleName": rule_name, + "Description": description, + "Scope": scope, + "Source": { + "Owner": owner, + "SourceIdentifier": lambda_arn, + "SourceDetails": [ + { + "EventSource": "aws.config", + # TODO(liamschn): does messagetype need to be a parameter + "MessageType": "ScheduledNotification", + "MaximumExecutionFrequency": max_frequency, + } + ], + }, + "InputParameters": json.dumps(input_params), + # "MaximumExecutionFrequency": max_frequency, + "EvaluationModes": [ + { + 'Mode': eval_mode + }, + ] + } + ) + + # Log the response + sra_config.LOGGER.info(response) + + # Return the response + return response diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 4f009163f..066416170 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -105,6 +105,21 @@ def put_permissions(self, function_name, statement_id, principal, action, source SourceArn=source_arn, ) return response + except ClientError as e: + self.LOGGER.error(e) + return None + + def put_permissions_acct(self, function_name, statement_id, principal, action, source_acct): + """Put Lambda Function Permissions.""" + try: + response = self.LAMBDA_CLIENT.add_permission( + FunctionName=function_name, + StatementId=statement_id, + Action=action, + Principal=principal, + SourceAccount=source_acct, + ) + return response except ClientError as e: self.LOGGER.error(e) return None \ No newline at end of file From fddcb6ced3456f403d2538a3f50d6c239fdfebb8 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 16 Aug 2024 14:56:50 -0600 Subject: [PATCH 018/395] move operations in to separate functions (not sns yet) --- .../genai/bedrock_org/lambda/src/app.py | 243 ++++++++++-------- .../bedrock_org/lambda/src/sra_config.py | 5 +- .../genai/bedrock_org/lambda/src/sra_iam.py | 8 +- .../bedrock_org/lambda/src/sra_lambda.py | 3 +- 4 files changed, 144 insertions(+), 115 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 24c1adef2..530622cff 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -125,119 +125,23 @@ def create_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") - # TODO(liamschn): move iam deployment code to another function with parameters for reusability - # TODO(liamschn): use STS to assume in to the delegated admin account for config - # TODO(liamschn): ensure ACCOUNT id is the delegated admin account id + # TODO(liamschn): Get list of accounts and regions from payload for each config rule and iterate through each # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: # 3a) Deploy IAM execution role for custom config rule lambda rule_name = rule.replace("_", "-") - LOGGER.info(f"Deploying execution role for {rule_name} rule lambda") - iam_role_search = iam.check_iam_role_exists(rule_name) - if iam_role_search[0] is False: - if DRY_RUN is False: - LOGGER.info(f"Creating {rule_name} IAM role") - role_arn = iam.create_role(rule_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"])["Role"]["Arn"] - else: - LOGGER.info(f"DRY_RUN: Creating {rule} IAM role") - else: - LOGGER.info(f"{rule_name} IAM role already exists.") - role_arn = iam_role_search[1] - - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][0]["Resource"].replace("ACCOUNT_ID", ACCOUNT) - # iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("PARTITION", sts.PARTITION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][0]["Resource"].replace("REGION", sts.HOME_REGION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][1]["Resource"].replace("ACCOUNT_ID", ACCOUNT) - # iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("PARTITION", sts.PARTITION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][1]["Resource"].replace("REGION", sts.HOME_REGION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) - LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") - - policy_arn = f"arn:{sts.PARTITION}:iam::{ACCOUNT}:policy/{rule_name}-lamdba-basic-execution" - iam_policy_search = iam.check_iam_policy_exists(policy_arn) - if iam_policy_search is False: - if DRY_RUN is False: - LOGGER.info(f"Creating {rule_name}-lamdba-basic-execution IAM policy") - iam.create_policy(f"{rule_name}-lamdba-basic-execution", iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]) - else: - LOGGER.info(f"DRY _RUN: Creating {rule_name}-lamdba-basic-execution IAM policy") - else: - LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") - - policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) - if policy_attach_search1 is False: - if DRY_RUN is False: - LOGGER.info(f"Attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") - iam.attach_policy(rule_name, policy_arn) - else: - LOGGER.info(f"DRY_RUN: attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") - - policy_attach_search1 = iam.check_iam_policy_attached( - rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole" - ) - if policy_attach_search1 is False: - if DRY_RUN is False: - LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") - iam.attach_policy(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") - else: - LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") + role_arn = deploy_iam_role(ACCOUNT, rule_name) + # TODO(liamschn): need to add a wait after creating the role and check to see if it exists or else: An error occurred (InvalidParameterValueException) when calling the CreateFunction operation: The role defined for the function cannot be assumed by Lambda. + # 3b) Deploy lambda for custom config rule - LOGGER.info(f"Deploying lambda function for {rule_name} config rule...") - lambda_function_search = lambdas.find_lambda_function(rule_name) - if lambda_function_search == None: - LOGGER.info(f"{rule_name} lambda function not found. Creating...") - lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" - LOGGER.info(f"Lambda file URL: {lambda_file_url}") - lambda_create = lambdas.create_lambda_function( - s3.STAGING_BUCKET, - f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip", - role_arn, - rule_name, - "app.lambda_handler", - "python3.12", - 900, - 512, - ) - lambda_arn = lambda_create["FunctionArn"] - else: - LOGGER.info(f"{rule_name} already exists. Search result: {lambda_function_search}") - lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] + # TODO(liamschn): use STS assume into account and region + lambda_arn = deploy_lambda_function(ACCOUNT, rule_name, role_arn, []) + # TODO(liamschn): needo to add a wait after creating lambda function or error: + # An error occurred (InvalidParameterValueException) when calling the PutConfigRule operation: The specified AWS Lambda function is not in Active state. Please retry after sometimeLAMBDA_WARNING: Unhandled exception. The most likely cause is an issue in the function code. However, in rare cases, a Lambda runtime update can cause unexpected function behavior. For functions using managed runtimes, runtime updates can be triggered by a function change, or can be applied automatically. To determine if the runtime has been updated, check the runtime version in the INIT_START log entry. If this error correlates with a change in the runtime version, you may be able to mitigate this error by temporarily rolling back to the previous runtime version. For more information, see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-update.html + # 3c) Deploy the config rule (requires config solution [config_org for orgs or config_mgmt for ct]) - LOGGER.info(f"Deploying {rule_name} config rule...") - config_rule_search = config.find_config_rule(rule_name) - if config_rule_search[0] is False: - if DRY_RUN is False: - LOGGER.info(f"Creating Config policy permissions for {rule_name} lambda function") - # TODO(liamschn): search for permissions on lambda before adding the policy - lambdas.put_permissions(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", f"arn:aws:lambda:us-west-2:{ACCOUNT}:function:{rule_name}") - LOGGER.info(f"Creating {rule_name} config rule") - # TODO(liamschn): Determine if we need to add a description for the config rules - # TODO(liamschn): Determine what we will do for input parameters variable in the config rule create function - config.create_config_rule( - rule_name, - lambda_arn, - "One_Hour", - "CUSTOM_LAMBDA", - rule_name, - {"applicableResourceType": "AWS::IAM::User", "maxCount": "0"}, - "DETECTIVE", - ) - else: - LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function") - LOGGER.info(f"DRY_RUN: Creating {rule_name} config rule") - else: - LOGGER.info(f"{rule_name} config rule already exists.") + config_rule_arn = deploy_config_rule(ACCOUNT, rule_name, lambda_arn, []) # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: @@ -273,15 +177,138 @@ def process_sns_records(records: list) -> None: message = json.loads(sns_info["Message"]) # deploy_config_rule(message["AccountId"], message["ConfigRuleName"], message["Regions"]) +def deploy_iam_role(account_id: str, rule_name: str) -> str: + """Deploy IAM role. -def deploy_config_rule(account_id: str, config_rule_name: str, regions: list) -> None: - """Deploy config rule. + Args: + account_id: AWS account ID + rule_name: config rule name + """ + LOGGER.info(f"Deploying execution role for {rule_name} rule lambda") + iam_role_search = iam.check_iam_role_exists(rule_name) + if iam_role_search[0] is False: + if DRY_RUN is False: + LOGGER.info(f"Creating {rule_name} IAM role") + role_arn = iam.create_role(rule_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"], SOLUTION_NAME)["Role"]["Arn"] + else: + LOGGER.info(f"DRY_RUN: Creating {rule} IAM role") + else: + LOGGER.info(f"{rule_name} IAM role already exists.") + role_arn = iam_role_search[1] + + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][0]["Resource"].replace("ACCOUNT_ID", ACCOUNT) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][0]["Resource"].replace("REGION", sts.HOME_REGION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][1]["Resource"].replace("ACCOUNT_ID", ACCOUNT) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][1]["Resource"].replace("REGION", sts.HOME_REGION) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + "Statement" + ][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) + LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") + + policy_arn = f"arn:{sts.PARTITION}:iam::{ACCOUNT}:policy/{rule_name}-lamdba-basic-execution" + iam_policy_search = iam.check_iam_policy_exists(policy_arn) + if iam_policy_search is False: + if DRY_RUN is False: + LOGGER.info(f"Creating {rule_name}-lamdba-basic-execution IAM policy") + iam.create_policy(f"{rule_name}-lamdba-basic-execution", iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"], SOLUTION_NAME) + else: + LOGGER.info(f"DRY _RUN: Creating {rule_name}-lamdba-basic-execution IAM policy") + else: + LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") + + policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) + if policy_attach_search1 is False: + if DRY_RUN is False: + LOGGER.info(f"Attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") + iam.attach_policy(rule_name, policy_arn) + else: + LOGGER.info(f"DRY_RUN: attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") + + policy_attach_search1 = iam.check_iam_policy_attached( + rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole" + ) + if policy_attach_search1 is False: + if DRY_RUN is False: + LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") + iam.attach_policy(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") + else: + LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") + return role_arn + +def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regions: list) -> None: + """Deploy lambda function. Args: account_id: AWS account ID config_rule_name: config rule name + role_arn: IAM role ARN + regions: list of regions to deploy the lambda function + """ + LOGGER.info(f"Deploying lambda function for {rule_name} config rule...") + lambda_function_search = lambdas.find_lambda_function(rule_name) + if lambda_function_search == None: + LOGGER.info(f"{rule_name} lambda function not found. Creating...") + lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" + LOGGER.info(f"Lambda file URL: {lambda_file_url}") + lambda_create = lambdas.create_lambda_function( + s3.STAGING_BUCKET, + f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip", + role_arn, + rule_name, + "app.lambda_handler", + "python3.12", + 900, + 512, + SOLUTION_NAME + ) + lambda_arn = lambda_create["FunctionArn"] + else: + LOGGER.info(f"{rule_name} already exists. Search result: {lambda_function_search}") + lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] + return lambda_arn + +def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, regions: list) -> None: + """Deploy config rule. + + Args: + account_id: AWS account ID + rule_name: config rule name + lambda_arn: lambda function ARN regions: list of regions to deploy the config rule """ + LOGGER.info(f"Deploying {rule_name} config rule...") + config_rule_search = config.find_config_rule(rule_name) + if config_rule_search[0] is False: + if DRY_RUN is False: + LOGGER.info(f"Creating Config policy permissions for {rule_name} lambda function") + # TODO(liamschn): search for permissions on lambda before adding the policy + lambdas.put_permissions_acct(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", ACCOUNT) + LOGGER.info(f"Creating {rule_name} config rule") + # TODO(liamschn): Determine if we need to add a description for the config rules + # TODO(liamschn): Determine what we will do for input parameters variable in the config rule create function + config.create_config_rule( + rule_name, + lambda_arn, + "One_Hour", + "CUSTOM_LAMBDA", + rule_name, + {"applicableResourceType": "AWS::IAM::User", "maxCount": "0"}, + "DETECTIVE", + SOLUTION_NAME + ) + else: + LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function") + LOGGER.info(f"DRY_RUN: Creating {rule_name} config rule") + else: + LOGGER.info(f"{rule_name} config rule already exists.") def lambda_handler(event, context): diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 015338a6d..36a42091a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -111,7 +111,7 @@ def find_config_rule(self, rule_name): return True, response - def create_config_rule(self, rule_name, lambda_arn, max_frequency, owner, description, input_params, eval_mode, scope={}): + def create_config_rule(self, rule_name, lambda_arn, max_frequency, owner, description, input_params, eval_mode, solution_name, scope={}): """Create Config Rule.""" # Create the Config Rule response = self.CONFIG_CLIENT.put_config_rule( @@ -138,7 +138,8 @@ def create_config_rule(self, rule_name, lambda_arn, max_frequency, owner, descri 'Mode': eval_mode }, ] - } + }, + Tags=[{"Key": "sra-solution", "Value": solution_name}] ) # Log the response diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 710ac22f7..03aeeeef1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -263,7 +263,7 @@ def wait_for_stack_instances(self, stack_set_name, retries: int = 30): # todo(l return # IAM service functions - def create_role(self, role_name: str, trust_policy: dict) -> CreateRoleResponseTypeDef: + def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> CreateRoleResponseTypeDef: """Create IAM role. Args: @@ -275,9 +275,9 @@ def create_role(self, role_name: str, trust_policy: dict) -> CreateRoleResponseT Dictionary output of a successful CreateRole request """ self.LOGGER.info("Creating role %s.", role_name) - return self.IAM_CLIENT.create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy)) + return self.IAM_CLIENT.create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", "Value": solution_name}]) - def create_policy(self, policy_name: str, policy_document: dict) -> CreatePolicyResponseTypeDef: + def create_policy(self, policy_name: str, policy_document: dict, solution_name: str) -> CreatePolicyResponseTypeDef: """Create IAM policy. Args: @@ -289,7 +289,7 @@ def create_policy(self, policy_name: str, policy_document: dict) -> CreatePolicy Dictionary output of a successful CreatePolicy request """ self.LOGGER.info(f"Creating {policy_name} IAM policy") - return self.IAM_CLIENT.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(policy_document)) + return self.IAM_CLIENT.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", "Value": solution_name}]) # def attach_policy(self, role_name: str, policy_name: str, policy_document: str) -> EmptyResponseMetadataTypeDef: # """Attach policy to IAM role. diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 066416170..32b5b7570 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -65,7 +65,7 @@ def find_lambda_function(self, function_name): self.LOGGER.error(e) return None - def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function_name, handler, runtime, timeout, memory_size): + def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function_name, handler, runtime, timeout, memory_size, solution_name): """Create Lambda Function.""" try: response = self.LAMBDA_CLIENT.create_function( @@ -76,6 +76,7 @@ def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, Timeout=timeout, MemorySize=memory_size, + Tags={"sra-solution": solution_name}, ) return response except ClientError as e: From 7b9e76e60d647ab8ee1a7a92d65a39ca5f6e9d71 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 16 Aug 2024 18:53:06 -0600 Subject: [PATCH 019/395] fixed lambda/config timing create errors --- .../genai/bedrock_org/lambda/src/app.py | 16 ++--- .../bedrock_org/lambda/src/sra_lambda.py | 71 ++++++++++++++----- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 530622cff..b6383b6a9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -133,7 +133,6 @@ def create_event(event, context): role_arn = deploy_iam_role(ACCOUNT, rule_name) # TODO(liamschn): need to add a wait after creating the role and check to see if it exists or else: An error occurred (InvalidParameterValueException) when calling the CreateFunction operation: The role defined for the function cannot be assumed by Lambda. - # 3b) Deploy lambda for custom config rule # TODO(liamschn): use STS assume into account and region lambda_arn = deploy_lambda_function(ACCOUNT, rule_name, role_arn, []) @@ -177,6 +176,7 @@ def process_sns_records(records: list) -> None: message = json.loads(sns_info["Message"]) # deploy_config_rule(message["AccountId"], message["ConfigRuleName"], message["Regions"]) + def deploy_iam_role(account_id: str, rule_name: str) -> str: """Deploy IAM role. @@ -232,9 +232,7 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"DRY_RUN: attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") - policy_attach_search1 = iam.check_iam_policy_attached( - rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole" - ) + policy_attach_search1 = iam.check_iam_policy_attached(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") if policy_attach_search1 is False: if DRY_RUN is False: LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") @@ -243,7 +241,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") return role_arn -def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regions: list) -> None: + +def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regions: list) -> str: """Deploy lambda function. Args: @@ -267,14 +266,15 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio "python3.12", 900, 512, - SOLUTION_NAME + SOLUTION_NAME, ) - lambda_arn = lambda_create["FunctionArn"] + lambda_arn = lambda_create["Configuration"]["FunctionArn"] else: LOGGER.info(f"{rule_name} already exists. Search result: {lambda_function_search}") lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] return lambda_arn + def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, regions: list) -> None: """Deploy config rule. @@ -302,7 +302,7 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, regions rule_name, {"applicableResourceType": "AWS::IAM::User", "maxCount": "0"}, "DETECTIVE", - SOLUTION_NAME + SOLUTION_NAME, ) else: LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 32b5b7570..7a6da8732 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -33,6 +33,7 @@ # from mypy_boto3_cloudformation import CloudFormationClient # from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_lambda.client import LambdaClient + # from mypy_boto3_iam.client import IAMClient # from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef @@ -67,21 +68,54 @@ def find_lambda_function(self, function_name): def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function_name, handler, runtime, timeout, memory_size, solution_name): """Create Lambda Function.""" + while True: + try: + create_response = self.LAMBDA_CLIENT.create_function( + FunctionName=function_name, + Runtime=runtime, + Handler=handler, + Role=role_arn, + Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, + Timeout=timeout, + MemorySize=memory_size, + Tags={"sra-solution": solution_name}, + ) + break + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceConflictException": + try: + self.LOGGER.info(f"{function_name} function already exists. Updating...") + update_response = self.LAMBDA_CLIENT.update_function_code( + FunctionName=function_name, + Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, + ) + self.LOGGER.info(f"Lambda function code updated successfully: {response}") + break + except Exception as e: + self.LOGGER.info(f"Error deploying Lambda function: {e}") + break + elif error.response["Error"]["Code"] == "InvalidParameterValueException": + self.LOGGER.info(f"Lambda cannot assume role yet. Retrying...") + # TODO(liamschn): need to add a maximum retry mechanism here + sleep(5) + else: + self.LOGGER.info(f"Error deploying Lambda function: {error}") + break + # txt_response.insert(tk.END, f"Error deploying Lambda: {e}\n") try: - response = self.LAMBDA_CLIENT.create_function( - FunctionName=function_name, - Runtime=runtime, - Handler=handler, - Role=role_arn, - Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, - Timeout=timeout, - MemorySize=memory_size, - Tags={"sra-solution": solution_name}, - ) - return response - except ClientError as e: - self.LOGGER.error(e) - return None + while True: + get_response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) + if get_response["Configuration"]["State"] == "Active": + self.LOGGER.info(f"Lambda function {function_name} is now active") + break + # TODO(liamschn): need to add a maximum retry mechanism here + sleep(5) + except Exception as e: + self.LOGGER.info(f"Error getting Lambda function: {e}") + + # except ClientError as e: + # self.LOGGER.error(e) + return get_response def get_permissions(self, function_name): """Get Lambda Function Permissions.""" @@ -107,7 +141,12 @@ def put_permissions(self, function_name, statement_id, principal, action, source ) return response except ClientError as e: - self.LOGGER.error(e) + if e.response["Error"]["Code"] == "ResourceConflictException": + # TODO(liamschn): consider updating the permission here + self.LOGGER.info(f"{function_name} permission already exists.") + return None + else: + self.LOGGER.info(f"Error adding lambda permission: {e}") return None def put_permissions_acct(self, function_name, statement_id, principal, action, source_acct): @@ -123,4 +162,4 @@ def put_permissions_acct(self, function_name, statement_id, principal, action, s return response except ClientError as e: self.LOGGER.error(e) - return None \ No newline at end of file + return None From cecce33563e0a63a96531f4440f580e40cc1738b Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sun, 18 Aug 2024 23:09:12 -0600 Subject: [PATCH 020/395] accounts/regions parameters/deployment; not tested --- .../genai/bedrock_org/lambda/src/app.py | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b6383b6a9..80995a1b0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -37,6 +37,8 @@ STATE_TABLE: str = "sra_state" SOLUTION_NAME: str = "sra-bedrock-org" # SOLUTION_DIR: str = "bedrock_org" +RULE_REGIONS_ACCOUNTS = {} +GOVERNED_REGIONS = [] LAMBDA_START: str = "" LAMBDA_FINISH: str = "" @@ -64,6 +66,8 @@ def get_resource_parameters(event): global DRY_RUN + global RULE_REGIONS_ACCOUNTS + global GOVERNED_REGIONS LOGGER.info("Getting resource params...") # TODO(liamschn): what parameters do we need for this solution? @@ -73,6 +77,9 @@ def get_resource_parameters(event): repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" sts.CONFIGURATION_ROLE = "sra-execution" + + GOVERNED_REGIONS = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/regions/customer-control-tower-regions") + staging_bucket_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/staging-s3-bucket-name") if staging_bucket_param[0] is True: s3.STAGING_BUCKET = staging_bucket_param[1] @@ -80,7 +87,9 @@ def get_resource_parameters(event): else: LOGGER.info("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") raise ValueError("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") from None - + # TODO(liamschn): continue working on getting this parameter. see test_even_bedrock_org.txt (or lambda) for test event; need to test in CFN too + if "RULE_REGIONS_ACCOUNTS" in event["ResourceProperties"]: + RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'","\"")) if event["ResourceProperties"]["DRY_RUN"] == "true": # dry run LOGGER.info("Dry run enabled...") @@ -125,22 +134,30 @@ def create_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") - # TODO(liamschn): Get list of accounts and regions from payload for each config rule and iterate through each # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: + # Get bedrock solution rule accounts and regions + if SOLUTION_NAME in RULE_REGIONS_ACCOUNTS: + if "accounts" in RULE_REGIONS_ACCOUNTS[SOLUTION_NAME]: + rule_accounts = RULE_REGIONS_ACCOUNTS[SOLUTION_NAME]["accounts"] + else: + rule_accounts = [] + if "regions" in RULE_REGIONS_ACCOUNTS[SOLUTION_NAME]: + rule_regions = RULE_REGIONS_ACCOUNTS[SOLUTION_NAME]["regions"] + else: + rule_regions = [] # 3a) Deploy IAM execution role for custom config rule lambda rule_name = rule.replace("_", "-") - role_arn = deploy_iam_role(ACCOUNT, rule_name) - # TODO(liamschn): need to add a wait after creating the role and check to see if it exists or else: An error occurred (InvalidParameterValueException) when calling the CreateFunction operation: The role defined for the function cannot be assumed by Lambda. + for acct in rule_accounts: + role_arn = deploy_iam_role(acct, rule_name) - # 3b) Deploy lambda for custom config rule - # TODO(liamschn): use STS assume into account and region - lambda_arn = deploy_lambda_function(ACCOUNT, rule_name, role_arn, []) - # TODO(liamschn): needo to add a wait after creating lambda function or error: - # An error occurred (InvalidParameterValueException) when calling the PutConfigRule operation: The specified AWS Lambda function is not in Active state. Please retry after sometimeLAMBDA_WARNING: Unhandled exception. The most likely cause is an issue in the function code. However, in rare cases, a Lambda runtime update can cause unexpected function behavior. For functions using managed runtimes, runtime updates can be triggered by a function change, or can be applied automatically. To determine if the runtime has been updated, check the runtime version in the INIT_START log entry. If this error correlates with a change in the runtime version, you may be able to mitigate this error by temporarily rolling back to the previous runtime version. For more information, see https://docs.aws.amazon.com/lambda/latest/dg/runtimes-update.html + for acct in rule_accounts: + for region in rule_regions: + # 3b) Deploy lambda for custom config rule + lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) - # 3c) Deploy the config rule (requires config solution [config_org for orgs or config_mgmt for ct]) - config_rule_arn = deploy_config_rule(ACCOUNT, rule_name, lambda_arn, []) + # 3c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) + config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region) # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: @@ -184,6 +201,7 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: account_id: AWS account ID rule_name: config rule name """ + iam.IAM_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "iam", REGION) LOGGER.info(f"Deploying execution role for {rule_name} rule lambda") iam_role_search = iam.check_iam_role_exists(rule_name) if iam_role_search[0] is False: @@ -242,7 +260,7 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: return role_arn -def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regions: list) -> str: +def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, region: str) -> str: """Deploy lambda function. Args: @@ -251,7 +269,8 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio role_arn: IAM role ARN regions: list of regions to deploy the lambda function """ - LOGGER.info(f"Deploying lambda function for {rule_name} config rule...") + lambdas.LAMBDA_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "lambda", region) + LOGGER.info(f"Deploying lambda function for {rule_name} config rule in {region}...") lambda_function_search = lambdas.find_lambda_function(rule_name) if lambda_function_search == None: LOGGER.info(f"{rule_name} lambda function not found. Creating...") @@ -275,7 +294,7 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio return lambda_arn -def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, regions: list) -> None: +def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: str) -> None: """Deploy config rule. Args: @@ -284,7 +303,8 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, regions lambda_arn: lambda function ARN regions: list of regions to deploy the config rule """ - LOGGER.info(f"Deploying {rule_name} config rule...") + LOGGER.info(f"Deploying {rule_name} config rule in {region}...") + config.CONFIG_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) if config_rule_search[0] is False: if DRY_RUN is False: From e71d8b9acbb51adfc742cc661e7aae187496e893 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 19 Aug 2024 13:37:03 -0600 Subject: [PATCH 021/395] deploys to accounts/regions specified; test-code only; still needs work --- .../genai/bedrock_org/lambda/src/app.py | 74 ++++++++++--------- .../genai/bedrock_org/lambda/src/sra_iam.py | 4 +- .../bedrock_org/lambda/src/sra_lambda.py | 19 +++-- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 80995a1b0..286addb74 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -89,7 +89,7 @@ def get_resource_parameters(event): raise ValueError("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") from None # TODO(liamschn): continue working on getting this parameter. see test_even_bedrock_org.txt (or lambda) for test event; need to test in CFN too if "RULE_REGIONS_ACCOUNTS" in event["ResourceProperties"]: - RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'","\"")) + RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'", '"')) if event["ResourceProperties"]["DRY_RUN"] == "true": # dry run LOGGER.info("Dry run enabled...") @@ -136,18 +136,22 @@ def create_event(event, context): # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: + rule_name = rule.replace("_", "-") # Get bedrock solution rule accounts and regions - if SOLUTION_NAME in RULE_REGIONS_ACCOUNTS: - if "accounts" in RULE_REGIONS_ACCOUNTS[SOLUTION_NAME]: - rule_accounts = RULE_REGIONS_ACCOUNTS[SOLUTION_NAME]["accounts"] + if rule_name in RULE_REGIONS_ACCOUNTS: + if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: + rule_accounts = RULE_REGIONS_ACCOUNTS[rule_name]["accounts"] else: rule_accounts = [] - if "regions" in RULE_REGIONS_ACCOUNTS[SOLUTION_NAME]: - rule_regions = RULE_REGIONS_ACCOUNTS[SOLUTION_NAME]["regions"] + if "regions" in RULE_REGIONS_ACCOUNTS[rule_name]: + rule_regions = RULE_REGIONS_ACCOUNTS[rule_name]["regions"] else: rule_regions = [] + else: + LOGGER.info(f"No {rule_name} accounts or regions found in RULE_REGIONS_ACCOUNTS dictionary. Dictionary: {RULE_REGIONS_ACCOUNTS}") + # TODO(liamschn): setup default for org accounts and governed regions + LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") # 3a) Deploy IAM execution role for custom config rule lambda - rule_name = rule.replace("_", "-") for acct in rule_accounts: role_arn = deploy_iam_role(acct, rule_name) @@ -202,61 +206,61 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: rule_name: config rule name """ iam.IAM_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "iam", REGION) - LOGGER.info(f"Deploying execution role for {rule_name} rule lambda") + LOGGER.info(f"Deploying IAM {rule_name} execution role for rule lambda in {account_id}...") iam_role_search = iam.check_iam_role_exists(rule_name) if iam_role_search[0] is False: if DRY_RUN is False: LOGGER.info(f"Creating {rule_name} IAM role") role_arn = iam.create_role(rule_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"], SOLUTION_NAME)["Role"]["Arn"] else: - LOGGER.info(f"DRY_RUN: Creating {rule} IAM role") + LOGGER.info(f"DRY_RUN: Creating {rule_name} IAM role") else: LOGGER.info(f"{rule_name} IAM role already exists.") role_arn = iam_role_search[1] iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" - ][0]["Resource"].replace("ACCOUNT_ID", ACCOUNT) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][0]["Resource"].replace("REGION", sts.HOME_REGION) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][1]["Resource"].replace("ACCOUNT_ID", ACCOUNT) + ][0]["Resource"].replace("ACCOUNT_ID", account_id) + # iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + # "Statement" + # ][0]["Resource"].replace("REGION", sts.HOME_REGION) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" - ][1]["Resource"].replace("REGION", sts.HOME_REGION) + ][1]["Resource"].replace("ACCOUNT_ID", account_id) + # iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ + # "Statement" + # ][1]["Resource"].replace("REGION", sts.HOME_REGION) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" ][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") - policy_arn = f"arn:{sts.PARTITION}:iam::{ACCOUNT}:policy/{rule_name}-lamdba-basic-execution" + policy_arn = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}-lamdba-basic-execution" iam_policy_search = iam.check_iam_policy_exists(policy_arn) if iam_policy_search is False: if DRY_RUN is False: - LOGGER.info(f"Creating {rule_name}-lamdba-basic-execution IAM policy") + LOGGER.info(f"Creating {rule_name}-lamdba-basic-execution IAM policy in {account_id}...") iam.create_policy(f"{rule_name}-lamdba-basic-execution", iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"], SOLUTION_NAME) else: - LOGGER.info(f"DRY _RUN: Creating {rule_name}-lamdba-basic-execution IAM policy") + LOGGER.info(f"DRY _RUN: Creating {rule_name}-lamdba-basic-execution IAM policy in {account_id}...") else: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) if policy_attach_search1 is False: if DRY_RUN is False: - LOGGER.info(f"Attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") + LOGGER.info(f"Attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role in {account_id}...") iam.attach_policy(rule_name, policy_arn) else: - LOGGER.info(f"DRY_RUN: attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role") + LOGGER.info(f"DRY_RUN: attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role in {account_id}...") policy_attach_search1 = iam.check_iam_policy_attached(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") if policy_attach_search1 is False: if DRY_RUN is False: - LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") + LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role in {account_id}...") iam.attach_policy(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") else: - LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role") + LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role in {account_id}...") return role_arn @@ -270,12 +274,12 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio regions: list of regions to deploy the lambda function """ lambdas.LAMBDA_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "lambda", region) - LOGGER.info(f"Deploying lambda function for {rule_name} config rule in {region}...") + LOGGER.info(f"Deploying lambda function for {rule_name} config rule to {account_id} in {region}...") lambda_function_search = lambdas.find_lambda_function(rule_name) if lambda_function_search == None: - LOGGER.info(f"{rule_name} lambda function not found. Creating...") - lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" - LOGGER.info(f"Lambda file URL: {lambda_file_url}") + LOGGER.info(f"{rule_name} lambda function not found in {account_id}. Creating...") + # lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" + # LOGGER.info(f"Lambda file URL: {lambda_file_url}") lambda_create = lambdas.create_lambda_function( s3.STAGING_BUCKET, f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip", @@ -289,7 +293,7 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio ) lambda_arn = lambda_create["Configuration"]["FunctionArn"] else: - LOGGER.info(f"{rule_name} already exists. Search result: {lambda_function_search}") + LOGGER.info(f"{rule_name} already exists in {account_id}. Search result: {lambda_function_search}") lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] return lambda_arn @@ -303,15 +307,15 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: lambda_arn: lambda function ARN regions: list of regions to deploy the config rule """ - LOGGER.info(f"Deploying {rule_name} config rule in {region}...") + LOGGER.info(f"Deploying {rule_name} config rule to {account_id} in {region}...") config.CONFIG_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) if config_rule_search[0] is False: if DRY_RUN is False: - LOGGER.info(f"Creating Config policy permissions for {rule_name} lambda function") + LOGGER.info(f"Creating Config policy permissions for {rule_name} lambda function in {account_id} in {region}...") # TODO(liamschn): search for permissions on lambda before adding the policy - lambdas.put_permissions_acct(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", ACCOUNT) - LOGGER.info(f"Creating {rule_name} config rule") + lambdas.put_permissions_acct(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", account_id) + LOGGER.info(f"Creating {rule_name} config rule in {account_id} in {region}...") # TODO(liamschn): Determine if we need to add a description for the config rules # TODO(liamschn): Determine what we will do for input parameters variable in the config rule create function config.create_config_rule( @@ -325,8 +329,8 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: SOLUTION_NAME, ) else: - LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function") - LOGGER.info(f"DRY_RUN: Creating {rule_name} config rule") + LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function in {account_id} in {region}...") + LOGGER.info(f"DRY_RUN: Creating {rule_name} config rule in {account_id} in {region}...") else: LOGGER.info(f"{rule_name} config rule already exists.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 03aeeeef1..775d0a92f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -96,11 +96,11 @@ class sra_iam: "sra-lambda-basic-execution": { "Version": "2012-10-17", "Statement": [ - {"Effect": "Allow", "Action": "logs:CreateLogGroup", "Resource": "arn:" + PARTITION + ":logs:REGION:ACCOUNT_ID:*"}, + {"Effect": "Allow", "Action": "logs:CreateLogGroup", "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:*"}, { "Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], - "Resource": "arn:" + PARTITION + ":logs:REGION:ACCOUNT_ID:log-group:/aws/lambda/CONFIG_RULE_NAME:*", + "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:log-group:/aws/lambda/CONFIG_RULE_NAME:*", }, ], }, diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 7a6da8732..e60036540 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -68,14 +68,20 @@ def find_lambda_function(self, function_name): def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function_name, handler, runtime, timeout, memory_size, solution_name): """Create Lambda Function.""" - while True: + self.LOGGER.info(f"Role ARN passed to create_lambda_function: {role_arn}...") + max_retries = 10 + retries = 0 + # temp testing: /tmp/sra_staging_upload/sra-bedrock-org/rules/sra-check-iam-users/sra-check-iam-users.zip + zip_file_path = "/tmp/sra_staging_upload/sra-bedrock-org/rules/sra-check-iam-users/sra-check-iam-users.zip" + while retries < max_retries: try: create_response = self.LAMBDA_CLIENT.create_function( FunctionName=function_name, Runtime=runtime, Handler=handler, Role=role_arn, - Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, + # Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, + Code={"ZipFile": open(zip_file_path, "rb").read()}, Timeout=timeout, MemorySize=memory_size, Tags={"sra-solution": solution_name}, @@ -89,7 +95,7 @@ def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function FunctionName=function_name, Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, ) - self.LOGGER.info(f"Lambda function code updated successfully: {response}") + self.LOGGER.info(f"Lambda function code updated successfully: {update_response}") break except Exception as e: self.LOGGER.info(f"Error deploying Lambda function: {e}") @@ -97,22 +103,25 @@ def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function elif error.response["Error"]["Code"] == "InvalidParameterValueException": self.LOGGER.info(f"Lambda cannot assume role yet. Retrying...") # TODO(liamschn): need to add a maximum retry mechanism here + retries += 1 sleep(5) else: self.LOGGER.info(f"Error deploying Lambda function: {error}") break # txt_response.insert(tk.END, f"Error deploying Lambda: {e}\n") try: - while True: + retries = 0 + while retries < max_retries: get_response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) if get_response["Configuration"]["State"] == "Active": self.LOGGER.info(f"Lambda function {function_name} is now active") break # TODO(liamschn): need to add a maximum retry mechanism here + retries += 1 sleep(5) except Exception as e: self.LOGGER.info(f"Error getting Lambda function: {e}") - + # except ClientError as e: # self.LOGGER.error(e) return get_response From 7d2de812c9482ebc6a88b0591e0bd942bba3741f Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 19 Aug 2024 23:08:34 -0600 Subject: [PATCH 022/395] updated variables; working code --- .../solutions/genai/bedrock_org/lambda/src/app.py | 7 +++---- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 7 ++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 286addb74..ecbff8b37 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -278,11 +278,10 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio lambda_function_search = lambdas.find_lambda_function(rule_name) if lambda_function_search == None: LOGGER.info(f"{rule_name} lambda function not found in {account_id}. Creating...") - # lambda_file_url = f"https://{s3.STAGING_BUCKET}.{iam.S3_HOST_NAME}/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" - # LOGGER.info(f"Lambda file URL: {lambda_file_url}") + lambda_source_zip = f"/tmp/sra_staging_upload/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" + LOGGER.info(f"Lambda zip file: {lambda_source_zip}") lambda_create = lambdas.create_lambda_function( - s3.STAGING_BUCKET, - f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip", + lambda_source_zip, role_arn, rule_name, "app.lambda_handler", diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index e60036540..d89181e4b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -66,13 +66,11 @@ def find_lambda_function(self, function_name): self.LOGGER.error(e) return None - def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function_name, handler, runtime, timeout, memory_size, solution_name): + def create_lambda_function(self, code_zip_file, role_arn, function_name, handler, runtime, timeout, memory_size, solution_name): """Create Lambda Function.""" self.LOGGER.info(f"Role ARN passed to create_lambda_function: {role_arn}...") max_retries = 10 retries = 0 - # temp testing: /tmp/sra_staging_upload/sra-bedrock-org/rules/sra-check-iam-users/sra-check-iam-users.zip - zip_file_path = "/tmp/sra_staging_upload/sra-bedrock-org/rules/sra-check-iam-users/sra-check-iam-users.zip" while retries < max_retries: try: create_response = self.LAMBDA_CLIENT.create_function( @@ -80,8 +78,7 @@ def create_lambda_function(self, code_s3_bucket, code_s3_key, role_arn, function Runtime=runtime, Handler=handler, Role=role_arn, - # Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, - Code={"ZipFile": open(zip_file_path, "rb").read()}, + Code={"ZipFile": open(code_zip_file, "rb").read()}, Timeout=timeout, MemorySize=memory_size, Tags={"sra-solution": solution_name}, From a9c37fc9787d0ea2c4e68281d4a59cbb2c5b8c68 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 20 Aug 2024 13:14:07 -0600 Subject: [PATCH 023/395] add rough cfn template (not working); tweak lambda module --- .../bedrock_org/lambda/src/sra_lambda.py | 3 +- .../templates/sra-bedrock-org-main.yaml | 165 ++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index d89181e4b..df2172b50 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -83,6 +83,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler MemorySize=memory_size, Tags={"sra-solution": solution_name}, ) + self.LOGGER.info(f"Lambda function created successfully: {create_response}") break except ClientError as error: if error.response["Error"]["Code"] == "ResourceConflictException": @@ -90,7 +91,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler self.LOGGER.info(f"{function_name} function already exists. Updating...") update_response = self.LAMBDA_CLIENT.update_function_code( FunctionName=function_name, - Code={"S3Bucket": code_s3_bucket, "S3Key": code_s3_key}, + ZipFile=open(code_zip_file, "rb").read(), ) self.LOGGER.info(f"Lambda function code updated successfully: {update_response}") break diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml new file mode 100644 index 000000000..4583d787d --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -0,0 +1,165 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: CloudFormation template to create a Lambda function and its execution role + +Parameters: + pSraRepoZipUrl: + Type: String + Default: 'https://github.com/liamschn/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip' + Description: The S3 URL for the SRA solution zip file + + pDryRun: + Type: String + # TODO(liamschn): change the default to 'true' after done testing + Default: 'false' + AllowedValues: + - 'true' + - 'false' + Description: Whether to run in dry run mode or not + + # TODO(liamschn): this may not scale as the max is 4096 bytes; consider multiple parameters such as one for accounts and one for regions (may even need more if we have environments with 1000 accounts); the default below is already 198 bytes + pRuleRegionsAccounts: + Type: CommaDelimitedList + Default: "{'sra-check-iam-users':{'accounts':['863518454635'],'regions':['us-west-2','us-east-1']},'test-rule2':{'accounts':['444444444444','555555555555','666666666666'],'regions':['us-east-1','us-west-2']}}" + Description: List of regions and accounts to include in the SRA solution + + pSRAExecutionRoleName: + Type: String + Default: 'sra-execution' + Description: The name of the IAM role to use for execution of the SRA solution + + pDeployLambdaLogGroup: + Type: String + Default: 'false' + AllowedValues: + - 'true' + - 'false' + Description: true or false; deploy lambda log group + + pLogGroupRetention: + Type: Number + Default: 30 + Description: The number of days to retain logs in the CloudWatch Log Group + + pLambdaLogLevel: + Type: String + Default: 'INFO' + AllowedValues: + - 'DEBUG' + - 'INFO' + - 'WARNING' + - 'ERROR' + - 'CRITICAL' + Description: The logging level for the Lambda function + + pSraSolutionName: + Type: String + Default: 'sra-bedrock-org' + Description: The name of the SRA solution + + pSraSolutionVersion: + Type: String + Default: '1.0.0' + Description: The version of the SRA solution + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: SRA Solution Configuration + Parameters: + - pSraRepoZipUrl + - pDryRun + - pRuleRegionsAccounts + - pSraSolutionName + - pSraSolutionVersion + - Label: + default: IAM Roles + Parameters: + - pSRAExecutionRoleName + - Label: + default: Logging Configuration + Parameters: + - pDeployLambdaLogGroup + - pLogGroupRetention + - pLambdaLogLevel + ParameterLabels: + pSraRepoZipUrl: + default: SRA Repo Zip URL + pDryRun: + default: Dry Run + pRuleRegionsAccounts: + default: Rule Regions and Accounts + pSRAExecutionRoleName: + default: Stack Execution Role Name + pDeployLambdaLogGroup: + default: Deploy Lambda Log Group (true or false) + pLogGroupRetention: + default: Log Group Retention (days) + pLambdaLogLevel: + default: Lambda Log Level + pSraSolutionName: + default: SRA Solution Name + pSraSolutionVersion: + default: SRA Solution Version + +Resources: + + LambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - 'sts:AssumeRole' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + + # TODO(liamschn): refer to the zip file in the staging s3 bucket for the code (which should also include the cfn response module/output needed for the custom resource) + LambdaFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: !Ref pSraSolutionName + Runtime: python3.12 + Role: !GetAtt LambdaExecutionRole.Arn + Handler: index.lambda_handler + Code: + ZipFile: | + import json + + def lambda_handler(event, context): + return { + 'statusCode': 200, + 'body': json.dumps('Hello from Lambda!') + } + + CustomResource: + Type: AWS::CloudFormation::CustomResource + Properties: + ServiceToken: !GetAtt LambdaFunction.Arn + SRA_REPO_ZIP_URL: !Ref pSraRepoZipUrl + DRY_RUN: !Ref pDryRun + RULE_REGIONS_ACCOUNTS: !Join [',', !Ref pRuleRegionsAccounts] + EXECUTION_ROLE_NAME: !Ref pSRAExecutionRoleName + LOG_GROUP_DEPLOY: !Ref pDeployLambdaLogGroup + LOG_GROUP_RETENTION: !Ref pLogGroupRetention + LOG_LEVEL: !Ref pLambdaLogLevel + SOLUTION_NAME: !Ref pSraSolutionName + SOLUTION_VERSION: !Ref pSraSolutionVersion + + LambdaInvokePermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref LambdaFunction + Action: lambda:InvokeFunction + Principal: cloudformation.amazonaws.com + SourceArn: !Sub 'arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stackSet/${AWS::StackName}/*' + +Outputs: + LambdaFunctionArn: + Description: ARN of the Lambda function + Value: !GetAtt LambdaFunction.Arn \ No newline at end of file From 89a3077a601fede8d3b0c0cfb242b78773683839 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 20 Aug 2024 16:06:35 -0600 Subject: [PATCH 024/395] cfn to use staged lambda code --- .../templates/sra-bedrock-org-main.yaml | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 4583d787d..fa987c023 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -51,16 +51,30 @@ Parameters: - 'CRITICAL' Description: The logging level for the Lambda function - pSraSolutionName: + pSRASolutionName: + AllowedValues: ['sra-bedrock-org'] + Description: The SRA solution name. The default value is the folder name of the solution Type: String Default: 'sra-bedrock-org' - Description: The name of the SRA solution pSraSolutionVersion: Type: String Default: '1.0.0' Description: The version of the SRA solution + pSRAStagingS3BucketName: + # AllowedPattern: '^(?=^.{3,63}$)(?!.*[.-]{2})(?!.*[--]{2})(?!^(?:(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(?!$)|$)){4}$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)' + ConstraintDescription: + SRA Staging S3 bucket name can include numbers, lowercase letters, uppercase letters, and hyphens (-). It cannot start or end with a hyphen (-). + Description: + SRA Staging S3 bucket name for the artifacts relevant to solution. (e.g., lambda zips, CloudFormation templates) S3 bucket name can include + numbers, lowercase letters, uppercase letters, and hyphens (-). It cannot start or end with a hyphen (-). + # Type: String + Type: AWS::SSM::Parameter::Value + Default: /sra/staging-s3-bucket-name + + + Metadata: AWS::CloudFormation::Interface: ParameterGroups: @@ -70,8 +84,9 @@ Metadata: - pSraRepoZipUrl - pDryRun - pRuleRegionsAccounts - - pSraSolutionName + - pSRASolutionName - pSraSolutionVersion + - pSRAStagingS3BucketName - Label: default: IAM Roles Parameters: @@ -97,10 +112,12 @@ Metadata: default: Log Group Retention (days) pLambdaLogLevel: default: Lambda Log Level - pSraSolutionName: + pSRASolutionName: default: SRA Solution Name pSraSolutionVersion: default: SRA Solution Version + pSRAStagingS3BucketName: + default: SRA Staging S3 Bucket Name Resources: @@ -123,19 +140,22 @@ Resources: LambdaFunction: Type: AWS::Lambda::Function Properties: - FunctionName: !Ref pSraSolutionName + FunctionName: !Ref pSRASolutionName Runtime: python3.12 Role: !GetAtt LambdaExecutionRole.Arn Handler: index.lambda_handler Code: - ZipFile: | - import json - - def lambda_handler(event, context): - return { - 'statusCode': 200, - 'body': json.dumps('Hello from Lambda!') - } + S3Bucket: !Ref pSRAStagingS3BucketName + S3Key: !Sub ${pSRASolutionName}/lambda_code/${pSRASolutionName}.zip + # Code: + # ZipFile: | + # import json + + # def lambda_handler(event, context): + # return { + # 'statusCode': 200, + # 'body': json.dumps('Hello from Lambda!') + # } CustomResource: Type: AWS::CloudFormation::CustomResource @@ -148,7 +168,7 @@ Resources: LOG_GROUP_DEPLOY: !Ref pDeployLambdaLogGroup LOG_GROUP_RETENTION: !Ref pLogGroupRetention LOG_LEVEL: !Ref pLambdaLogLevel - SOLUTION_NAME: !Ref pSraSolutionName + SOLUTION_NAME: !Ref pSRASolutionName SOLUTION_VERSION: !Ref pSraSolutionVersion LambdaInvokePermission: From d6a402243c8647ff5b1e8740570f224fb520823d Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 20 Aug 2024 16:12:46 -0600 Subject: [PATCH 025/395] cfn function handler update --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index fa987c023..ac6f328cd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -143,7 +143,7 @@ Resources: FunctionName: !Ref pSRASolutionName Runtime: python3.12 Role: !GetAtt LambdaExecutionRole.Arn - Handler: index.lambda_handler + Handler: app.lambda_handler Code: S3Bucket: !Ref pSRAStagingS3BucketName S3Key: !Sub ${pSRASolutionName}/lambda_code/${pSRASolutionName}.zip From 810c9516823f6554e2d66d5389716bc2da83b524 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 21 Aug 2024 15:18:39 -0600 Subject: [PATCH 026/395] working on CFN response elements --- .../genai/bedrock_org/lambda/src/app.py | 18 +++++++++++++++-- .../genai/bedrock_org/lambda/src/sra_iam.py | 2 +- .../templates/sra-bedrock-org-main.yaml | 20 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ecbff8b37..282e40f8c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -45,12 +45,15 @@ ACCOUNT: str = boto3.client("sts").get_caller_identity().get("Account") REGION: str = os.environ.get("AWS_REGION") -CFN_RESOURCE_ID: str = "sra-s3-function" +CFN_RESOURCE_ID: str = "sra-bedrock-org-function" # dry run global variables DRY_RUN: bool = True DRY_RUN_DATA: dict = {} +# other global variables +LIVE_RUN_DATA: dict = {} + # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? ssm_params = sra_ssm_params.sra_ssm_params() @@ -101,6 +104,8 @@ def get_resource_parameters(event): def create_event(event, context): + global DRY_RUN_DATA + global LIVE_RUN_DATA event_info = {"Event": event} LOGGER.info(event_info) @@ -108,8 +113,11 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info("Live run: downloading and staging the config rule code...") repo.download_code_library(repo.REPO_ZIP_URL) + LIVE_RUN_DATA["CodeDownload"] = "Downloaded code library" repo.prepare_config_rules_for_staging(repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) + LIVE_RUN_DATA["CodePrep"] = "Prepared config rule code for staging" s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET, "/") + LIVE_RUN_DATA["CodeStaging"] = "Staged config rule code to staging s3 bucket" else: LOGGER.info(f"DRY_RUN: Downloading code library from {repo.REPO_ZIP_URL}") LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") @@ -122,11 +130,14 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info(f"Creating {SOLUTION_NAME}-configuration SNS topic") topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) + LIVE_RUN_DATA["SNSCreate"] = "Created SNS topic" LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") # TODO(liamschn): search for permissions on lambda before adding the policy lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) + LIVE_RUN_DATA["SNSPermissions"] = "Added lambda permissions for SNS topic" LOGGER.info(f"Subscribing {context.invoked_function_arn} to {topic_arn}") sns.create_sns_subscription(topic_arn, "lambda", context.invoked_function_arn) + LIVE_RUN_DATA["SNSSubscription"] = "Subscribed lambda to SNS topic" else: LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") @@ -154,18 +165,21 @@ def create_event(event, context): # 3a) Deploy IAM execution role for custom config rule lambda for acct in rule_accounts: role_arn = deploy_iam_role(acct, rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "Deployed IAM role for custom config rule lambda" for acct in rule_accounts: for region in rule_regions: # 3b) Deploy lambda for custom config rule lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "Deployed custom config lambda function" # 3c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "Deployed custom config rule" # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: - cfnresponse.send(event, context, cfnresponse.SUCCESS, data, CFN_RESOURCE_ID) + cfnresponse.send(event, context, cfnresponse.SUCCESS, LIVE_RUN_DATA, CFN_RESOURCE_ID) return CFN_RESOURCE_ID diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 775d0a92f..29279955e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -54,7 +54,7 @@ class sra_iam: UNEXPECTED = "Unexpected!" # EMPTY_VALUE = "NONE" BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) - SRA_SOLUTION_NAME = "sra-common-prerequisites" + SRA_SOLUTION_NAME = "sra-common-prerequisites" # todo(liamschn): solution name should be in the main/app module CFN_RESOURCE_ID: str = "sra-iam-function" CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" SRA_EXECUTION_ROLE: str = "sra-execution" # todo(liamschn): parameterize this role name diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index ac6f328cd..de385f9ec 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -73,6 +73,14 @@ Parameters: Type: AWS::SSM::Parameter::Value Default: /sra/staging-s3-bucket-name + pBedrockOrgLambdaRoleName: + AllowedPattern: '^[\w+=,.@-]{1,64}$' + ConstraintDescription: Max 64 alphanumeric characters. Also special characters supported [+, =, ., @, -] + Default: sra-bedrock-org-lambda + Description: Bedrock security control configuration Lambda role name + Type: String + AllowedValues: ['sra-bedrock-org-lambda'] + Metadata: @@ -91,6 +99,7 @@ Metadata: default: IAM Roles Parameters: - pSRAExecutionRoleName + - pBedrockOrgLambdaRoleName - Label: default: Logging Configuration Parameters: @@ -118,12 +127,15 @@ Metadata: default: SRA Solution Version pSRAStagingS3BucketName: default: SRA Staging S3 Bucket Name + pBedrockOrgLambdaRoleName: + default: SRA Bedrock Org lambda role name Resources: LambdaExecutionRole: Type: AWS::IAM::Role Properties: + RoleName: !Ref pBedrockOrgLambdaRoleName AssumeRolePolicyDocument: Version: '2012-10-17' Statement: @@ -133,8 +145,13 @@ Resources: - lambda.amazonaws.com Action: - 'sts:AssumeRole' + Tags: + - Key: sra-solution + Value: !Ref pSRASolutionName ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + # TODO(liamschn): least privilege policies need to be created for this lambda role + - arn:aws:iam::aws:policy/AdministratorAccess # TODO(liamschn): refer to the zip file in the staging s3 bucket for the code (which should also include the cfn response module/output needed for the custom resource) LambdaFunction: @@ -144,6 +161,9 @@ Resources: Runtime: python3.12 Role: !GetAtt LambdaExecutionRole.Arn Handler: app.lambda_handler + Tags: + - Key: sra-solution + Value: !Ref pSRASolutionName Code: S3Bucket: !Ref pSRAStagingS3BucketName S3Key: !Sub ${pSRASolutionName}/lambda_code/${pSRASolutionName}.zip From 768a6205f5b26ac6f6880e96951aade60fbf24de Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 21 Aug 2024 15:39:43 -0600 Subject: [PATCH 027/395] working on CFN response elements_2 --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index de385f9ec..42faa6057 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -178,7 +178,7 @@ Resources: # } CustomResource: - Type: AWS::CloudFormation::CustomResource + Type: Custom::LambdaCustomResource Properties: ServiceToken: !GetAtt LambdaFunction.Arn SRA_REPO_ZIP_URL: !Ref pSraRepoZipUrl From 32654bfbb69ea1756bccfbd1659d72f2b3839692 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 21 Aug 2024 16:26:20 -0600 Subject: [PATCH 028/395] added logging/tracing for debug --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 282e40f8c..b85a192ff 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -179,7 +179,10 @@ def create_event(event, context): # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: + LOGGER.info("Resource type is a custom resource") cfnresponse.send(event, context, cfnresponse.SUCCESS, LIVE_RUN_DATA, CFN_RESOURCE_ID) + else: + LOGGER.info("Resource type is not a custom resource") return CFN_RESOURCE_ID From 93d6d23b7b2a753195112c04f0d3aed3b30260ac Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 21 Aug 2024 16:44:55 -0600 Subject: [PATCH 029/395] update timeout and memory --- .../bedrock_org/templates/sra-bedrock-org-main.yaml | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 42faa6057..549365330 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -167,15 +167,8 @@ Resources: Code: S3Bucket: !Ref pSRAStagingS3BucketName S3Key: !Sub ${pSRASolutionName}/lambda_code/${pSRASolutionName}.zip - # Code: - # ZipFile: | - # import json - - # def lambda_handler(event, context): - # return { - # 'statusCode': 200, - # 'body': json.dumps('Hello from Lambda!') - # } + Timeout: 900 + MemorySize: 512 CustomResource: Type: Custom::LambdaCustomResource From 84d12012d605a8465a31f68631614e337d9dd012 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 22 Aug 2024 15:50:34 -0600 Subject: [PATCH 030/395] update dry_run/live_run; cfn resource names --- .../genai/bedrock_org/lambda/src/app.py | 118 ++++++++++++++++-- .../bedrock_org/lambda/src/requirements.txt | 1 + .../bedrock_org/lambda/src/sra_bedrock.py | 0 .../bedrock_org/lambda/src/sra_lambda.py | 18 +++ .../templates/sra-bedrock-org-main.yaml | 13 +- 5 files changed, 131 insertions(+), 19 deletions(-) delete mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_bedrock.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b85a192ff..1bb8ada2b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -130,18 +130,23 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info(f"Creating {SOLUTION_NAME}-configuration SNS topic") topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) - LIVE_RUN_DATA["SNSCreate"] = "Created SNS topic" + LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-configuration SNS topic" LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") # TODO(liamschn): search for permissions on lambda before adding the policy lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) - LIVE_RUN_DATA["SNSPermissions"] = "Added lambda permissions for SNS topic" + LIVE_RUN_DATA["SNSPermissions"] = "Added lambda sns-invoke permissions for SNS topic" LOGGER.info(f"Subscribing {context.invoked_function_arn} to {topic_arn}") sns.create_sns_subscription(topic_arn, "lambda", context.invoked_function_arn) - LIVE_RUN_DATA["SNSSubscription"] = "Subscribed lambda to SNS topic" + LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" else: LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") + DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-configuration SNS topic" + LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") + DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" + LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {topic_arn}") + DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") @@ -164,30 +169,45 @@ def create_event(event, context): LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") # 3a) Deploy IAM execution role for custom config rule lambda for acct in rule_accounts: - role_arn = deploy_iam_role(acct, rule_name) - LIVE_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "Deployed IAM role for custom config rule lambda" + if DRY_RUN is False: + role_arn = deploy_iam_role(acct, rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "Deployed IAM role for custom config rule lambda" + else: + LOGGER.info(f"DRY_RUN: Deploying IAM role for custom config rule lambda in {acct}") + DRY_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "DRY_RUN: Deploy IAM role for custom config rule lambda" for acct in rule_accounts: for region in rule_regions: # 3b) Deploy lambda for custom config rule - lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "Deployed custom config lambda function" + if DRY_RUN is False: + lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "Deployed custom config lambda function" + else: + LOGGER.info(f"DRY_RUN: Deploying lambda for custom config rule in {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "DRY_RUN: Deploy custom config lambda function" # 3c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) - config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "Deployed custom config rule" + if DRY_RUN is False: + config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "Deployed custom config rule" + else: + LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" # End if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: LOGGER.info("Resource type is a custom resource") - cfnresponse.send(event, context, cfnresponse.SUCCESS, LIVE_RUN_DATA, CFN_RESOURCE_ID) + if DRY_RUN is False: + cfnresponse.send(event, context, cfnresponse.SUCCESS, LIVE_RUN_DATA, CFN_RESOURCE_ID) + else: + cfnresponse.send(event, context, cfnresponse.SUCCESS, DRY_RUN_DATA, CFN_RESOURCE_ID) else: LOGGER.info("Resource type is not a custom resource") return CFN_RESOURCE_ID def update_event(event, context): - # TODO(liamschn): handle CFN update events; maybe unnecessary + # TODO(liamschn): handle CFN update events; use case: change from DRY_RUN = False to DRY_RUN = True LOGGER.info("update event function") # data = sra_s3.s3_resource_check() # TODO(liamschn): update data dictionary @@ -197,9 +217,83 @@ def update_event(event, context): def delete_event(event, context): + global DRY_RUN_DATA + global LIVE_RUN_DATA + LOGGER.info("delete event function") + # 1) Delete SNS topic + topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") + if topic_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {SOLUTION_NAME}-configuration SNS topic") + LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-configuration SNS topic" + sns.delete_sns_topic(topic_search) + else: + LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-configuration SNS topic") + DRY_RUN_DATA["SNSDelete"] = f"Delete {SOLUTION_NAME}-configuration SNS topic" + + # 2) Delete config rules + for rule in repo.CONFIG_RULES[SOLUTION_NAME]: + rule_name = rule.replace("_", "-") + # Get bedrock solution rule accounts and regions + if rule_name in RULE_REGIONS_ACCOUNTS: + if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: + rule_accounts = RULE_REGIONS_ACCOUNTS[rule_name]["accounts"] + else: + rule_accounts = [] + if "regions" in RULE_REGIONS_ACCOUNTS[rule_name]: + rule_regions = RULE_REGIONS_ACCOUNTS[rule_name]["regions"] + else: + rule_regions = [] + else: + LOGGER.info(f"No {rule_name} accounts or regions found in RULE_REGIONS_ACCOUNTS dictionary. Dictionary: {RULE_REGIONS_ACCOUNTS}") + LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") + for acct in rule_accounts: + for region in rule_regions: + # 3) Delete the config rule + config_rule_search = config.find_config_rule(rule_name) + if config_rule_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} config rule for account {acct} in {region}") + config.delete_config_rule(config_rule_search) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" + else: + LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") + else: + LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} custom config rule" + + # 4) Delete lambda for custom config rule + lambda_search = lambdas.find_lambda_function(f"{rule_name}-{acct}-{region}") + if lambda_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") + lambdas.delete_lambda_function(lambda_search) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" + else: + LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} lambda function" + else: + LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") + + # 5) Delete IAM execution role for custom config rule lambda + role_search = iam.check_iam_role_exists(rule_name) + if role_search[0] is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} IAM role for account {acct} in {region}") + iam.delete_role(role_search[1]) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM role" + else: + LOGGER.info(f"DRY_RUN: Deleting {rule_name} IAM role for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} IAM role" + else: + LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") + if RESOURCE_TYPE != "Other": - cfnresponse.send(event, context, cfnresponse.SUCCESS, {"delete_operation": "succeeded deleting"}, CFN_RESOURCE_ID) + if DRY_RUN is False: + cfnresponse.send(event, context, cfnresponse.SUCCESS, LIVE_RUN_DATA, CFN_RESOURCE_ID) + else: + cfnresponse.send(event, context, cfnresponse.SUCCESS, DRY_RUN_DATA, CFN_RESOURCE_ID) def process_sns_records(records: list) -> None: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt index b9435de85..9acb7c1db 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt @@ -1,2 +1,3 @@ #install latest +# TODO(liamschn): not using crhelper crhelper \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_bedrock.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_bedrock.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index df2172b50..754037075 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -170,3 +170,21 @@ def put_permissions_acct(self, function_name, statement_id, principal, action, s except ClientError as e: self.LOGGER.error(e) return None + + def remove_permissions(self, function_name, statement_id): + """Remove Lambda Function Permissions.""" + try: + response = self.LAMBDA_CLIENT.remove_permission(FunctionName=function_name, StatementId=statement_id) + return response + except ClientError as e: + self.LOGGER.error(e) + return None + + def delete_lambda_function(self, function_name): + """Delete Lambda Function.""" + try: + response = self.LAMBDA_CLIENT.delete_function(FunctionName=function_name) + return response + except ClientError as e: + self.LOGGER.error(e) + return None \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 549365330..d7e60c29e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -132,7 +132,7 @@ Metadata: Resources: - LambdaExecutionRole: + rBedrockOrgLambdaRole: Type: AWS::IAM::Role Properties: RoleName: !Ref pBedrockOrgLambdaRoleName @@ -153,13 +153,12 @@ Resources: # TODO(liamschn): least privilege policies need to be created for this lambda role - arn:aws:iam::aws:policy/AdministratorAccess - # TODO(liamschn): refer to the zip file in the staging s3 bucket for the code (which should also include the cfn response module/output needed for the custom resource) - LambdaFunction: + rBedrockOrgLambdaFunction: Type: AWS::Lambda::Function Properties: FunctionName: !Ref pSRASolutionName Runtime: python3.12 - Role: !GetAtt LambdaExecutionRole.Arn + Role: !GetAtt rBedrockOrgLambdaRole.Arn Handler: app.lambda_handler Tags: - Key: sra-solution @@ -170,7 +169,7 @@ Resources: Timeout: 900 MemorySize: 512 - CustomResource: + rBedrockOrgLambdaCustomResource: Type: Custom::LambdaCustomResource Properties: ServiceToken: !GetAtt LambdaFunction.Arn @@ -184,7 +183,7 @@ Resources: SOLUTION_NAME: !Ref pSRASolutionName SOLUTION_VERSION: !Ref pSraSolutionVersion - LambdaInvokePermission: + rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LambdaFunction @@ -195,4 +194,4 @@ Resources: Outputs: LambdaFunctionArn: Description: ARN of the Lambda function - Value: !GetAtt LambdaFunction.Arn \ No newline at end of file + Value: !GetAtt rBedrockOrgLambdaFunction.Arn \ No newline at end of file From 0c6f91fd6583a08082f74740a4715f29263940fa Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 22 Aug 2024 15:52:36 -0600 Subject: [PATCH 031/395] update dry_run/live_run --- .../genai/bedrock_org/lambda/src/sra_config.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 36a42091a..9ecd014ff 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -147,3 +147,16 @@ def create_config_rule(self, rule_name, lambda_arn, max_frequency, owner, descri # Return the response return response + + def delete_config_rule(self, rule_name): + """Delete Config Rule.""" + # Delete the Config Rule + response = self.CONFIG_CLIENT.delete_config_rule( + ConfigRuleName=rule_name + ) + + # Log the response + sra_config.LOGGER.info(f"Delete config rule response: {response}") + + # Return the response + return response \ No newline at end of file From 36d1b5a489238540c17be5e6d4a5dcbb3eaf3ec3 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 22 Aug 2024 18:45:08 -0600 Subject: [PATCH 032/395] working on delete operations; not working --- .../genai/bedrock_org/lambda/src/app.py | 45 ++++++++++++------- .../bedrock_org/lambda/src/sra_config.py | 19 ++++---- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 1bb8ada2b..d091e1f5c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -231,10 +231,10 @@ def delete_event(event, context): else: LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSDelete"] = f"Delete {SOLUTION_NAME}-configuration SNS topic" - + # 2) Delete config rules - for rule in repo.CONFIG_RULES[SOLUTION_NAME]: - rule_name = rule.replace("_", "-") + for rule in RULE_REGIONS_ACCOUNTS: + rule_name: str = rule.replace("_", "-") # Get bedrock solution rule accounts and regions if rule_name in RULE_REGIONS_ACCOUNTS: if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: @@ -252,10 +252,10 @@ def delete_event(event, context): for region in rule_regions: # 3) Delete the config rule config_rule_search = config.find_config_rule(rule_name) - if config_rule_search is not None: + if config_rule_search[0] is True: if DRY_RUN is False: LOGGER.info(f"Deleting {rule_name} config rule for account {acct} in {region}") - config.delete_config_rule(config_rule_search) + config.delete_config_rule(rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") @@ -276,18 +276,31 @@ def delete_event(event, context): else: LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") - # 5) Delete IAM execution role for custom config rule lambda - role_search = iam.check_iam_role_exists(rule_name) - if role_search[0] is True: - if DRY_RUN is False: - LOGGER.info(f"Deleting {rule_name} IAM role for account {acct} in {region}") - iam.delete_role(role_search[1]) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM role" - else: - LOGGER.info(f"DRY_RUN: Deleting {rule_name} IAM role for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} IAM role" + # 5) Delete IAM policy + iam.IAM_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "iam", REGION) + policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" + policy_search = iam.check_iam_policy_exists(policy_arn) + if policy_search[0] is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") + iam.delete_policy(policy_arn) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" else: - LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") + LOGGER.info(f"DRY_RUN: Deleting {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} IAM policy" + + # 6) Delete IAM execution role for custom config rule lambda + role_search = iam.check_iam_role_exists(rule_name) + if role_search[0] is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} IAM role for account {acct} in {region}") + iam.delete_role(rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM role" + else: + LOGGER.info(f"DRY_RUN: Deleting {rule_name} IAM role for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} IAM role" + else: + LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") if RESOURCE_TYPE != "Other": if DRY_RUN is False: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 9ecd014ff..5a5cb780a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -151,12 +151,15 @@ def create_config_rule(self, rule_name, lambda_arn, max_frequency, owner, descri def delete_config_rule(self, rule_name): """Delete Config Rule.""" # Delete the Config Rule - response = self.CONFIG_CLIENT.delete_config_rule( - ConfigRuleName=rule_name - ) - - # Log the response - sra_config.LOGGER.info(f"Delete config rule response: {response}") + try: + self.CONFIG_CLIENT.delete_config_rule( + ConfigRuleName=rule_name + ) - # Return the response - return response \ No newline at end of file + # Log the response + sra_config.LOGGER.info(f"Deleted {rule_name} config rule succeeded.") + except ClientError as e: + if e.response["Error"]["Code"] == "NoSuchConfigRuleException": + self.LOGGER.info(f"No such config rule: {rule_name}") + else: + self.LOGGER.info(f"Unexpected error: {e}") \ No newline at end of file From 84be9bd4b6e6160d4bdd68620fcfb5c13f1fe689 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 23 Aug 2024 10:14:19 -0600 Subject: [PATCH 033/395] more delete operation updates --- .../genai/bedrock_org/lambda/src/app.py | 36 +++++++++++++------ .../genai/bedrock_org/lambda/src/sra_iam.py | 23 ++++++++++-- .../genai/bedrock_org/lambda/src/sra_sns.py | 6 ++-- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index d091e1f5c..77a9d2ecf 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -230,9 +230,11 @@ def delete_event(event, context): sns.delete_sns_topic(topic_search) else: LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-configuration SNS topic") - DRY_RUN_DATA["SNSDelete"] = f"Delete {SOLUTION_NAME}-configuration SNS topic" + DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-configuration SNS topic" # 2) Delete config rules + # TODO(liamschn): deal with invalid rule names + # TODO(liamschn): deal with invalid account IDs for rule in RULE_REGIONS_ACCOUNTS: rule_name: str = rule.replace("_", "-") # Get bedrock solution rule accounts and regions @@ -261,7 +263,7 @@ def delete_event(event, context): LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") else: LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} custom config rule" + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" # 4) Delete lambda for custom config rule lambda_search = lambdas.find_lambda_function(f"{rule_name}-{acct}-{region}") @@ -272,24 +274,38 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} lambda function" + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} lambda function" else: LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") - # 5) Delete IAM policy + # 5) Detach IAM policies + # TODO(liamschn): handle case where policy is not found attached_policies = None iam.IAM_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "iam", REGION) + attached_policies = iam.list_attached_iam_policies(rule_name) + if attached_policies is not None: + if DRY_RUN is False: + for policy in attached_policies: + LOGGER.info(f"Detaching {policy['PolicyName']} IAM policy from account {acct} in {region}") + iam.detach_policy(rule_name, policy["PolicyArn"]) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDetach"] = f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" + else: + LOGGER.info(f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" + + # 6) Delete IAM policy policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" + LOGGER.info(f"Policy ARN: {policy_arn}") policy_search = iam.check_iam_policy_exists(policy_arn) - if policy_search[0] is True: + if policy_search is True: if DRY_RUN is False: LOGGER.info(f"Deleting {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") iam.delete_policy(policy_arn) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" else: - LOGGER.info(f"DRY_RUN: Deleting {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} IAM policy" + LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDelete"] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" - # 6) Delete IAM execution role for custom config rule lambda + # 7) Delete IAM execution role for custom config rule lambda role_search = iam.check_iam_role_exists(rule_name) if role_search[0] is True: if DRY_RUN is False: @@ -297,8 +313,8 @@ def delete_event(event, context): iam.delete_role(rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM role" else: - LOGGER.info(f"DRY_RUN: Deleting {rule_name} IAM role for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Delete {rule_name} IAM role" + LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_RoleDelete"] = f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}" else: LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 29279955e..b4ede81d9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -323,7 +323,7 @@ def attach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadat self.LOGGER.info("Attaching policy to %s.", role_name) return self.IAM_CLIENT.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) - def detach_policy(self, role_name: str, policy_name: str) -> EmptyResponseMetadataTypeDef: + def detach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadataTypeDef: """Detach IAM policy. Args: @@ -335,7 +335,7 @@ def detach_policy(self, role_name: str, policy_name: str) -> EmptyResponseMetada Empty response metadata """ self.LOGGER.info("Detaching policy from %s.", role_name) - return self.IAM_CLIENT.delete_role_policy(RoleName=role_name, PolicyName=policy_name) + return self.IAM_CLIENT.detach_role_policy(RoleName=role_name, PolicyArn=policy_arn) def delete_policy(self, policy_arn: str) -> EmptyResponseMetadataTypeDef: """Delete IAM Policy. @@ -432,3 +432,22 @@ def check_iam_policy_attached(self, role_name, policy_arn): except ClientError as error: self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") raise + + def list_attached_iam_policies(self, role_name): + """ + Lists all IAM policies attached to an IAM role. + + Parameters: + - role_name (str): The name of the IAM role. + + Returns: + list: A list of dictionaries containing information about the attached policies. + """ + try: + response = self.IAM_CLIENT.list_attached_role_policies(RoleName=role_name) + attached_policies = response["AttachedPolicies"] + self.LOGGER.info(f"Attached policies for role '{role_name}': {attached_policies}") + return attached_policies + except ClientError as error: + self.LOGGER.error(f"Error listing attached policies for role '{role_name}': {error}") + raise ValueError(f"Error listing attached policies for role '{role_name}': {error}") from None \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index c81e5f5e0..683856a7b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -54,10 +54,10 @@ def find_sns_topic(self, topic_name: str) -> str: return response["Attributes"]["TopicArn"] except ClientError as e: if e.response["Error"]["Code"] == "NotFoundException": - self.LOGGER.error(f"SNS Topic '{topic_name}' not found exception.") + self.LOGGER.info(f"SNS Topic '{topic_name}' not found exception.") return None elif e.response["Error"]["Code"] == "NotFound": - self.LOGGER.error(f"SNS Topic '{topic_name}' not found.") + self.LOGGER.info(f"SNS Topic '{topic_name}' not found.") return None else: raise ValueError(f"Error finding SNS topic: {e}") from None @@ -95,7 +95,7 @@ def find_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) -> return True except ClientError as e: if e.response["Error"]["Code"] == "NotFoundException": - self.LOGGER.error(f"SNS Subscription for {endpoint} not found on topic {topic_arn}.") + self.LOGGER.info(f"SNS Subscription for {endpoint} not found on topic {topic_arn}.") return False else: raise ValueError(f"Error finding SNS subscription: {e}") from None From c905a3357aabfd300ebdcc577392f31fb4cda6cc Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 23 Aug 2024 11:29:39 -0600 Subject: [PATCH 034/395] update assume role calls --- .../solutions/genai/bedrock_org/lambda/src/app.py | 2 ++ .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 77a9d2ecf..b7d38e598 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -253,6 +253,7 @@ def delete_event(event, context): for acct in rule_accounts: for region in rule_regions: # 3) Delete the config rule + config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) if config_rule_search[0] is True: if DRY_RUN is False: @@ -266,6 +267,7 @@ def delete_event(event, context): DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" # 4) Delete lambda for custom config rule + lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) lambda_search = lambdas.find_lambda_function(f"{rule_name}-{acct}-{region}") if lambda_search is not None: if DRY_RUN is False: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index d7e60c29e..30bc968e1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -172,7 +172,7 @@ Resources: rBedrockOrgLambdaCustomResource: Type: Custom::LambdaCustomResource Properties: - ServiceToken: !GetAtt LambdaFunction.Arn + ServiceToken: !GetAtt rBedrockOrgLambdaFunction.Arn SRA_REPO_ZIP_URL: !Ref pSraRepoZipUrl DRY_RUN: !Ref pDryRun RULE_REGIONS_ACCOUNTS: !Join [',', !Ref pRuleRegionsAccounts] @@ -186,12 +186,12 @@ Resources: rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission Properties: - FunctionName: !Ref LambdaFunction + FunctionName: !Ref rBedrockOrgLambdaFunction Action: lambda:InvokeFunction Principal: cloudformation.amazonaws.com SourceArn: !Sub 'arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stackSet/${AWS::StackName}/*' Outputs: - LambdaFunctionArn: + BedrockOrgLambdaFunctionArn: Description: ARN of the Lambda function Value: !GetAtt rBedrockOrgLambdaFunction.Arn \ No newline at end of file From 79af3419bea0d9f5c37b77dfd7682c7b7e7fecbc Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 23 Aug 2024 11:47:48 -0600 Subject: [PATCH 035/395] reset live/dry run data --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b7d38e598..719878c1c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -106,6 +106,9 @@ def get_resource_parameters(event): def create_event(event, context): global DRY_RUN_DATA global LIVE_RUN_DATA + DRY_RUN_DATA = {} + LIVE_RUN_DATA = {} + event_info = {"Event": event} LOGGER.info(event_info) @@ -219,7 +222,8 @@ def update_event(event, context): def delete_event(event, context): global DRY_RUN_DATA global LIVE_RUN_DATA - + DRY_RUN_DATA = {} + LIVE_RUN_DATA = {} LOGGER.info("delete event function") # 1) Delete SNS topic topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") From 652e8a946b227c58565efc85b2cd58138cec9de0 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 23 Aug 2024 12:57:02 -0600 Subject: [PATCH 036/395] minor update to delete op --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 719878c1c..39afa2099 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -272,7 +272,7 @@ def delete_event(event, context): # 4) Delete lambda for custom config rule lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) - lambda_search = lambdas.find_lambda_function(f"{rule_name}-{acct}-{region}") + lambda_search = lambdas.find_lambda_function(rule_name) if lambda_search is not None: if DRY_RUN is False: LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") From 21c3fd1ef9a308bc61e33c6a2c60dcec7ca674aa Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 23 Aug 2024 13:07:00 -0600 Subject: [PATCH 037/395] minor update to delete op --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 39afa2099..55d358234 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -276,7 +276,7 @@ def delete_event(event, context): if lambda_search is not None: if DRY_RUN is False: LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") - lambdas.delete_lambda_function(lambda_search) + lambdas.delete_lambda_function(rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") From d0c4b5324847fed05a492e90ac6ed8825b1d595b Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 29 Aug 2024 15:18:07 -0600 Subject: [PATCH 038/395] update iam user rule; add s3 model eval rule --- .../sra_bedrock_check_eval_job_bucket/app.py | 95 ++++++++++++ .../sra_bedrock_check_iam_user_access/app.py | 141 ++++++++++++++++++ .../lambda/rules/sra_check_iam_users/app.py | 127 ---------------- .../templates/sra-bedrock-org-main.yaml | 2 +- 4 files changed, 237 insertions(+), 128 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py delete mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/app.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py new file mode 100644 index 000000000..a556d8b6a --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -0,0 +1,95 @@ +import boto3 +from botocore.exceptions import ClientError + +def evaluate_compliance(configuration_item, rule_parameters): + # Get the specified bucket name from rule parameters + specified_bucket = rule_parameters.get('BedrockModelEvalJobBucketName') + if not specified_bucket: + return 'NON_COMPLIANT', 'BedrockModelEvalJobBucketName parameter is not specified' + + # Extract the bucket name from the configuration item + evaluated_bucket = configuration_item['resourceName'] + + # Check if the evaluated bucket matches the specified Bedrock model evaluation job bucket + if evaluated_bucket != specified_bucket: + return 'NOT_APPLICABLE', f'This bucket is not the specified Bedrock model evaluation job bucket ({specified_bucket})' + + # Initialize S3 client + s3 = boto3.client('s3') + + # Set default values for configurable parameters + check_retention = rule_parameters.get('CheckRetention', 'true').lower() == 'true' + check_encryption = rule_parameters.get('CheckEncryption', 'true').lower() == 'true' + check_logging = rule_parameters.get('CheckLogging', 'true').lower() == 'true' + check_object_locking = rule_parameters.get('CheckObjectLocking', 'true').lower() == 'true' + check_versioning = rule_parameters.get('CheckVersioning', 'true').lower() == 'true' + + try: + # Check retention policy + if check_retention: + try: + retention = s3.get_bucket_lifecycle_configuration(Bucket=evaluated_bucket) + if not any(rule.get('Expiration') for rule in retention['Rules']): + return 'NON_COMPLIANT', 'Bucket does not have a retention policy' + except ClientError as e: + if e.response['Error']['Code'] == 'NoSuchLifecycleConfiguration': + return 'NON_COMPLIANT', 'Bucket does not have a retention policy' + raise + + # Check KMS CMK encryption + if check_encryption: + encryption = s3.get_bucket_encryption(Bucket=evaluated_bucket) + if 'ServerSideEncryptionConfiguration' not in encryption: + return 'NON_COMPLIANT', 'Bucket is not encrypted with KMS CMK' + sse_config = encryption['ServerSideEncryptionConfiguration']['Rules'][0]['ApplyServerSideEncryptionByDefault'] + if sse_config['SSEAlgorithm'] != 'aws:kms': + return 'NON_COMPLIANT', 'Bucket is not encrypted with KMS CMK' + + # Check server access logging + if check_logging: + logging = s3.get_bucket_logging(Bucket=evaluated_bucket) + if 'LoggingEnabled' not in logging: + return 'NON_COMPLIANT', 'Server access logging is not enabled' + + # Check object locking + if check_object_locking: + object_locking = s3.get_object_lock_configuration(Bucket=evaluated_bucket) + if 'ObjectLockConfiguration' not in object_locking: + return 'NON_COMPLIANT', 'Object locking is not enabled' + + # Check versioning + if check_versioning: + versioning = s3.get_bucket_versioning(Bucket=evaluated_bucket) + if 'Status' not in versioning or versioning['Status'] != 'Enabled': + return 'NON_COMPLIANT', 'Versioning is not enabled' + + return 'COMPLIANT', 'Bucket meets all security requirements' + + except ClientError as e: + return 'NON_COMPLIANT', f'Error evaluating bucket: {str(e)}' + +def lambda_handler(event, context): + invoking_event = event['invokingEvent'] + rule_parameters = event['ruleParameters'] + configuration_item = invoking_event['configurationItem'] + + compliance_type, annotation = evaluate_compliance(configuration_item, rule_parameters) + + config = boto3.client('config') + config.put_evaluations( + Evaluations=[ + { + 'ComplianceResourceType': configuration_item['resourceType'], + 'ComplianceResourceId': configuration_item['resourceId'], + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] + }, + ], + ResultToken=event['resultToken'] + ) + + return { + 'compliance_type': compliance_type, + 'annotation': annotation + } \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py new file mode 100644 index 000000000..85f04afd3 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -0,0 +1,141 @@ +import botocore +import boto3 +import json +import datetime +import logging +import os # maybe not needed for logging + +# Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). +ASSUME_ROLE_MODE = False +DEFAULT_RESOURCE_TYPE = "AWS::::Account" + +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") + +# Define the AWS Config rule parameters +RULE_NAME = "sra-bedrock-check-iam-user-access" +SERVICE_NAME = "bedrock.amazonaws.com" + +# Create a session and IAM client +session = boto3.Session() +iam_client = session.client("iam") + + +def evaluate_compliance(event, context): + """ + Evaluates compliance for the given AWS Config event. + """ + LOGGER.info(f"eval compliance event: {event}") + # Fetch IAM users + iam_users = iam_client.list_users()["Users"] + + # Iterate over each IAM user + non_compliant_users = [] + for user in iam_users: + user_name = user["UserName"] + LOGGER.info(f"user: {user_name}") + user_policies = iam_client.list_user_policies(UserName=user_name)["PolicyNames"] + user_groups = iam_client.list_groups_for_user(UserName=user_name)["Groups"] + managed_policies = iam_client.list_attached_user_policies(UserName=user_name)["AttachedPolicies"] + + # Check if the user has access to the Bedrock service + has_access = False + for policy in user_policies: + LOGGER.info(f"policy: {policy}") + policy_document = iam_client.get_user_policy(UserName=user_name, PolicyName=policy)["PolicyDocument"] + if check_policy_document(policy_document): + LOGGER.info("User policy has access") + has_access = True + break + + for group in user_groups: + group_policies = iam_client.list_group_policies(GroupName=group["GroupName"])["PolicyNames"] + for policy in group_policies: + policy_document = iam_client.get_group_policy(GroupName=group["GroupName"], PolicyName=policy)["PolicyDocument"] + if check_policy_document(policy_document): + LOGGER.info("Group policy has access") + has_access = True + break + + for managed_policy in managed_policies: + LOGGER.info(f"managed policy: {managed_policy}") + managed_policy_version = iam_client.get_policy(PolicyArn=managed_policy["PolicyArn"])["Policy"]["DefaultVersionId"] + managed_policy_document = iam_client.get_policy_version(PolicyArn=managed_policy["PolicyArn"], VersionId=managed_policy_version)["PolicyVersion"]["Document"] + if check_policy_document(managed_policy_document): + LOGGER.info("Managed policy has access") + has_access = True + break + + if has_access: + non_compliant_users.append(user_name) + + # Prepare the evaluation result + if non_compliant_users: + compliance_type = "NON_COMPLIANT" + annotation = "The following IAM users have access to the Amazon Bedrock service: " + ", ".join(non_compliant_users) + else: + compliance_type = "COMPLIANT" + annotation = "No IAM users have access to the Amazon Bedrock service." + + LOGGER.info(f"account id: {event['awsAccountId']}") + evaluation_result = { + "ComplianceType": compliance_type, + "Annotation": annotation, + "EvaluationResultIdentifier": {"EvaluationResultQualifier": {"ResourceId": event["awsAccountId"]}}, + } + + return evaluation_result + + +def check_policy_document(policy_document): + """ + Checks if the given policy document allows access to the Bedrock service. + """ + statements = policy_document["Statement"] + for statement in statements: + LOGGER.info(f"policy statement: {statement}") + if statement["Effect"] == "Allow": + resources = statement.get("Resource", []) + LOGGER.info(f"resources: {resources}") + # if "*" in resources or SERVICE_NAME in resources: + # return True + actions = statement.get("Action", []) + LOGGER.info(f"actions: {actions}") + if any(action.startswith("bedrock:") for action in actions): + return True + + return False + + +def lambda_handler(event, context): + """ + AWS Lambda function entry point. + """ + LOGGER.info(f"Event: {event}") + # Parse the event + invoking_event = json.loads(event["invokingEvent"]) + result_token = event.get("resultToken") + + # Evaluate compliance + evaluation_result = evaluate_compliance(invoking_event, context) + + # Send the evaluation result to AWS Config + config_client = boto3.client("config") + config_client.put_evaluations( + Evaluations=[ + { + "ComplianceResourceType": DEFAULT_RESOURCE_TYPE, + "ComplianceResourceId": invoking_event["awsAccountId"], + "ComplianceType": evaluation_result["ComplianceType"], + "Annotation": evaluation_result["Annotation"], + "OrderingTimestamp": invoking_event["notificationCreationTime"], + } + ], + ResultToken=result_token, + ) + + # Return the evaluation result + return evaluation_result diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/app.py deleted file mode 100644 index b5e77a269..000000000 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_check_iam_users/app.py +++ /dev/null @@ -1,127 +0,0 @@ -import botocore -import boto3 -import json -import datetime -import logging -import os # maybe not needed for logging - -# Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). -ASSUME_ROLE_MODE = False -DEFAULT_RESOURCE_TYPE = "AWS::::Account" - -# Setup Default Logger -LOGGER = logging.getLogger(__name__) -log_level = os.environ.get("LOG_LEVEL", logging.INFO) -LOGGER.setLevel(log_level) -LOGGER.info(f"boto3 version: {boto3.__version__}") - - -# This gets the client after assuming the Config service role -# either in the same AWS account or cross-account. -def get_client(service, event): - """Return the service boto client. It should be used instead of directly calling the client. - Keyword arguments: - service -- the service name used for calling the boto.client() - event -- the event variable given in the lambda handler - """ - if not ASSUME_ROLE_MODE: - return boto3.client(service) - credentials = get_assume_role_credentials(event["executionRoleArn"]) - return boto3.client( - service, - aws_access_key_id=credentials["AccessKeyId"], - aws_secret_access_key=credentials["SecretAccessKey"], - aws_session_token=credentials["SessionToken"], - ) - - -def get_assume_role_credentials(role_arn): - sts_client = boto3.client("sts") - try: - assume_role_response = sts_client.assume_role(RoleArn=role_arn, RoleSessionName="configLambdaExecution") - return assume_role_response["Credentials"] - except botocore.exceptions.ClientError as ex: - # Scrub error message for any internal account info leaks - if "AccessDenied" in ex.response["Error"]["Code"]: - ex.response["Error"]["Message"] = "AWS Config does not have permission to assume the IAM role." - else: - ex.response["Error"]["Message"] = "InternalError" - ex.response["Error"]["Code"] = "InternalError" - raise ex - - -# Check whether the message is a ScheduledNotification or not. -def is_scheduled_notification(message_type): - return message_type == "ScheduledNotification" - - -def count_resource_types(applicable_resource_type, next_token, count): - resource_identifier = AWS_CONFIG_CLIENT.list_discovered_resources(resourceType=applicable_resource_type, nextToken=next_token) - updated = count + len(resource_identifier["resourceIdentifiers"]) - return updated - - -# Evaluates the configuration items in the snapshot and returns the compliance value to the handler. -def evaluate_compliance(max_count, actual_count): - return "NON_COMPLIANT" if int(actual_count) > int(max_count) else "COMPLIANT" - - -def evaluate_parameters(rule_parameters): - if "applicableResourceType" not in rule_parameters: - raise ValueError('The parameter with "applicableResourceType" as key must be defined.') - if not rule_parameters["applicableResourceType"]: - raise ValueError('The parameter "applicableResourceType" must have a defined value.') - return rule_parameters - - -# This generate an evaluation for config -def build_evaluation(resource_id, compliance_type, event, resource_type=DEFAULT_RESOURCE_TYPE, annotation=None): - """Form an evaluation as a dictionary. Usually suited to report on scheduled rules. - Keyword arguments: - resource_id -- the unique id of the resource to report - compliance_type -- either COMPLIANT, NON_COMPLIANT or NOT_APPLICABLE - event -- the event variable given in the lambda handler - resource_type -- the CloudFormation resource type (or AWS::::Account) to report on the rule (default DEFAULT_RESOURCE_TYPE) - annotation -- an annotation to be added to the evaluation (default None) - """ - eval_cc = {} - if annotation: - eval_cc["Annotation"] = annotation - eval_cc["ComplianceResourceType"] = resource_type - eval_cc["ComplianceResourceId"] = resource_id - eval_cc["ComplianceType"] = compliance_type - eval_cc["OrderingTimestamp"] = str(json.loads(event["invokingEvent"])["notificationCreationTime"]) - return eval_cc - - -def lambda_handler(event, context): - LOGGER.info(event) - global AWS_CONFIG_CLIENT - - evaluations = [] - rule_parameters = {} - resource_count = 0 - max_count = 0 - - invoking_event = json.loads(event["invokingEvent"]) - if "ruleParameters" in event: - rule_parameters = json.loads(event["ruleParameters"]) - - valid_rule_parameters = evaluate_parameters(rule_parameters) - - compliance_value = "NOT_APPLICABLE" - - AWS_CONFIG_CLIENT = get_client("config", event) - if is_scheduled_notification(invoking_event["messageType"]): - result_resource_count = count_resource_types(valid_rule_parameters["applicableResourceType"], "", resource_count) - - if valid_rule_parameters.get("maxCount"): - max_count = valid_rule_parameters["maxCount"] - LOGGER.info(f"maxCount set to: {max_count} from rule parameter") - else: - LOGGER.info(f"maxCount set to: {max_count} as default") - - LOGGER.info(f"result resource count: {result_resource_count}") - compliance_value = evaluate_compliance(max_count, result_resource_count) - evaluations.append(build_evaluation(event["accountId"], compliance_value, event, resource_type=DEFAULT_RESOURCE_TYPE)) - response = AWS_CONFIG_CLIENT.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 30bc968e1..4fa0f0ca7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -19,7 +19,7 @@ Parameters: # TODO(liamschn): this may not scale as the max is 4096 bytes; consider multiple parameters such as one for accounts and one for regions (may even need more if we have environments with 1000 accounts); the default below is already 198 bytes pRuleRegionsAccounts: Type: CommaDelimitedList - Default: "{'sra-check-iam-users':{'accounts':['863518454635'],'regions':['us-west-2','us-east-1']},'test-rule2':{'accounts':['444444444444','555555555555','666666666666'],'regions':['us-east-1','us-west-2']}}" + Default: "{'sra-bedrock-check-iam-user-access':{'accounts':['863518454635'],'regions':['us-west-2','us-east-1']},'sra-bedrock-check-eval-job-bucket':{'accounts':['863518454635'],'regions':['us-east-1','us-west-2']}}" Description: List of regions and accounts to include in the SRA solution pSRAExecutionRoleName: From f9d7cb5e3890d7da730d065b4c481ac7d14ce2c5 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 29 Aug 2024 15:55:13 -0600 Subject: [PATCH 039/395] fix rule dir exists bug --- .../solutions/genai/bedrock_org/lambda/src/sra_repo.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 3d1f5574d..5d7ad0b44 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -161,8 +161,10 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f self.CONFIG_RULES[solution_name].append(config_rule) else: self.CONFIG_RULES[solution_name] = [config_rule] - os.mkdir(staging_temp_folder + upload_folder_name) - os.mkdir(staging_temp_folder + upload_folder_name + "/rules") + if not os.path.exists(staging_temp_folder + upload_folder_name): + os.mkdir(staging_temp_folder + upload_folder_name) + if not os.path.exists(staging_temp_folder + upload_folder_name + "/rules"): + os.mkdir(staging_temp_folder + upload_folder_name + "/rules") config_rule_staging_folder_path = ( staging_temp_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name ) From 2c6837a7c125d17da4f12cad6b00d53bff3b1749 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 29 Aug 2024 16:06:27 -0600 Subject: [PATCH 040/395] fix rule dir exists bug --- .../genai/bedrock_org/lambda/src/sra_repo.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 5d7ad0b44..3ba030cdd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -168,13 +168,20 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f config_rule_staging_folder_path = ( staging_temp_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name ) - os.mkdir(config_rule_staging_folder_path) - os.mkdir(staging_upload_folder + upload_folder_name) - os.mkdir(staging_upload_folder + upload_folder_name + "/rules") + if not os.path.exists(config_rule_staging_folder_path): + self.LOGGER.info(f"Creating {config_rule_staging_folder_path} folder") + os.mkdir(config_rule_staging_folder_path) + if not os.path.exists(staging_upload_folder + upload_folder_name): + self.LOGGER.info(f"Creating {staging_upload_folder + upload_folder_name} folder") + os.mkdir(staging_upload_folder + upload_folder_name) + if not os.path.exists(staging_upload_folder + upload_folder_name + "/rules"): + self.LOGGER.info(f"Creating {staging_upload_folder + upload_folder_name + '/rules'} folder") config_rule_upload_folder_path = ( staging_upload_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name ) - os.mkdir(config_rule_upload_folder_path) + if not os.path.exists(config_rule_upload_folder_path): + self.LOGGER.info(f"Creating {config_rule_upload_folder_path} folder") + os.mkdir(config_rule_upload_folder_path) self.LOGGER.info(f"DEBUG: config_rule_staging_folder_path: {config_rule_staging_folder_path}") self.LOGGER.info(f"DEBUG: config_rule_upload_folder_path: {config_rule_upload_folder_path}") # lambda code From 3dfc014896257c3f74348ea98992754459885f21 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 29 Aug 2024 17:57:59 -0600 Subject: [PATCH 041/395] updating resource id --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 55d358234..293b207c6 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -515,7 +515,7 @@ def lambda_handler(event, context): LOGGER.exception("Unexpected!") reason = f"See the details in CloudWatch Log Stream: '{context.log_group_name}'" if RESOURCE_TYPE != "Other": - cfnresponse.send(event, context, cfnresponse.FAILED, {}, "sra-s3-lambda", reason=reason) + cfnresponse.send(event, context, cfnresponse.FAILED, {}, CFN_RESOURCE_ID, reason=reason) LAMBDA_FINISH = dynamodb.get_date_time() return { "statusCode": 500, From b84a83294a091cf4b17b7b00203a809b3a5e0632 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 29 Aug 2024 18:14:49 -0600 Subject: [PATCH 042/395] fix rule dir exists bug --- .../solutions/genai/bedrock_org/lambda/src/sra_iam.py | 3 +++ .../solutions/genai/bedrock_org/lambda/src/sra_repo.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index b4ede81d9..75afcadbf 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -449,5 +449,8 @@ def list_attached_iam_policies(self, role_name): self.LOGGER.info(f"Attached policies for role '{role_name}': {attached_policies}") return attached_policies except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + self.LOGGER.info(f"The role '{role_name}' does not exist.") + return self.LOGGER.error(f"Error listing attached policies for role '{role_name}': {error}") raise ValueError(f"Error listing attached policies for role '{role_name}': {error}") from None \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 3ba030cdd..7760b360e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -166,7 +166,7 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f if not os.path.exists(staging_temp_folder + upload_folder_name + "/rules"): os.mkdir(staging_temp_folder + upload_folder_name + "/rules") config_rule_staging_folder_path = ( - staging_temp_folder + upload_folder_name + "/rules/" + config_rule_upload_folder_name + staging_temp_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name ) if not os.path.exists(config_rule_staging_folder_path): self.LOGGER.info(f"Creating {config_rule_staging_folder_path} folder") @@ -176,6 +176,7 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f os.mkdir(staging_upload_folder + upload_folder_name) if not os.path.exists(staging_upload_folder + upload_folder_name + "/rules"): self.LOGGER.info(f"Creating {staging_upload_folder + upload_folder_name + '/rules'} folder") + os.mkdir(staging_upload_folder + upload_folder_name + "/rules") config_rule_upload_folder_path = ( staging_upload_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name ) From ba4a38c463b0b00ce877f1946508a7c943dde9d0 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 30 Aug 2024 12:38:48 -0600 Subject: [PATCH 043/395] updated policy for lambda --- .../solutions/genai/bedrock_org/lambda/src/app.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 293b207c6..da5ecfa44 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -380,7 +380,7 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: "Statement" ][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") - + # TODO(liamschn): change the rule execution role to be specific permissions needed (i.e. read access to IAM, or S3) policy_arn = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}-lamdba-basic-execution" iam_policy_search = iam.check_iam_policy_exists(policy_arn) if iam_policy_search is False: @@ -400,13 +400,22 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"DRY_RUN: attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role in {account_id}...") - policy_attach_search1 = iam.check_iam_policy_attached(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") - if policy_attach_search1 is False: + policy_attach_search2 = iam.check_iam_policy_attached(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") + if policy_attach_search2 is False: if DRY_RUN is False: LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role in {account_id}...") iam.attach_policy(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") else: LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role in {account_id}...") + + policy_attach_search3 = iam.check_iam_policy_attached(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole") + if policy_attach_search3 is False: + if DRY_RUN is False: + LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role in {account_id}...") + iam.attach_policy(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole") + else: + LOGGER.info(f"DRY_RUN: Attaching AWSLambdaBasicExecutionRole policy to {rule_name} IAM role in {account_id}...") + return role_arn From 376902700d1526c651c88417ec5a350e277a9c60 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 30 Aug 2024 14:35:31 -0600 Subject: [PATCH 044/395] adding iam policies --- .../genai/bedrock_org/lambda/src/app.py | 40 +++++++++++++++++++ .../genai/bedrock_org/lambda/src/sra_iam.py | 4 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index da5ecfa44..809e6e707 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -53,6 +53,26 @@ # other global variables LIVE_RUN_DATA: dict = {} +IAM_POLICY_DOCUMENTS: dict = { + "sra-bedrock-check-iam-user-access": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadIAM", "Effect": "Allow", + "Action": ["iam:Get*", "iam:List*"], "Resource": "*", + }, + ], + }, + "sra-bedrock-check-eval-job-bucket": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadS3", "Effect": "Allow", + "Action": ["s3:List*","s3:Describe*"], "Resource": "*", + }, + ], + }, +} # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? @@ -392,6 +412,18 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") + policy_arn2 = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}" + iam_policy_search2 = iam.check_iam_policy_exists(policy_arn2) + if iam_policy_search2 is False: + if DRY_RUN is False: + LOGGER.info(f"Creating {rule_name} IAM policy in {account_id}...") + iam.create_policy(f"{rule_name}", IAM_POLICY_DOCUMENTS[rule_name], SOLUTION_NAME) + else: + LOGGER.info(f"DRY _RUN: Creating {rule_name} IAM policy in {account_id}...") + else: + LOGGER.info(f"{rule_name} IAM policy already exists") + + policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) if policy_attach_search1 is False: if DRY_RUN is False: @@ -416,6 +448,14 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"DRY_RUN: Attaching AWSLambdaBasicExecutionRole policy to {rule_name} IAM role in {account_id}...") + policy_attach_search4 = iam.check_iam_policy_attached(rule_name, policy_arn2) + if policy_attach_search4 is False: + if DRY_RUN is False: + LOGGER.info(f"Attaching {rule_name} to {rule_name} IAM role in {account_id}...") + iam.attach_policy(rule_name, policy_arn2) + else: + LOGGER.info(f"DRY_RUN: attaching {rule_name} to {rule_name} IAM role in {account_id}...") + return role_arn diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 75afcadbf..1dbb07fcc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -96,9 +96,9 @@ class sra_iam: "sra-lambda-basic-execution": { "Version": "2012-10-17", "Statement": [ - {"Effect": "Allow", "Action": "logs:CreateLogGroup", "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:*"}, + {"Sid":"CreateLogGroup", "Effect": "Allow", "Action": "logs:CreateLogGroup", "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:*"}, { - "Effect": "Allow", + "Sid":"CreateStreamPutEvents", "Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:log-group:/aws/lambda/CONFIG_RULE_NAME:*", }, From 2f1b747840623d94e6c1b12f11c08ab4a983f688 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 30 Aug 2024 18:30:55 -0600 Subject: [PATCH 045/395] updating s3 eval rule --- .../sra_bedrock_check_eval_job_bucket/app.py | 179 ++++++++++-------- .../genai/bedrock_org/lambda/src/app.py | 8 +- 2 files changed, 108 insertions(+), 79 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index a556d8b6a..86bf68a0f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -1,95 +1,122 @@ import boto3 +import json from botocore.exceptions import ClientError +from datetime import datetime +import logging +import os # maybe not needed for logging +import ast -def evaluate_compliance(configuration_item, rule_parameters): - # Get the specified bucket name from rule parameters - specified_bucket = rule_parameters.get('BedrockModelEvalJobBucketName') - if not specified_bucket: - return 'NON_COMPLIANT', 'BedrockModelEvalJobBucketName parameter is not specified' +# Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). +ASSUME_ROLE_MODE = False +DEFAULT_RESOURCE_TYPE = "AWS::S3::Bucket" - # Extract the bucket name from the configuration item - evaluated_bucket = configuration_item['resourceName'] +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") - # Check if the evaluated bucket matches the specified Bedrock model evaluation job bucket - if evaluated_bucket != specified_bucket: - return 'NOT_APPLICABLE', f'This bucket is not the specified Bedrock model evaluation job bucket ({specified_bucket})' +# Define the AWS Config rule parameters +RULE_NAME = "sra-bedrock-check-eval-job-bucket" +SERVICE_NAME = "bedrock.amazonaws.com" - # Initialize S3 client - s3 = boto3.client('s3') - # Set default values for configurable parameters - check_retention = rule_parameters.get('CheckRetention', 'true').lower() == 'true' - check_encryption = rule_parameters.get('CheckEncryption', 'true').lower() == 'true' - check_logging = rule_parameters.get('CheckLogging', 'true').lower() == 'true' - check_object_locking = rule_parameters.get('CheckObjectLocking', 'true').lower() == 'true' - check_versioning = rule_parameters.get('CheckVersioning', 'true').lower() == 'true' +def evaluate_compliance(event, context): + LOGGER.info(f"Evaluate Compliance Event: {event}") + # Initialize AWS clients + s3 = boto3.client('s3') + config = boto3.client('config') + # Get rule parameters + params = ast.literal_eval(event['ruleParameters']) + LOGGER.info(f"Parameters: {params}") + bucket_name = params.get('BucketName', '') + check_retention = params.get('CheckRetention', 'true').lower() != 'false' + check_encryption = params.get('CheckEncryption', 'true').lower() != 'false' + check_logging = params.get('CheckLogging', 'true').lower() != 'false' + check_object_locking = params.get('CheckObjectLocking', 'true').lower() != 'false' + check_versioning = params.get('CheckVersioning', 'true').lower() != 'false' + + # Check if the bucket exists try: - # Check retention policy - if check_retention: - try: - retention = s3.get_bucket_lifecycle_configuration(Bucket=evaluated_bucket) - if not any(rule.get('Expiration') for rule in retention['Rules']): - return 'NON_COMPLIANT', 'Bucket does not have a retention policy' - except ClientError as e: - if e.response['Error']['Code'] == 'NoSuchLifecycleConfiguration': - return 'NON_COMPLIANT', 'Bucket does not have a retention policy' - raise - - # Check KMS CMK encryption - if check_encryption: - encryption = s3.get_bucket_encryption(Bucket=evaluated_bucket) + s3.head_bucket(Bucket=bucket_name) + except ClientError as e: + return build_evaluation('NOT_APPLICABLE', f"Bucket {bucket_name} does not exist or is not accessible") + + compliance_type = 'COMPLIANT' + annotation = [] + + # Check retention + if check_retention: + try: + retention = s3.get_bucket_lifecycle_configuration(Bucket=bucket_name) + if not any(rule.get('Expiration') for rule in retention.get('Rules', [])): + compliance_type = 'NON_COMPLIANT' + annotation.append("Retention policy not set") + except ClientError: + compliance_type = 'NON_COMPLIANT' + annotation.append("Retention policy not set") + + # Check encryption + if check_encryption: + try: + encryption = s3.get_bucket_encryption(Bucket=bucket_name) if 'ServerSideEncryptionConfiguration' not in encryption: - return 'NON_COMPLIANT', 'Bucket is not encrypted with KMS CMK' - sse_config = encryption['ServerSideEncryptionConfiguration']['Rules'][0]['ApplyServerSideEncryptionByDefault'] - if sse_config['SSEAlgorithm'] != 'aws:kms': - return 'NON_COMPLIANT', 'Bucket is not encrypted with KMS CMK' - - # Check server access logging - if check_logging: - logging = s3.get_bucket_logging(Bucket=evaluated_bucket) - if 'LoggingEnabled' not in logging: - return 'NON_COMPLIANT', 'Server access logging is not enabled' - - # Check object locking - if check_object_locking: - object_locking = s3.get_object_lock_configuration(Bucket=evaluated_bucket) + compliance_type = 'NON_COMPLIANT' + annotation.append("KMS CMK encryption not enabled") + except ClientError: + compliance_type = 'NON_COMPLIANT' + annotation.append("KMS CMK encryption not enabled") + + # Check logging + if check_logging: + logging = s3.get_bucket_logging(Bucket=bucket_name) + if 'LoggingEnabled' not in logging: + compliance_type = 'NON_COMPLIANT' + annotation.append("Server access logging not enabled") + + # Check object locking + if check_object_locking: + try: + object_locking = s3.get_object_lock_configuration(Bucket=bucket_name) if 'ObjectLockConfiguration' not in object_locking: - return 'NON_COMPLIANT', 'Object locking is not enabled' - - # Check versioning - if check_versioning: - versioning = s3.get_bucket_versioning(Bucket=evaluated_bucket) - if 'Status' not in versioning or versioning['Status'] != 'Enabled': - return 'NON_COMPLIANT', 'Versioning is not enabled' - - return 'COMPLIANT', 'Bucket meets all security requirements' - - except ClientError as e: - return 'NON_COMPLIANT', f'Error evaluating bucket: {str(e)}' + compliance_type = 'NON_COMPLIANT' + annotation.append("Object locking not enabled") + except ClientError: + compliance_type = 'NON_COMPLIANT' + annotation.append("Object locking not enabled") + + # Check versioning + if check_versioning: + versioning = s3.get_bucket_versioning(Bucket=bucket_name) + if versioning.get('Status') != 'Enabled': + compliance_type = 'NON_COMPLIANT' + annotation.append("Versioning not enabled") + + annotation_str = '; '.join(annotation) if annotation else "All checked features are compliant" + return build_evaluation(compliance_type, annotation_str) + +def build_evaluation(compliance_type, annotation): + LOGGER.info(f"Build Evaluation Compliance Type: {compliance_type} Annotation: {annotation}") + return { + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': datetime.now().isoformat() + } def lambda_handler(event, context): - invoking_event = event['invokingEvent'] - rule_parameters = event['ruleParameters'] - configuration_item = invoking_event['configurationItem'] - - compliance_type, annotation = evaluate_compliance(configuration_item, rule_parameters) - + LOGGER.info(f"Lambda Handler Event: {event}") + evaluation = evaluate_compliance(event, context) config = boto3.client('config') config.put_evaluations( Evaluations=[ { - 'ComplianceResourceType': configuration_item['resourceType'], - 'ComplianceResourceId': configuration_item['resourceId'], - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] - }, + 'ComplianceResourceType': 'AWS::S3::Bucket', + 'ComplianceResourceId': event['ruleParameters']['BucketName'], + 'ComplianceType': evaluation['ComplianceType'], + 'Annotation': evaluation['Annotation'], + 'OrderingTimestamp': evaluation['OrderingTimestamp'] + } ], ResultToken=event['resultToken'] - ) - - return { - 'compliance_type': compliance_type, - 'annotation': annotation - } \ No newline at end of file + ) \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 809e6e707..fd6df9f02 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -68,7 +68,9 @@ "Statement": [ { "Sid": "AllowReadS3", "Effect": "Allow", - "Action": ["s3:List*","s3:Describe*"], "Resource": "*", + "Action": ["s3:GetBucketLifecycleConfiguration", "s3:GetBucketEncryption", + "s3:GetBucketLogging", "s3:GetObjectLockConfiguration", + "s3:GetBucketVersioning", "s3:HeadBucket"], "Resource": "arn:aws:s3:::*", }, ], }, @@ -511,14 +513,14 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: lambdas.put_permissions_acct(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", account_id) LOGGER.info(f"Creating {rule_name} config rule in {account_id} in {region}...") # TODO(liamschn): Determine if we need to add a description for the config rules - # TODO(liamschn): Determine what we will do for input parameters variable in the config rule create function + # TODO(liamschn): Determine what we will do for input parameters variable in the config rule create function;need an s3 bucket currently config.create_config_rule( rule_name, lambda_arn, "One_Hour", "CUSTOM_LAMBDA", rule_name, - {"applicableResourceType": "AWS::IAM::User", "maxCount": "0"}, + {"applicableResourceType": "AWS::IAM::User", "maxCount": "0", "BucketName": "test-mod-eval-bucket"}, "DETECTIVE", SOLUTION_NAME, ) From 53850a5fc6fd7bcb683cc4cc8f884c09f387984f Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 30 Aug 2024 21:49:37 -0600 Subject: [PATCH 046/395] updating bucket eval perms/code --- .../sra_bedrock_check_eval_job_bucket/app.py | 20 +++++-- .../genai/bedrock_org/lambda/src/app.py | 57 ++++++++++++------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index 86bf68a0f..40be3167d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -38,9 +38,10 @@ def evaluate_compliance(event, context): check_versioning = params.get('CheckVersioning', 'true').lower() != 'false' # Check if the bucket exists - try: - s3.head_bucket(Bucket=bucket_name) - except ClientError as e: + # try: + # s3.head_bucket(Bucket=bucket_name) + # except ClientError as e: + if not check_bucket_exists(bucket_name): return build_evaluation('NOT_APPLICABLE', f"Bucket {bucket_name} does not exist or is not accessible") compliance_type = 'COMPLIANT' @@ -96,6 +97,16 @@ def evaluate_compliance(event, context): annotation_str = '; '.join(annotation) if annotation else "All checked features are compliant" return build_evaluation(compliance_type, annotation_str) +def check_bucket_exists(bucket_name): + s3 = boto3.client('s3') + try: + response = s3.list_buckets() + buckets = [bucket['Name'] for bucket in response['Buckets']] + return bucket_name in buckets + except ClientError as e: + print(f"An error occurred: {e}") + return False + def build_evaluation(compliance_type, annotation): LOGGER.info(f"Build Evaluation Compliance Type: {compliance_type} Annotation: {annotation}") return { @@ -108,11 +119,12 @@ def lambda_handler(event, context): LOGGER.info(f"Lambda Handler Event: {event}") evaluation = evaluate_compliance(event, context) config = boto3.client('config') + params = ast.literal_eval(event['ruleParameters']) config.put_evaluations( Evaluations=[ { 'ComplianceResourceType': 'AWS::S3::Bucket', - 'ComplianceResourceId': event['ruleParameters']['BucketName'], + 'ComplianceResourceId': params.get('BucketName'), 'ComplianceType': evaluation['ComplianceType'], 'Annotation': evaluation['Annotation'], 'OrderingTimestamp': evaluation['OrderingTimestamp'] diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index fd6df9f02..b014f9f7a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -55,25 +55,31 @@ LIVE_RUN_DATA: dict = {} IAM_POLICY_DOCUMENTS: dict = { "sra-bedrock-check-iam-user-access": { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowReadIAM", "Effect": "Allow", - "Action": ["iam:Get*", "iam:List*"], "Resource": "*", - }, - ], - }, - "sra-bedrock-check-eval-job-bucket": { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowReadS3", "Effect": "Allow", - "Action": ["s3:GetBucketLifecycleConfiguration", "s3:GetBucketEncryption", - "s3:GetBucketLogging", "s3:GetObjectLockConfiguration", - "s3:GetBucketVersioning", "s3:HeadBucket"], "Resource": "arn:aws:s3:::*", - }, - ], - }, + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadIAM", "Effect": "Allow", + "Action": ["iam:Get*", "iam:List*"], "Resource": "*", + }, + ], + }, + "sra-bedrock-check-eval-job-bucket": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadS3", "Effect": "Allow", + "Action": [ + "s3:GetLifecycleConfiguration", + "s3:GetEncryptionConfiguration", + "s3:GetBucketLogging", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketVersioning", + "s3:ListBucket", + "s3:ListAllMyBuckets" + ], "Resource": "arn:aws:s3:::*", + }, + ], + }, } # Instantiate sra class objects @@ -333,6 +339,19 @@ def delete_event(event, context): LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDelete"] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" + policy_arn2 = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}" + LOGGER.info(f"Policy ARN: {policy_arn2}") + policy_search = iam.check_iam_policy_exists(policy_arn2) + if policy_search is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} IAM policy for account {acct} in {region}") + iam.delete_policy(policy_arn2) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" + else: + LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDelete"] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" + + # 7) Delete IAM execution role for custom config rule lambda role_search = iam.check_iam_role_exists(rule_name) if role_search[0] is True: From 15e6b08536787341e3911885a809694feb957a0f Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sat, 31 Aug 2024 15:07:40 -0600 Subject: [PATCH 047/395] adding param for bucket --- .../solutions/genai/bedrock_org/lambda/src/app.py | 8 +++++++- .../bedrock_org/templates/sra-bedrock-org-main.yaml | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b014f9f7a..fff566679 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -39,6 +39,7 @@ # SOLUTION_DIR: str = "bedrock_org" RULE_REGIONS_ACCOUNTS = {} GOVERNED_REGIONS = [] +BEDROCK_MODEL_EVAL_BUCKET: str = "" LAMBDA_START: str = "" LAMBDA_FINISH: str = "" @@ -99,6 +100,7 @@ def get_resource_parameters(event): global DRY_RUN global RULE_REGIONS_ACCOUNTS global GOVERNED_REGIONS + global BEDROCK_MODEL_EVAL_BUCKET LOGGER.info("Getting resource params...") # TODO(liamschn): what parameters do we need for this solution? @@ -121,6 +123,10 @@ def get_resource_parameters(event): # TODO(liamschn): continue working on getting this parameter. see test_even_bedrock_org.txt (or lambda) for test event; need to test in CFN too if "RULE_REGIONS_ACCOUNTS" in event["ResourceProperties"]: RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'", '"')) + + if "BEDROCK_MODEL_EVAL_BUCKET" in event["ResourceProperties"]: + BEDROCK_MODEL_EVAL_BUCKET = event["ResourceProperties"]["BEDROCK_MODEL_EVAL_BUCKET"] + if event["ResourceProperties"]["DRY_RUN"] == "true": # dry run LOGGER.info("Dry run enabled...") @@ -539,7 +545,7 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: "One_Hour", "CUSTOM_LAMBDA", rule_name, - {"applicableResourceType": "AWS::IAM::User", "maxCount": "0", "BucketName": "test-mod-eval-bucket"}, + {"BucketName": BEDROCK_MODEL_EVAL_BUCKET}, "DETECTIVE", SOLUTION_NAME, ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 4fa0f0ca7..5a9d3952b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -81,6 +81,15 @@ Parameters: Type: String AllowedValues: ['sra-bedrock-org-lambda'] + pBedrockModelEvalBucket: + AllowedPattern: '^(?=^.{3,63}$)(?!.*[.-]{2})(?!.*[--]{2})(?!^(?:(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(?!$)|$)){4}$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)' + ConstraintDescription: + Bedrock Model Evaluation Job S3 bucket name can include numbers, lowercase letters, uppercase letters, and hyphens (-). It cannot start or end with a hyphen (-). + Description: + Bedrock Model Evaluation Job S3 bucket name. + Type: String + Default: 'test-mod-eval-bucket' + Metadata: @@ -95,6 +104,7 @@ Metadata: - pSRASolutionName - pSraSolutionVersion - pSRAStagingS3BucketName + - pBedrockModelEvalBucket - Label: default: IAM Roles Parameters: @@ -129,6 +139,8 @@ Metadata: default: SRA Staging S3 Bucket Name pBedrockOrgLambdaRoleName: default: SRA Bedrock Org lambda role name + pBedrockModelEvalBucket: + default: Bedrock Model Evaluation Job S3 bucket name Resources: @@ -182,6 +194,7 @@ Resources: LOG_LEVEL: !Ref pLambdaLogLevel SOLUTION_NAME: !Ref pSRASolutionName SOLUTION_VERSION: !Ref pSraSolutionVersion + BEDROCK_MODEL_EVAL_BUCKET: !Ref pBedrockModelEvalBucket rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 4ce95caad24cc80a082ce7c9b06f199489954785 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sun, 1 Sep 2024 12:33:36 -0600 Subject: [PATCH 048/395] changing parameters for config rules --- .../genai/bedrock_org/lambda/src/app.py | 96 ++++++++++++++--- .../templates/sra-bedrock-org-main.yaml | 100 ++++++++++++++---- 2 files changed, 162 insertions(+), 34 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index fff566679..71d26c904 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -136,6 +136,63 @@ def get_resource_parameters(event): LOGGER.info("Dry run disabled...") DRY_RUN = False +def get_rule_params(rule_name, event): + """_summary_ + + Args: + rule_name (str): name of config rule + event (dict): lambda event + + Returns: + tuple: (rule_deploy, rule_accounts, rule_regions, rule_params) + rule_deploy (bool): whether to deploy the rule + rule_accounts (list): list of accounts to deploy the rule to + rule_regions (list): list of regions to deploy the rule to + rule_input_params (dict): dictionary of rule input parameters + """ + if rule_name.upper() in event["ResourceProperties"]: + LOGGER.info(f"{rule_name} parameter found in event ResourceProperties") + rule_params = json.loads(event["ResourceProperties"][rule_name.upper()]) + LOGGER.info(f"{rule_name.upper()} parameters: {rule_params}") + if "deploy" in rule_params: + LOGGER.info(f"{rule_name.upper()} 'deploy' parameter found in event ResourceProperties") + if rule_params["deploy"] == 'true': + LOGGER.info(f"{rule_name.upper()} 'deploy' parameter set to 'true'") + rule_deploy = True + else: + LOGGER.info(f"{rule_name.upper()} 'deploy' parameter set to 'false'") + rule_deploy = False + else: + LOGGER.info(f"{rule_name.upper()} 'deploy' parameter not found in event ResourceProperties; setting to False") + rule_deploy = False + if "accounts" in rule_params: + LOGGER.info(f"{rule_name.upper()} 'accounts' parameter found in event ResourceProperties") + rule_accounts = rule_params["accounts"] + LOGGER.info(f"{rule_name.upper()} accounts: {rule_accounts}") + else: + LOGGER.info(f"{rule_name.upper()} 'accounts' parameter not found in event ResourceProperties; setting to None and deploy to False") + rule_accounts = [] + rule_deploy = False + if "regions" in rule_params: + LOGGER.info(f"{rule_name.upper()} 'regions' parameter found in event ResourceProperties") + rule_regions = rule_params["regions"] + LOGGER.info(f"{rule_name.upper()} regions: {rule_regions}") + else: + LOGGER.info(f"{rule_name.upper()} 'regions' parameter not found in event ResourceProperties; setting to None and deploy to False") + rule_regions = [] + rule_deploy = False + if "input_params" in rule_params: + LOGGER.info(f"{rule_name.upper()} 'input_params' parameter found in event ResourceProperties") + rule_input_params = rule_params["input_params"] + LOGGER.info(f"{rule_name.upper()} input_params: {rule_input_params}") + else: + LOGGER.info(f"{rule_name.upper()} 'input_params' parameter not found in event ResourceProperties; setting to None") + rule_input_params = {} + return rule_deploy, rule_accounts, rule_regions, rule_input_params + else: + LOGGER.info(f"{rule_name.upper()} config rule parameter not found in event ResourceProperties; skipping...") + return False, [], [], {} + def create_event(event, context): global DRY_RUN_DATA @@ -191,19 +248,24 @@ def create_event(event, context): for rule in repo.CONFIG_RULES[SOLUTION_NAME]: rule_name = rule.replace("_", "-") # Get bedrock solution rule accounts and regions - if rule_name in RULE_REGIONS_ACCOUNTS: - if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: - rule_accounts = RULE_REGIONS_ACCOUNTS[rule_name]["accounts"] - else: - rule_accounts = [] - if "regions" in RULE_REGIONS_ACCOUNTS[rule_name]: - rule_regions = RULE_REGIONS_ACCOUNTS[rule_name]["regions"] - else: - rule_regions = [] - else: - LOGGER.info(f"No {rule_name} accounts or regions found in RULE_REGIONS_ACCOUNTS dictionary. Dictionary: {RULE_REGIONS_ACCOUNTS}") - # TODO(liamschn): setup default for org accounts and governed regions - LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") + rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) + if rule_deploy is False: + continue + + # return {"statusCode": 400, "body": f"{rule_name} parameter not found in event ResourceProperties"} + # if rule_name in RULE_REGIONS_ACCOUNTS: + # if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: + # rule_accounts = RULE_REGIONS_ACCOUNTS[rule_name]["accounts"] + # else: + # rule_accounts = [] + # if "regions" in RULE_REGIONS_ACCOUNTS[rule_name]: + # rule_regions = RULE_REGIONS_ACCOUNTS[rule_name]["regions"] + # else: + # rule_regions = [] + # else: + # LOGGER.info(f"No {rule_name} accounts or regions found in RULE_REGIONS_ACCOUNTS dictionary. Dictionary: {RULE_REGIONS_ACCOUNTS}") + # # TODO(liamschn): setup default for org accounts and governed regions + # LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") # 3a) Deploy IAM execution role for custom config rule lambda for acct in rule_accounts: if DRY_RUN is False: @@ -225,7 +287,7 @@ def create_event(event, context): # 3c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) if DRY_RUN is False: - config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region) + config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region, rule_input_params) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "Deployed custom config rule" else: LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") @@ -519,7 +581,7 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio return lambda_arn -def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: str) -> None: +def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: str, input_params: dict) -> None: """Deploy config rule. Args: @@ -527,6 +589,7 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: rule_name: config rule name lambda_arn: lambda function ARN regions: list of regions to deploy the config rule + input_params: input parameters for the config rule """ LOGGER.info(f"Deploying {rule_name} config rule to {account_id} in {region}...") config.CONFIG_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "config", region) @@ -545,7 +608,8 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: "One_Hour", "CUSTOM_LAMBDA", rule_name, - {"BucketName": BEDROCK_MODEL_EVAL_BUCKET}, + # {"BucketName": BEDROCK_MODEL_EVAL_BUCKET}, + input_params, "DETECTIVE", SOLUTION_NAME, ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 5a9d3952b..b8ed8f40b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -17,10 +17,10 @@ Parameters: Description: Whether to run in dry run mode or not # TODO(liamschn): this may not scale as the max is 4096 bytes; consider multiple parameters such as one for accounts and one for regions (may even need more if we have environments with 1000 accounts); the default below is already 198 bytes - pRuleRegionsAccounts: - Type: CommaDelimitedList - Default: "{'sra-bedrock-check-iam-user-access':{'accounts':['863518454635'],'regions':['us-west-2','us-east-1']},'sra-bedrock-check-eval-job-bucket':{'accounts':['863518454635'],'regions':['us-east-1','us-west-2']}}" - Description: List of regions and accounts to include in the SRA solution + # pRuleRegionsAccounts: + # Type: CommaDelimitedList + # Default: "{'sra-bedrock-check-iam-user-access':{'accounts':['863518454635'],'regions':['us-west-2','us-east-1']},'sra-bedrock-check-eval-job-bucket':{'accounts':['863518454635'],'regions':['us-east-1','us-west-2']}}" + # Description: List of regions and accounts to include in the SRA solution pSRAExecutionRoleName: Type: String @@ -81,15 +81,54 @@ Parameters: Type: String AllowedValues: ['sra-bedrock-org-lambda'] - pBedrockModelEvalBucket: - AllowedPattern: '^(?=^.{3,63}$)(?!.*[.-]{2})(?!.*[--]{2})(?!^(?:(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(?!$)|$)){4}$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)' - ConstraintDescription: - Bedrock Model Evaluation Job S3 bucket name can include numbers, lowercase letters, uppercase letters, and hyphens (-). It cannot start or end with a hyphen (-). - Description: - Bedrock Model Evaluation Job S3 bucket name. + # pBedrockModelEvalBucket: + # AllowedPattern: '^(?=^.{3,63}$)(?!.*[.-]{2})(?!.*[--]{2})(?!^(?:(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(?!$)|$)){4}$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)' + # ConstraintDescription: + # Bedrock Model Evaluation Job S3 bucket name can include numbers, lowercase letters, uppercase letters, and hyphens (-). It cannot start or end with a hyphen (-). + # Description: + # Bedrock Model Evaluation Job S3 bucket name. + # Type: String + # Default: 'test-mod-eval-bucket' + + # pBedrockDeployModelEvalBucketRule: + # Type: String + # Default: 'true' + # AllowedValues: + # - 'true' + # - 'false' + # Description: true or false; deploy bedrock model evaluation bucket config rule (default is true) + + # pBedrockDeployIAMUserAccessRule: + # Type: String + # Default: 'true' + # AllowedValues: + # - 'true' + # - 'false' + # Description: true or false; deploy bedrock IAM user access config rule (default is true) + + pBedrockModelEvalBucketRuleParams: Type: String - Default: 'test-mod-eval-bucket' + # TODO(liamschn): update default value of pBedrockModelEvalBucketRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"BucketName": "test-mod-eval-bucket"}}' + Description: Bedrock Model Evaluation Job Config Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ + ConstraintDescription: + "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object (can be empty or contain 'BucketName'). Arrays can be empty. + Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {\"s3BucketName\": \"my-bucket\"}} or + {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" + pBedrockIAMUserAccessRuleParams: + Type: String + # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' + Description: Bedrock IAM User Access Config Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ + ConstraintDescription: + "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object/dict (can be empty or contain 'BucketName'). Arrays can be empty. + Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or + {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" Metadata: @@ -104,7 +143,6 @@ Metadata: - pSRASolutionName - pSraSolutionVersion - pSRAStagingS3BucketName - - pBedrockModelEvalBucket - Label: default: IAM Roles Parameters: @@ -116,13 +154,25 @@ Metadata: - pDeployLambdaLogGroup - pLogGroupRetention - pLambdaLogLevel + - Label: + default: Bedrock Model Evaluation Bucket Rule + Parameters: + - pBedrockModelEvalBucketRuleParams + # - pBedrockDeployModelEvalBucketRule + # - pBedrockModelEvalBucket + - Label: + default: Bedrock IAM User Access Rule + Parameters: + - pBedrockIAMUserAccessRuleParams + # - pBedrockDeployIAMUserAccessRule + ParameterLabels: pSraRepoZipUrl: default: SRA Repo Zip URL pDryRun: default: Dry Run - pRuleRegionsAccounts: - default: Rule Regions and Accounts + # pRuleRegionsAccounts: + # default: Rule Regions and Accounts pSRAExecutionRoleName: default: Stack Execution Role Name pDeployLambdaLogGroup: @@ -139,8 +189,16 @@ Metadata: default: SRA Staging S3 Bucket Name pBedrockOrgLambdaRoleName: default: SRA Bedrock Org lambda role name - pBedrockModelEvalBucket: - default: Bedrock Model Evaluation Job S3 bucket name + # pBedrockModelEvalBucket: + # default: Bedrock Model Evaluation Job S3 bucket name + # pBedrockDeployModelEvalBucketRule: + # default: Deploy Bedrock Model Evaluation Job Config Rule + # pBedrockDeployIAMUserAccessRule: + # default: Deploy Bedrock IAM User Access Config Rule + pBedrockModelEvalBucketRuleParams: + default: Bedrock Model Evaluation Job Config Rule Parameters + pBedrockIAMUserAccessRuleParams: + default: Bedrock IAM User Access Config Rule Parameters Resources: @@ -187,14 +245,20 @@ Resources: ServiceToken: !GetAtt rBedrockOrgLambdaFunction.Arn SRA_REPO_ZIP_URL: !Ref pSraRepoZipUrl DRY_RUN: !Ref pDryRun - RULE_REGIONS_ACCOUNTS: !Join [',', !Ref pRuleRegionsAccounts] + # RULE_REGIONS_ACCOUNTS: !Join [',', !Ref pRuleRegionsAccounts] EXECUTION_ROLE_NAME: !Ref pSRAExecutionRoleName LOG_GROUP_DEPLOY: !Ref pDeployLambdaLogGroup LOG_GROUP_RETENTION: !Ref pLogGroupRetention LOG_LEVEL: !Ref pLambdaLogLevel SOLUTION_NAME: !Ref pSRASolutionName SOLUTION_VERSION: !Ref pSraSolutionVersion - BEDROCK_MODEL_EVAL_BUCKET: !Ref pBedrockModelEvalBucket + # sra-bedrock-check-eval-job-bucket rule parameters + # BEDROCK_DEPLOY_MODEL_EVAL_BUCKET_RULE: !Ref pBedrockDeployModelEvalBucketRule + # BEDROCK_MODEL_EVAL_BUCKET: !Ref pBedrockModelEvalBucket + # sra-bedrock-check-iam-user-access rule parameters + # BEDROCK_DEPLOY_IAM_USER_ACCESS_RULE: !Ref pBedrockDeployIAMUserAccessRule + SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET: !Ref pBedrockModelEvalBucketRuleParams + SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From ddee6005ae0e848cfe281b2bf25788e0669054b4 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 14:21:30 -0600 Subject: [PATCH 049/395] bedrock check guardrails rule --- .../rules/sra_becrock_check_guardrails/app.py | 74 +++++++++++++++++++ .../templates/sra-bedrock-org-main.yaml | 31 ++++++++ 2 files changed, 105 insertions(+) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_becrock_check_guardrails/app.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_becrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_becrock_check_guardrails/app.py new file mode 100644 index 000000000..afa9151d1 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_becrock_check_guardrails/app.py @@ -0,0 +1,74 @@ +import boto3 +import json +from datetime import datetime + +# Define the guardrail types as parameters +GUARDRAIL_PARAMETERS = { + 'check_safe_content': True, + 'check_responsible_ai': True, + 'check_data_privacy': True, + 'check_content_filtering': True, + 'check_token_limit': True +} + +def evaluate_compliance(configuration_item, rule_parameters): + # This function is not used for scheduled rules, but is required by AWS Config + return 'NOT_APPLICABLE' + +def lambda_handler(event, context): + # Initialize the Bedrock client + bedrock = boto3.client('bedrock') + + # Get rule parameters, use defaults if not provided + rule_params = event.get('ruleParameters', {}) + for param, default in GUARDRAIL_PARAMETERS.items(): + GUARDRAIL_PARAMETERS[param] = rule_params.get(param, default) + + # Get all available Bedrock model providers + model_providers = bedrock.list_foundation_models()['modelSummaries'] + + all_compliant = True + non_compliant_models = [] + + for model in model_providers: + model_id = model['modelId'] + + try: + # Get the guardrails for each model + guardrails = bedrock.get_foundation_model_guardrails(modelId=model_id) + + # Check if all selected guardrails are enabled + for guardrail in guardrails['guardrails']: + guardrail_type = guardrail['type'].lower() + if guardrail_type in GUARDRAIL_PARAMETERS and GUARDRAIL_PARAMETERS[f'check_{guardrail_type}']: + if not guardrail['enabled']: + all_compliant = False + non_compliant_models.append(f"{model_id} ({guardrail_type})") + except bedrock.exceptions.ResourceNotFoundException: + # If the model doesn't support guardrails, skip it + continue + + if all_compliant: + compliance_type = 'COMPLIANT' + annotation = 'All supported Bedrock models have the selected guardrails enabled.' + else: + compliance_type = 'NON_COMPLIANT' + annotation = f'The following models do not have all selected guardrails enabled: {", ".join(non_compliant_models)}' + + evaluation = { + 'ComplianceResourceType': 'AWS::::Account', + 'ComplianceResourceId': event['accountId'], + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': str(datetime.now().isoformat()) + } + + result = { + 'evaluations': [evaluation], + 'resultToken': event['resultToken'] + } + + config = boto3.client('config') + config.put_evaluations(**result) + + return result \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index b8ed8f40b..996166d56 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -130,6 +130,30 @@ Parameters: Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" + # pBedrockGuardrailsRuleParams: + # Type: String + # Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_safe_content": "true", "check_responsible_ai": "true", "check_data_privacy": "true", "check_content_filtering": "true", "check_token_limit": "true"}}' + # Description: Bedrock Guardrails Config Rule Parameters + # AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ + # ConstraintDescription: + # "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + # 'regions' (array of region names), and 'input_params' object/dict (can be empty or contain 'BucketName'). Arrays can be empty. + # Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or + # {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" + pBedrockGuardrailsRuleParams: + Type: String + # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_safe_content": "true", "check_responsible_ai": "true", "check_data_privacy": "true", "check_content_filtering": "true", "check_token_limit": "true"}}' + Description: Bedrock Guardrails Config Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_safe_content"\s*:\s*"(true|false)")?(\s*,\s*"check_responsible_ai"\s*:\s*"(true|false)")?(\s*,\s*"check_data_privacy"\s*:\s*"(true|false)")?(\s*,\s*"check_content_filtering"\s*:\s*"(true|false)")?(\s*,\s*"check_token_limit"\s*:\s*"(true|false)")?\s*\}\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object with optional parameters: + 'check_safe_content', 'check_responsible_ai', 'check_data_privacy', 'check_content_filtering', 'check_token_limit'. + Each parameter in 'input_params' should be either "true" or "false". + Arrays can be empty. + Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_safe_content": "true", "check_responsible_ai": "false"}} or + {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} Metadata: AWS::CloudFormation::Interface: @@ -165,6 +189,10 @@ Metadata: Parameters: - pBedrockIAMUserAccessRuleParams # - pBedrockDeployIAMUserAccessRule + - Label: + default: Bedrock Guardrails Rule + Parameters: + - pBedrockGuardrailsRuleParams ParameterLabels: pSraRepoZipUrl: @@ -199,6 +227,8 @@ Metadata: default: Bedrock Model Evaluation Job Config Rule Parameters pBedrockIAMUserAccessRuleParams: default: Bedrock IAM User Access Config Rule Parameters + pBedrockGuardrailsRuleParams: + default: Bedrock Guardrails Config Rule Parameters Resources: @@ -259,6 +289,7 @@ Resources: # BEDROCK_DEPLOY_IAM_USER_ACCESS_RULE: !Ref pBedrockDeployIAMUserAccessRule SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET: !Ref pBedrockModelEvalBucketRuleParams SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams + SRA-BEDROCK-CHECK-GUARDRAILS: !Ref pBedrockGuardrailsRuleParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From fa12fce7b7c5e6a1bdae4e37ab25cc07aef9811d Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 14:33:07 -0600 Subject: [PATCH 050/395] working on update event --- .../solutions/genai/bedrock_org/lambda/src/app.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 71d26c904..a739f10a4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -306,13 +306,20 @@ def create_event(event, context): def update_event(event, context): - # TODO(liamschn): handle CFN update events; use case: change from DRY_RUN = False to DRY_RUN = True + # TODO(liamschn): handle CFN update events; use case: change from DRY_RUN = False to DRY_RUN = True or vice versa + # TODO(liamschn): handle CFN update events; use case: add additional config rules via new rules in code (i.e. ...\rules\new_rule\app.py) + # TODO(liamschn): handle CFN update events; use case: changing config rule parameters (i.e. deploy, accounts, regions, input_params) + # TODO(liamschn): handle CFN update events; use case: setting deploy = false should remove the config rule + global DRY_RUN_DATA LOGGER.info("update event function") + # Temp calling create_event so that an update will actually do something; need to determine if this is the best way or not. + create_event(event, context) # data = sra_s3.s3_resource_check() # TODO(liamschn): update data dictionary - data = {"data": "no info"} - if RESOURCE_TYPE != "Other": - cfnresponse.send(event, context, cfnresponse.SUCCESS, data, CFN_RESOURCE_ID) + # data = {"data": "no info"} + # if RESOURCE_TYPE != "Other": + # cfnresponse.send(event, context, cfnresponse.SUCCESS, data, CFN_RESOURCE_ID) + return CFN_RESOURCE_ID def delete_event(event, context): From b2ef8e68ed2e279403930f64fe6d219a1fa88e2b Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 14:40:30 -0600 Subject: [PATCH 051/395] fix misspelled directory --- .../app.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/{sra_becrock_check_guardrails => sra_bedrock_check_guardrails}/app.py (100%) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_becrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py similarity index 100% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_becrock_check_guardrails/app.py rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py From 83bf1385651d1ac9f5419c96f993e11b8b1ab4db Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 15:07:29 -0600 Subject: [PATCH 052/395] adding new perms; updating new rule --- .../rules/sra_bedrock_check_guardrails/app.py | 3 +- .../genai/bedrock_org/lambda/src/app.py | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index afa9151d1..20963f3f7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -1,6 +1,7 @@ import boto3 import json from datetime import datetime +import ast # Define the guardrail types as parameters GUARDRAIL_PARAMETERS = { @@ -20,7 +21,7 @@ def lambda_handler(event, context): bedrock = boto3.client('bedrock') # Get rule parameters, use defaults if not provided - rule_params = event.get('ruleParameters', {}) + rule_params = ast.literal_eval(event.get('ruleParameters', {})) for param, default in GUARDRAIL_PARAMETERS.items(): GUARDRAIL_PARAMETERS[param] = rule_params.get(param, default) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a739f10a4..579d88b31 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -59,8 +59,10 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "AllowReadIAM", "Effect": "Allow", - "Action": ["iam:Get*", "iam:List*"], "Resource": "*", + "Sid": "AllowReadIAM", + "Effect": "Allow", + "Action": ["iam:Get*", "iam:List*"], + "Resource": "*", }, ], }, @@ -68,16 +70,18 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "AllowReadS3", "Effect": "Allow", + "Sid": "AllowReadS3", + "Effect": "Allow", "Action": [ "s3:GetLifecycleConfiguration", "s3:GetEncryptionConfiguration", "s3:GetBucketLogging", "s3:GetBucketObjectLockConfiguration", "s3:GetBucketVersioning", - "s3:ListBucket", - "s3:ListAllMyBuckets" - ], "Resource": "arn:aws:s3:::*", + "s3:ListBucket", + "s3:ListAllMyBuckets", + ], + "Resource": "arn:aws:s3:::*", }, ], }, @@ -123,7 +127,7 @@ def get_resource_parameters(event): # TODO(liamschn): continue working on getting this parameter. see test_even_bedrock_org.txt (or lambda) for test event; need to test in CFN too if "RULE_REGIONS_ACCOUNTS" in event["ResourceProperties"]: RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'", '"')) - + if "BEDROCK_MODEL_EVAL_BUCKET" in event["ResourceProperties"]: BEDROCK_MODEL_EVAL_BUCKET = event["ResourceProperties"]["BEDROCK_MODEL_EVAL_BUCKET"] @@ -136,6 +140,7 @@ def get_resource_parameters(event): LOGGER.info("Dry run disabled...") DRY_RUN = False + def get_rule_params(rule_name, event): """_summary_ @@ -156,7 +161,7 @@ def get_rule_params(rule_name, event): LOGGER.info(f"{rule_name.upper()} parameters: {rule_params}") if "deploy" in rule_params: LOGGER.info(f"{rule_name.upper()} 'deploy' parameter found in event ResourceProperties") - if rule_params["deploy"] == 'true': + if rule_params["deploy"] == "true": LOGGER.info(f"{rule_name.upper()} 'deploy' parameter set to 'true'") rule_deploy = True else: @@ -396,10 +401,14 @@ def delete_event(event, context): for policy in attached_policies: LOGGER.info(f"Detaching {policy['PolicyName']} IAM policy from account {acct} in {region}") iam.detach_policy(rule_name, policy["PolicyArn"]) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDetach"] = f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" + LIVE_RUN_DATA[ + f"{rule_name}_{acct}_{region}_PolicyDetach" + ] = f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" else: LOGGER.info(f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" + DRY_RUN_DATA[ + f"{rule_name}_{acct}_{region}_Delete" + ] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" # 6) Delete IAM policy policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" @@ -412,7 +421,9 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" else: LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDelete"] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" + DRY_RUN_DATA[ + f"{rule_name}_{acct}_{region}_PolicyDelete" + ] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" policy_arn2 = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}" LOGGER.info(f"Policy ARN: {policy_arn2}") @@ -424,8 +435,9 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" else: LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDelete"] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" - + DRY_RUN_DATA[ + f"{rule_name}_{acct}_{region}_PolicyDelete" + ] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" # 7) Delete IAM execution role for custom config rule lambda role_search = iam.check_iam_role_exists(rule_name) @@ -519,7 +531,6 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"{rule_name} IAM policy already exists") - policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) if policy_attach_search1 is False: if DRY_RUN is False: From cea4d3188fef414b40e2d583d44b2f0c2aa70193 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 15:30:55 -0600 Subject: [PATCH 053/395] updating delete operation code --- .../genai/bedrock_org/lambda/src/app.py | 91 ++++++++++--------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 579d88b31..7f7a52885 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -347,50 +347,57 @@ def delete_event(event, context): # 2) Delete config rules # TODO(liamschn): deal with invalid rule names # TODO(liamschn): deal with invalid account IDs - for rule in RULE_REGIONS_ACCOUNTS: - rule_name: str = rule.replace("_", "-") - # Get bedrock solution rule accounts and regions - if rule_name in RULE_REGIONS_ACCOUNTS: - if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: - rule_accounts = RULE_REGIONS_ACCOUNTS[rule_name]["accounts"] - else: - rule_accounts = [] - if "regions" in RULE_REGIONS_ACCOUNTS[rule_name]: - rule_regions = RULE_REGIONS_ACCOUNTS[rule_name]["regions"] - else: - rule_regions = [] - else: - LOGGER.info(f"No {rule_name} accounts or regions found in RULE_REGIONS_ACCOUNTS dictionary. Dictionary: {RULE_REGIONS_ACCOUNTS}") - LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") - for acct in rule_accounts: - for region in rule_regions: - # 3) Delete the config rule - config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) - config_rule_search = config.find_config_rule(rule_name) - if config_rule_search[0] is True: - if DRY_RUN is False: - LOGGER.info(f"Deleting {rule_name} config rule for account {acct} in {region}") - config.delete_config_rule(rule_name) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" + for prop in event["ResourceProperties"]: + if prop.startswith("SRA-"): + rule_name: str = prop + LOGGER.info(f"Delete operation: retrieving {rule_name} parameters...") + rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) + rule_name = rule_name.lower() + LOGGER.info(f"Delete operation: examining {rule_name} resources...") + # for rule in RULE_REGIONS_ACCOUNTS: + # rule_name: str = rule.replace("_", "-") + # # Get bedrock solution rule accounts and regions + # if rule_name in RULE_REGIONS_ACCOUNTS: + # if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: + # rule_accounts = RULE_REGIONS_ACCOUNTS[rule_name]["accounts"] + # else: + # rule_accounts = [] + # if "regions" in RULE_REGIONS_ACCOUNTS[rule_name]: + # rule_regions = RULE_REGIONS_ACCOUNTS[rule_name]["regions"] + # else: + # rule_regions = [] + # else: + # LOGGER.info(f"No {rule_name} accounts or regions found in RULE_REGIONS_ACCOUNTS dictionary. Dictionary: {RULE_REGIONS_ACCOUNTS}") + # LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") + for acct in rule_accounts: + for region in rule_regions: + # 3) Delete the config rule + config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) + config_rule_search = config.find_config_rule(rule_name) + if config_rule_search[0] is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} config rule for account {acct} in {region}") + config.delete_config_rule(rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" + else: + LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") else: - LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") - else: - LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" - - # 4) Delete lambda for custom config rule - lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) - lambda_search = lambdas.find_lambda_function(rule_name) - if lambda_search is not None: - if DRY_RUN is False: - LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") - lambdas.delete_lambda_function(rule_name) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" + LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" + + # 4) Delete lambda for custom config rule + lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) + lambda_search = lambdas.find_lambda_function(rule_name) + if lambda_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") + lambdas.delete_lambda_function(rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" + else: + LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} lambda function" else: - LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} lambda function" - else: - LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") + LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") # 5) Detach IAM policies # TODO(liamschn): handle case where policy is not found attached_policies = None From d90746c28e95d5a4dfa0686d42e4a20f23bddfa5 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 15:50:17 -0600 Subject: [PATCH 054/395] updating perms for new rule again --- .../solutions/genai/bedrock_org/lambda/src/app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 7f7a52885..0deb1fbbe 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -85,6 +85,17 @@ }, ], }, + "sra-bedrock-check-guardrails": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadBedrock", + "Effect": "Allow", + "Action": ["bedrock:ListFoundationModels","bedrock:GetFoundationModelGuardrails"], + "Resource": "*", + }, + ], + }, } # Instantiate sra class objects From 0f884de64ee397147e29aff20159f441f0fcf4a8 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 20:15:59 -0600 Subject: [PATCH 055/395] updating new rule + perms --- .../rules/sra_bedrock_check_guardrails/app.py | 100 +++++++++++------- .../genai/bedrock_org/lambda/src/app.py | 5 +- 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 20963f3f7..3e8e164a4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -2,59 +2,85 @@ import json from datetime import datetime import ast +import logging +import os -# Define the guardrail types as parameters -GUARDRAIL_PARAMETERS = { - 'check_safe_content': True, - 'check_responsible_ai': True, - 'check_data_privacy': True, - 'check_content_filtering': True, - 'check_token_limit': True +# Set up logging +log_level = os.environ.get('LOG_LEVEL', 'INFO').upper() +logging.basicConfig(level=log_level) +LOGGER = logging.getLogger(__name__) + +GUARDRAIL_FEATURES = { + 'content_filters': True, + 'denied_topics': True, + 'word_filters': True, + 'sensitive_info_filters': True, + 'contextual_grounding': True } def evaluate_compliance(configuration_item, rule_parameters): - # This function is not used for scheduled rules, but is required by AWS Config return 'NOT_APPLICABLE' def lambda_handler(event, context): - # Initialize the Bedrock client + LOGGER.info("Starting lambda_handler function") bedrock = boto3.client('bedrock') - # Get rule parameters, use defaults if not provided - rule_params = ast.literal_eval(event.get('ruleParameters', {})) - for param, default in GUARDRAIL_PARAMETERS.items(): - GUARDRAIL_PARAMETERS[param] = rule_params.get(param, default) + # Parse rule parameters safely using ast.literal_eval + LOGGER.info("Parsing rule parameters") + rule_params = ast.literal_eval(event.get('ruleParameters', '{}')) + for param, default in GUARDRAIL_FEATURES.items(): + GUARDRAIL_FEATURES[param] = rule_params.get(param, default) + LOGGER.info(f"Guardrail features to check: {GUARDRAIL_FEATURES}") - # Get all available Bedrock model providers - model_providers = bedrock.list_foundation_models()['modelSummaries'] + # List all guardrails + LOGGER.info("Listing all Bedrock guardrails") + guardrails = bedrock.list_guardrails()['guardrailSummaries'] + LOGGER.info(f"Found {len(guardrails)} guardrails") - all_compliant = True - non_compliant_models = [] + compliant_guardrails = [] + non_compliant_guardrails = {} - for model in model_providers: - model_id = model['modelId'] + for guardrail in guardrails: + guardrail_name = guardrail['guardrailName'] + LOGGER.info(f"Checking guardrail: {guardrail_name}") + guardrail_details = bedrock.get_guardrail(guardrailName=guardrail_name) - try: - # Get the guardrails for each model - guardrails = bedrock.get_foundation_model_guardrails(modelId=model_id) - - # Check if all selected guardrails are enabled - for guardrail in guardrails['guardrails']: - guardrail_type = guardrail['type'].lower() - if guardrail_type in GUARDRAIL_PARAMETERS and GUARDRAIL_PARAMETERS[f'check_{guardrail_type}']: - if not guardrail['enabled']: - all_compliant = False - non_compliant_models.append(f"{model_id} ({guardrail_type})") - except bedrock.exceptions.ResourceNotFoundException: - # If the model doesn't support guardrails, skip it - continue + missing_features = [] + for feature, required in GUARDRAIL_FEATURES.items(): + if required: + LOGGER.info(f"Checking feature: {feature}") + if feature == 'content_filters' and not guardrail_details.get('contentFilters'): + missing_features.append('content_filters') + elif feature == 'denied_topics' and not guardrail_details.get('deniedTopics'): + missing_features.append('denied_topics') + elif feature == 'word_filters' and not guardrail_details.get('wordFilters'): + missing_features.append('word_filters') + elif feature == 'sensitive_info_filters' and not guardrail_details.get('sensitiveInfoFilters'): + missing_features.append('sensitive_info_filters') + elif feature == 'contextual_grounding' and not guardrail_details.get('contextualGrounding'): + missing_features.append('contextual_grounding') + + if not missing_features: + LOGGER.info(f"Guardrail {guardrail_name} is compliant") + compliant_guardrails.append(guardrail_name) + else: + LOGGER.info(f"Guardrail {guardrail_name} is missing features: {missing_features}") + non_compliant_guardrails[guardrail_name] = missing_features - if all_compliant: + LOGGER.info("Determining overall compliance status") + if compliant_guardrails: compliance_type = 'COMPLIANT' - annotation = 'All supported Bedrock models have the selected guardrails enabled.' + if len(compliant_guardrails) == 1: + annotation = f"The following Bedrock guardrail contains all required features: {compliant_guardrails[0]}" + else: + annotation = f"The following Bedrock guardrails contain all required features: {', '.join(compliant_guardrails)}" + LOGGER.info(f"Account is COMPLIANT. {annotation}") else: compliance_type = 'NON_COMPLIANT' - annotation = f'The following models do not have all selected guardrails enabled: {", ".join(non_compliant_models)}' + annotation = 'No Bedrock guardrails contain all required features. Missing features per guardrail:\n' + for guardrail, missing in non_compliant_guardrails.items(): + annotation += f"- {guardrail}: missing {', '.join(missing)}\n" + LOGGER.info(f"Account is NON_COMPLIANT. {annotation}") evaluation = { 'ComplianceResourceType': 'AWS::::Account', @@ -64,6 +90,7 @@ def lambda_handler(event, context): 'OrderingTimestamp': str(datetime.now().isoformat()) } + LOGGER.info("Sending evaluation results to AWS Config") result = { 'evaluations': [evaluation], 'resultToken': event['resultToken'] @@ -72,4 +99,5 @@ def lambda_handler(event, context): config = boto3.client('config') config.put_evaluations(**result) + LOGGER.info("Lambda function execution completed") return result \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0deb1fbbe..c67e7727e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -91,7 +91,10 @@ { "Sid": "AllowReadBedrock", "Effect": "Allow", - "Action": ["bedrock:ListFoundationModels","bedrock:GetFoundationModelGuardrails"], + "Action": [ + "bedrock:ListGuardrails", + "bedrock:GetGuardrail" + ], "Resource": "*", }, ], From 109eabceb476b0e56160294d7194815070bf5efe Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 21:09:18 -0600 Subject: [PATCH 056/395] updated code --- .../lambda/rules/sra_bedrock_check_guardrails/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 3e8e164a4..9b0e0637b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -34,14 +34,14 @@ def lambda_handler(event, context): # List all guardrails LOGGER.info("Listing all Bedrock guardrails") - guardrails = bedrock.list_guardrails()['guardrailSummaries'] + guardrails = bedrock.list_guardrails()['guardrails'] LOGGER.info(f"Found {len(guardrails)} guardrails") compliant_guardrails = [] non_compliant_guardrails = {} for guardrail in guardrails: - guardrail_name = guardrail['guardrailName'] + guardrail_name = guardrail['name'] LOGGER.info(f"Checking guardrail: {guardrail_name}") guardrail_details = bedrock.get_guardrail(guardrailName=guardrail_name) From 89d9e20f6d7c1bd724a4792233e8994103cdc339 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 23:04:09 -0600 Subject: [PATCH 057/395] still working on new rule code --- .../rules/sra_bedrock_check_guardrails/app.py | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 9b0e0637b..f1b630334 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -5,10 +5,11 @@ import logging import os -# Set up logging -log_level = os.environ.get('LOG_LEVEL', 'INFO').upper() -logging.basicConfig(level=log_level) +# Setup Default Logger LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") GUARDRAIL_FEATURES = { 'content_filters': True, @@ -87,17 +88,24 @@ def lambda_handler(event, context): 'ComplianceResourceId': event['accountId'], 'ComplianceType': compliance_type, 'Annotation': annotation, - 'OrderingTimestamp': str(datetime.now().isoformat()) + 'OrderingTimestamp': datetime.now().isoformat() } LOGGER.info("Sending evaluation results to AWS Config") - result = { - 'evaluations': [evaluation], - 'resultToken': event['resultToken'] - } - config = boto3.client('config') - config.put_evaluations(**result) + + try: + response = config.put_evaluations( + Evaluations=[evaluation], + ResultToken=event['resultToken'] + ) + LOGGER.info(f"Evaluation sent successfully: {response}") + except Exception as e: + LOGGER.error(f"Error sending evaluation: {str(e)}") + raise LOGGER.info("Lambda function execution completed") - return result \ No newline at end of file + return { + 'statusCode': 200, + 'body': json.dumps('Evaluation complete') + } From c01502e2dd4b70ba2c4196fd341bf3db7bb47249 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 3 Sep 2024 23:11:50 -0600 Subject: [PATCH 058/395] still working on new rule code --- .../rules/sra_bedrock_check_guardrails/app.py | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index f1b630334..f8162ce9c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -42,31 +42,39 @@ def lambda_handler(event, context): non_compliant_guardrails = {} for guardrail in guardrails: - guardrail_name = guardrail['name'] - LOGGER.info(f"Checking guardrail: {guardrail_name}") - guardrail_details = bedrock.get_guardrail(guardrailName=guardrail_name) + guardrail_id = guardrail['id'] + guardrail_name = guardrail.get('name', guardrail_id) # Use 'name' if available, otherwise use the identifier + LOGGER.info(f"Checking guardrail: {guardrail_name} (ID: {guardrail_id})") - missing_features = [] - for feature, required in GUARDRAIL_FEATURES.items(): - if required: - LOGGER.info(f"Checking feature: {feature}") - if feature == 'content_filters' and not guardrail_details.get('contentFilters'): - missing_features.append('content_filters') - elif feature == 'denied_topics' and not guardrail_details.get('deniedTopics'): - missing_features.append('denied_topics') - elif feature == 'word_filters' and not guardrail_details.get('wordFilters'): - missing_features.append('word_filters') - elif feature == 'sensitive_info_filters' and not guardrail_details.get('sensitiveInfoFilters'): - missing_features.append('sensitive_info_filters') - elif feature == 'contextual_grounding' and not guardrail_details.get('contextualGrounding'): - missing_features.append('contextual_grounding') + try: + guardrail_details = bedrock.get_guardrail(guardrailIdentifier=guardrail_id) + + missing_features = [] + for feature, required in GUARDRAIL_FEATURES.items(): + if required: + LOGGER.info(f"Checking feature: {feature}") + if feature == 'content_filters' and not guardrail_details.get('contentFilters'): + missing_features.append('content_filters') + elif feature == 'denied_topics' and not guardrail_details.get('deniedTopics'): + missing_features.append('denied_topics') + elif feature == 'word_filters' and not guardrail_details.get('wordFilters'): + missing_features.append('word_filters') + elif feature == 'sensitive_info_filters' and not guardrail_details.get('sensitiveInfoFilters'): + missing_features.append('sensitive_info_filters') + elif feature == 'contextual_grounding' and not guardrail_details.get('contextualGrounding'): + missing_features.append('contextual_grounding') - if not missing_features: - LOGGER.info(f"Guardrail {guardrail_name} is compliant") - compliant_guardrails.append(guardrail_name) - else: - LOGGER.info(f"Guardrail {guardrail_name} is missing features: {missing_features}") - non_compliant_guardrails[guardrail_name] = missing_features + if not missing_features: + LOGGER.info(f"Guardrail {guardrail_name} is compliant") + compliant_guardrails.append(guardrail_name) + else: + LOGGER.info(f"Guardrail {guardrail_name} is missing features: {missing_features}") + non_compliant_guardrails[guardrail_name] = missing_features + + except bedrock.exceptions.ResourceNotFoundException: + LOGGER.warning(f"Guardrail {guardrail_name} (ID: {guardrail_id}) not found") + except Exception as e: + LOGGER.error(f"Error checking guardrail {guardrail_name} (ID: {guardrail_id}): {str(e)}") LOGGER.info("Determining overall compliance status") if compliant_guardrails: From 98ec885368b6d5c00b4cde46a813fe17cdda6254 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 4 Sep 2024 09:41:57 -0600 Subject: [PATCH 059/395] updating guardrail check rule and cfn template --- .../rules/sra_bedrock_check_guardrails/app.py | 11 ++-- .../templates/sra-bedrock-org-main.yaml | 64 +------------------ 2 files changed, 9 insertions(+), 66 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index f8162ce9c..6bc6b0562 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -29,6 +29,7 @@ def lambda_handler(event, context): # Parse rule parameters safely using ast.literal_eval LOGGER.info("Parsing rule parameters") rule_params = ast.literal_eval(event.get('ruleParameters', '{}')) + LOGGER.info(f"Rule parameters: {rule_params}") for param, default in GUARDRAIL_FEATURES.items(): GUARDRAIL_FEATURES[param] = rule_params.get(param, default) LOGGER.info(f"Guardrail features to check: {GUARDRAIL_FEATURES}") @@ -53,15 +54,15 @@ def lambda_handler(event, context): for feature, required in GUARDRAIL_FEATURES.items(): if required: LOGGER.info(f"Checking feature: {feature}") - if feature == 'content_filters' and not guardrail_details.get('contentFilters'): + if feature == 'content_filters' and not guardrail_details.get('contentPolicy'): missing_features.append('content_filters') - elif feature == 'denied_topics' and not guardrail_details.get('deniedTopics'): + elif feature == 'denied_topics' and not guardrail_details.get('topicPolicy'): missing_features.append('denied_topics') - elif feature == 'word_filters' and not guardrail_details.get('wordFilters'): + elif feature == 'word_filters' and not guardrail_details.get('wordPolicy'): missing_features.append('word_filters') - elif feature == 'sensitive_info_filters' and not guardrail_details.get('sensitiveInfoFilters'): + elif feature == 'sensitive_info_filters' and not guardrail_details.get('sensitiveInformationPolicy'): missing_features.append('sensitive_info_filters') - elif feature == 'contextual_grounding' and not guardrail_details.get('contextualGrounding'): + elif feature == 'contextual_grounding' and not guardrail_details.get('contextualGroundingPolicy'): missing_features.append('contextual_grounding') if not missing_features: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 996166d56..2b9f00c04 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -16,12 +16,6 @@ Parameters: - 'false' Description: Whether to run in dry run mode or not - # TODO(liamschn): this may not scale as the max is 4096 bytes; consider multiple parameters such as one for accounts and one for regions (may even need more if we have environments with 1000 accounts); the default below is already 198 bytes - # pRuleRegionsAccounts: - # Type: CommaDelimitedList - # Default: "{'sra-bedrock-check-iam-user-access':{'accounts':['863518454635'],'regions':['us-west-2','us-east-1']},'sra-bedrock-check-eval-job-bucket':{'accounts':['863518454635'],'regions':['us-east-1','us-west-2']}}" - # Description: List of regions and accounts to include in the SRA solution - pSRAExecutionRoleName: Type: String Default: 'sra-execution' @@ -80,32 +74,7 @@ Parameters: Description: Bedrock security control configuration Lambda role name Type: String AllowedValues: ['sra-bedrock-org-lambda'] - - # pBedrockModelEvalBucket: - # AllowedPattern: '^(?=^.{3,63}$)(?!.*[.-]{2})(?!.*[--]{2})(?!^(?:(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(?!$)|$)){4}$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)' - # ConstraintDescription: - # Bedrock Model Evaluation Job S3 bucket name can include numbers, lowercase letters, uppercase letters, and hyphens (-). It cannot start or end with a hyphen (-). - # Description: - # Bedrock Model Evaluation Job S3 bucket name. - # Type: String - # Default: 'test-mod-eval-bucket' - - # pBedrockDeployModelEvalBucketRule: - # Type: String - # Default: 'true' - # AllowedValues: - # - 'true' - # - 'false' - # Description: true or false; deploy bedrock model evaluation bucket config rule (default is true) - - # pBedrockDeployIAMUserAccessRule: - # Type: String - # Default: 'true' - # AllowedValues: - # - 'true' - # - 'false' - # Description: true or false; deploy bedrock IAM user access config rule (default is true) - + pBedrockModelEvalBucketRuleParams: Type: String # TODO(liamschn): update default value of pBedrockModelEvalBucketRuleParams prior to production @@ -130,22 +99,12 @@ Parameters: Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" - # pBedrockGuardrailsRuleParams: - # Type: String - # Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_safe_content": "true", "check_responsible_ai": "true", "check_data_privacy": "true", "check_content_filtering": "true", "check_token_limit": "true"}}' - # Description: Bedrock Guardrails Config Rule Parameters - # AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ - # ConstraintDescription: - # "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), - # 'regions' (array of region names), and 'input_params' object/dict (can be empty or contain 'BucketName'). Arrays can be empty. - # Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or - # {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" pBedrockGuardrailsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_safe_content": "true", "check_responsible_ai": "true", "check_data_privacy": "true", "check_content_filtering": "true", "check_token_limit": "true"}}' + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' Description: Bedrock Guardrails Config Rule Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_safe_content"\s*:\s*"(true|false)")?(\s*,\s*"check_responsible_ai"\s*:\s*"(true|false)")?(\s*,\s*"check_data_privacy"\s*:\s*"(true|false)")?(\s*,\s*"check_content_filtering"\s*:\s*"(true|false)")?(\s*,\s*"check_token_limit"\s*:\s*"(true|false)")?\s*\}\}$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), 'regions' (array of region names), and 'input_params' object with optional parameters: @@ -182,13 +141,10 @@ Metadata: default: Bedrock Model Evaluation Bucket Rule Parameters: - pBedrockModelEvalBucketRuleParams - # - pBedrockDeployModelEvalBucketRule - # - pBedrockModelEvalBucket - Label: default: Bedrock IAM User Access Rule Parameters: - pBedrockIAMUserAccessRuleParams - # - pBedrockDeployIAMUserAccessRule - Label: default: Bedrock Guardrails Rule Parameters: @@ -199,8 +155,6 @@ Metadata: default: SRA Repo Zip URL pDryRun: default: Dry Run - # pRuleRegionsAccounts: - # default: Rule Regions and Accounts pSRAExecutionRoleName: default: Stack Execution Role Name pDeployLambdaLogGroup: @@ -217,12 +171,6 @@ Metadata: default: SRA Staging S3 Bucket Name pBedrockOrgLambdaRoleName: default: SRA Bedrock Org lambda role name - # pBedrockModelEvalBucket: - # default: Bedrock Model Evaluation Job S3 bucket name - # pBedrockDeployModelEvalBucketRule: - # default: Deploy Bedrock Model Evaluation Job Config Rule - # pBedrockDeployIAMUserAccessRule: - # default: Deploy Bedrock IAM User Access Config Rule pBedrockModelEvalBucketRuleParams: default: Bedrock Model Evaluation Job Config Rule Parameters pBedrockIAMUserAccessRuleParams: @@ -275,18 +223,12 @@ Resources: ServiceToken: !GetAtt rBedrockOrgLambdaFunction.Arn SRA_REPO_ZIP_URL: !Ref pSraRepoZipUrl DRY_RUN: !Ref pDryRun - # RULE_REGIONS_ACCOUNTS: !Join [',', !Ref pRuleRegionsAccounts] EXECUTION_ROLE_NAME: !Ref pSRAExecutionRoleName LOG_GROUP_DEPLOY: !Ref pDeployLambdaLogGroup LOG_GROUP_RETENTION: !Ref pLogGroupRetention LOG_LEVEL: !Ref pLambdaLogLevel SOLUTION_NAME: !Ref pSRASolutionName SOLUTION_VERSION: !Ref pSraSolutionVersion - # sra-bedrock-check-eval-job-bucket rule parameters - # BEDROCK_DEPLOY_MODEL_EVAL_BUCKET_RULE: !Ref pBedrockDeployModelEvalBucketRule - # BEDROCK_MODEL_EVAL_BUCKET: !Ref pBedrockModelEvalBucket - # sra-bedrock-check-iam-user-access rule parameters - # BEDROCK_DEPLOY_IAM_USER_ACCESS_RULE: !Ref pBedrockDeployIAMUserAccessRule SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET: !Ref pBedrockModelEvalBucketRuleParams SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams SRA-BEDROCK-CHECK-GUARDRAILS: !Ref pBedrockGuardrailsRuleParams From 5e1677a3238b12f86a06448c3c8ec3828082cc57 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 5 Sep 2024 12:38:36 -0600 Subject: [PATCH 060/395] add endpoint rule; move perms to file --- .../sra_bedrock_check_vpc_endpoints/app.py | 85 +++++++++++++++++++ .../genai/bedrock_org/lambda/src/app.py | 56 ++---------- .../sra-config-lambda-iam-permissions.json | 54 ++++++++++++ .../templates/sra-bedrock-org-main.yaml | 27 +++++- 4 files changed, 172 insertions(+), 50 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py new file mode 100644 index 000000000..fbf1b6577 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -0,0 +1,85 @@ + +import boto3 +import json +import os +import logging +import ast + +# Configure logging +LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper() +LOGGER = logging.getLogger() +LOGGER.setLevel(LOG_LEVEL) + +# Initialize AWS clients +ec2_client = boto3.client('ec2') +config_client = boto3.client('config') + +def evaluate_compliance(configuration_item, rule_parameters): + """Evaluates if the required VPC endpoints are in place""" + + if configuration_item['resourceType'] != 'AWS::EC2::VPC': + return 'NOT_APPLICABLE' + + vpc_id = configuration_item['configuration']['vpcId'] + + # Parse rule parameters + params = ast.literal_eval(json.dumps(rule_parameters)) if rule_parameters else {} + check_bedrock = params.get('check_bedrock', True) + check_bedrock_agent = params.get('check_bedrock_agent', True) + check_bedrock_agent_runtime = params.get('check_bedrock_agent_runtime', True) + check_bedrock_runtime = params.get('check_bedrock_runtime', True) + + required_endpoints = [] + if check_bedrock: + required_endpoints.append('com.amazonaws.{region}.bedrock') + if check_bedrock_agent: + required_endpoints.append('com.amazonaws.{region}.bedrock-agent') + if check_bedrock_agent_runtime: + required_endpoints.append('com.amazonaws.{region}.bedrock-agent-runtime') + if check_bedrock_runtime: + required_endpoints.append('com.amazonaws.{region}.bedrock-runtime') + + # Get VPC endpoints + response = ec2_client.describe_vpc_endpoints(Filters=[{'Name': 'vpc-id', 'Values': [vpc_id]}]) + + existing_endpoints = [endpoint['ServiceName'] for endpoint in response['VpcEndpoints']] + + LOGGER.info(f"Checking VPC {vpc_id} for endpoints: {required_endpoints}") + LOGGER.debug(f"Existing endpoints: {existing_endpoints}") + + missing_endpoints = [endpoint for endpoint in required_endpoints if endpoint.format(region=configuration_item['awsRegion']) not in existing_endpoints] + + if missing_endpoints: + LOGGER.warning(f"Missing endpoints for VPC {vpc_id}: {missing_endpoints}") + return 'NON_COMPLIANT' + else: + LOGGER.info(f"All required endpoints are in place for VPC {vpc_id}") + return 'COMPLIANT' + +def lambda_handler(event, context): + LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.debug(f"Event: {json.dumps(event)}") + + invoking_event = json.loads(event['invokingEvent']) + rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} + + configuration_item = invoking_event.get('configurationItem') + if not configuration_item: + LOGGER.error("No configuration item found in the invoking event") + return + + compliance_type = evaluate_compliance(configuration_item, rule_parameters) + + config_client.put_evaluations( + Evaluations=[ + { + 'ComplianceResourceType': configuration_item['resourceType'], + 'ComplianceResourceId': configuration_item['resourceId'], + 'ComplianceType': compliance_type, + 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] + }, + ], + ResultToken=event['resultToken'] + ) + + LOGGER.info(f"Compliance evaluation complete. Result: {compliance_type}") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index c67e7727e..09c52d81e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -15,6 +15,8 @@ import sra_sns import sra_config +from typing import Dict, Any + # import sra_lambda # TODO(liamschn): Need to test with (and create) a CFN template @@ -31,12 +33,15 @@ log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) +def load_iam_policy_documents() -> Dict[str, Any]: + json_file_path = os.path.join(os.path.dirname(__file__), 'sra-config-lambda-iam-permissions.json') + with open(json_file_path, 'r') as file: + return json.load(file) + # Global vars -# STAGING_BUCKET: str = "" RESOURCE_TYPE: str = "" STATE_TABLE: str = "sra_state" SOLUTION_NAME: str = "sra-bedrock-org" -# SOLUTION_DIR: str = "bedrock_org" RULE_REGIONS_ACCOUNTS = {} GOVERNED_REGIONS = [] BEDROCK_MODEL_EVAL_BUCKET: str = "" @@ -54,52 +59,7 @@ # other global variables LIVE_RUN_DATA: dict = {} -IAM_POLICY_DOCUMENTS: dict = { - "sra-bedrock-check-iam-user-access": { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowReadIAM", - "Effect": "Allow", - "Action": ["iam:Get*", "iam:List*"], - "Resource": "*", - }, - ], - }, - "sra-bedrock-check-eval-job-bucket": { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowReadS3", - "Effect": "Allow", - "Action": [ - "s3:GetLifecycleConfiguration", - "s3:GetEncryptionConfiguration", - "s3:GetBucketLogging", - "s3:GetBucketObjectLockConfiguration", - "s3:GetBucketVersioning", - "s3:ListBucket", - "s3:ListAllMyBuckets", - ], - "Resource": "arn:aws:s3:::*", - }, - ], - }, - "sra-bedrock-check-guardrails": { - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "AllowReadBedrock", - "Effect": "Allow", - "Action": [ - "bedrock:ListGuardrails", - "bedrock:GetGuardrail" - ], - "Resource": "*", - }, - ], - }, -} +IAM_POLICY_DOCUMENTS: Dict[str, Any] = load_iam_policy_documents() # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json new file mode 100644 index 000000000..355e3b4eb --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json @@ -0,0 +1,54 @@ +{ + "sra-bedrock-check-iam-user-access": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadIAM", + "Effect": "Allow", + "Action": ["iam:Get*", "iam:List*"], + "Resource": "*" + } + ] + }, + "sra-bedrock-check-eval-job-bucket": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadS3", + "Effect": "Allow", + "Action": [ + "s3:GetLifecycleConfiguration", + "s3:GetEncryptionConfiguration", + "s3:GetBucketLogging", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketVersioning", + "s3:ListBucket", + "s3:ListAllMyBuckets" + ], + "Resource": "arn:aws:s3:::*" + } + ] + }, + "sra-bedrock-check-guardrails": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadBedrock", + "Effect": "Allow", + "Action": ["bedrock:ListGuardrails", "bedrock:GetGuardrail"], + "Resource": "*" + } + ] + }, + "sra-bedrock-check-vpc-endpoints": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadVPCEndpoints", + "Effect": "Allow", + "Action": ["ec2:DescribeVpcEndpoints"], + "Resource": "*" + } + ] + } +} diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 2b9f00c04..60f1651e1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -108,12 +108,28 @@ Parameters: ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), 'regions' (array of region names), and 'input_params' object with optional parameters: - 'check_safe_content', 'check_responsible_ai', 'check_data_privacy', 'check_content_filtering', 'check_token_limit'. + 'content_filters', 'denied_topics', 'word_filters', 'sensitive_info_filters', 'contextual_grounding'. Each parameter in 'input_params' should be either "true" or "false". Arrays can be empty. - Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_safe_content": "true", "check_responsible_ai": "false"}} or + Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "false"}} or {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} + pBedrockVPCEndpointsRuleParams: + Type: String + # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' + Description: Bedrock VPC Endpoints Config Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object with optional parameters: + 'check_bedrock', 'check_bedrock_agent', 'check_bedrock_agent_runtime', 'check_bedrock_runtime'. + Each parameter in 'input_params' should be either "true" or "false". + Arrays can be empty. + Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}} or + {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} + + Metadata: AWS::CloudFormation::Interface: ParameterGroups: @@ -149,6 +165,10 @@ Metadata: default: Bedrock Guardrails Rule Parameters: - pBedrockGuardrailsRuleParams + - Label: + default: Bedrock VPC Endpoints Rule + Parameters: + - pBedrockVPCEndpointsRuleParams ParameterLabels: pSraRepoZipUrl: @@ -177,6 +197,8 @@ Metadata: default: Bedrock IAM User Access Config Rule Parameters pBedrockGuardrailsRuleParams: default: Bedrock Guardrails Config Rule Parameters + pBedrockVPCEndpointsRuleParams: + default: Bedrock VPC Endpoints Config Rule Parameters Resources: @@ -232,6 +254,7 @@ Resources: SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET: !Ref pBedrockModelEvalBucketRuleParams SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams SRA-BEDROCK-CHECK-GUARDRAILS: !Ref pBedrockGuardrailsRuleParams + SRA-BEDROCK-CHECK-VPC-ENDPOINTS: !Ref pBedrockVPCEndpointsRuleParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 51728c5be63ca6107574e4e5e25b049ccce1e8d9 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 5 Sep 2024 12:53:56 -0600 Subject: [PATCH 061/395] minor update --- .../rules/sra_bedrock_check_vpc_endpoints/app.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py index fbf1b6577..c4eab205b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -5,10 +5,11 @@ import logging import ast -# Configure logging -LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO').upper() -LOGGER = logging.getLogger() -LOGGER.setLevel(LOG_LEVEL) +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") # Initialize AWS clients ec2_client = boto3.client('ec2') @@ -38,7 +39,7 @@ def evaluate_compliance(configuration_item, rule_parameters): required_endpoints.append('com.amazonaws.{region}.bedrock-agent-runtime') if check_bedrock_runtime: required_endpoints.append('com.amazonaws.{region}.bedrock-runtime') - + # Get VPC endpoints response = ec2_client.describe_vpc_endpoints(Filters=[{'Name': 'vpc-id', 'Values': [vpc_id]}]) From 0474223cbe80d31343d87d2d8f6992e8ae1c7bc6 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 5 Sep 2024 14:22:07 -0600 Subject: [PATCH 062/395] edits to code after testing; updating iam perms --- .../sra_bedrock_check_vpc_endpoints/app.py | 100 ++++++++++-------- .../sra-config-lambda-iam-permissions.json | 2 +- 2 files changed, 59 insertions(+), 43 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py index c4eab205b..97c46214b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -3,7 +3,6 @@ import json import os import logging -import ast # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -11,76 +10,93 @@ LOGGER.setLevel(log_level) LOGGER.info(f"boto3 version: {boto3.__version__}") +# Get AWS region from environment variable +AWS_REGION = os.environ.get('AWS_REGION') + # Initialize AWS clients -ec2_client = boto3.client('ec2') -config_client = boto3.client('config') +ec2_client = boto3.client('ec2', region_name=AWS_REGION) +config_client = boto3.client('config', region_name=AWS_REGION) -def evaluate_compliance(configuration_item, rule_parameters): +def evaluate_compliance(vpc_id, rule_parameters): """Evaluates if the required VPC endpoints are in place""" - if configuration_item['resourceType'] != 'AWS::EC2::VPC': - return 'NOT_APPLICABLE' - - vpc_id = configuration_item['configuration']['vpcId'] - # Parse rule parameters - params = ast.literal_eval(json.dumps(rule_parameters)) if rule_parameters else {} - check_bedrock = params.get('check_bedrock', True) - check_bedrock_agent = params.get('check_bedrock_agent', True) - check_bedrock_agent_runtime = params.get('check_bedrock_agent_runtime', True) - check_bedrock_runtime = params.get('check_bedrock_runtime', True) + params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} + check_bedrock = params.get('check_bedrock', 'true').lower() == 'true' + check_bedrock_agent = params.get('check_bedrock_agent', 'true').lower() == 'true' + check_bedrock_agent_runtime = params.get('check_bedrock_agent_runtime', 'true').lower() == 'true' + check_bedrock_runtime = params.get('check_bedrock_runtime', 'true').lower() == 'true' required_endpoints = [] if check_bedrock: - required_endpoints.append('com.amazonaws.{region}.bedrock') + required_endpoints.append(f'com.amazonaws.{AWS_REGION}.bedrock') if check_bedrock_agent: - required_endpoints.append('com.amazonaws.{region}.bedrock-agent') + required_endpoints.append(f'com.amazonaws.{AWS_REGION}.bedrock-agent') if check_bedrock_agent_runtime: - required_endpoints.append('com.amazonaws.{region}.bedrock-agent-runtime') + required_endpoints.append(f'com.amazonaws.{AWS_REGION}.bedrock-agent-runtime') if check_bedrock_runtime: - required_endpoints.append('com.amazonaws.{region}.bedrock-runtime') - + required_endpoints.append(f'com.amazonaws.{AWS_REGION}.bedrock-runtime') + # Get VPC endpoints response = ec2_client.describe_vpc_endpoints(Filters=[{'Name': 'vpc-id', 'Values': [vpc_id]}]) existing_endpoints = [endpoint['ServiceName'] for endpoint in response['VpcEndpoints']] LOGGER.info(f"Checking VPC {vpc_id} for endpoints: {required_endpoints}") - LOGGER.debug(f"Existing endpoints: {existing_endpoints}") + LOGGER.info(f"Existing endpoints: {existing_endpoints}") - missing_endpoints = [endpoint for endpoint in required_endpoints if endpoint.format(region=configuration_item['awsRegion']) not in existing_endpoints] + missing_endpoints = [endpoint for endpoint in required_endpoints if endpoint not in existing_endpoints] if missing_endpoints: - LOGGER.warning(f"Missing endpoints for VPC {vpc_id}: {missing_endpoints}") - return 'NON_COMPLIANT' + LOGGER.info(f"Missing endpoints for VPC {vpc_id}: {missing_endpoints}") + return 'NON_COMPLIANT', f"VPC {vpc_id} is missing the following Bedrock endpoints: {', '.join(missing_endpoints)}" else: LOGGER.info(f"All required endpoints are in place for VPC {vpc_id}") - return 'COMPLIANT' + return 'COMPLIANT', f"VPC {vpc_id} has all required Bedrock endpoints: {', '.join(required_endpoints)}" def lambda_handler(event, context): LOGGER.info('Evaluating compliance for AWS Config rule') - LOGGER.debug(f"Event: {json.dumps(event)}") + LOGGER.info(f"Event: {json.dumps(event)}") invoking_event = json.loads(event['invokingEvent']) rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} - configuration_item = invoking_event.get('configurationItem') - if not configuration_item: - LOGGER.error("No configuration item found in the invoking event") - return + if invoking_event['messageType'] == 'ScheduledNotification': + # This is a scheduled run, evaluate all VPCs + evaluations = [] + vpcs = ec2_client.describe_vpcs() + for vpc in vpcs['Vpcs']: + vpc_id = vpc['VpcId'] + compliance_type, annotation = evaluate_compliance(vpc_id, rule_parameters) + evaluations.append({ + 'ComplianceResourceType': 'AWS::EC2::VPC', + 'ComplianceResourceId': vpc_id, + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': invoking_event['notificationCreationTime'] + }) + else: + # This is a configuration change event + configuration_item = invoking_event['configurationItem'] + if configuration_item['resourceType'] != 'AWS::EC2::VPC': + LOGGER.info(f"Skipping non-VPC resource: {configuration_item['resourceType']}") + return - compliance_type = evaluate_compliance(configuration_item, rule_parameters) + vpc_id = configuration_item['resourceId'] + compliance_type, annotation = evaluate_compliance(vpc_id, rule_parameters) + evaluations = [{ + 'ComplianceResourceType': configuration_item['resourceType'], + 'ComplianceResourceId': vpc_id, + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] + }] - config_client.put_evaluations( - Evaluations=[ - { - 'ComplianceResourceType': configuration_item['resourceType'], - 'ComplianceResourceId': configuration_item['resourceId'], - 'ComplianceType': compliance_type, - 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] - }, - ], - ResultToken=event['resultToken'] - ) + # Submit compliance evaluations + if evaluations: + config_client.put_evaluations( + Evaluations=evaluations, + ResultToken=event['resultToken'] + ) - LOGGER.info(f"Compliance evaluation complete. Result: {compliance_type}") \ No newline at end of file + LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json index 355e3b4eb..bd0a77a0d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json @@ -46,7 +46,7 @@ { "Sid": "AllowReadVPCEndpoints", "Effect": "Allow", - "Action": ["ec2:DescribeVpcEndpoints"], + "Action": ["ec2:DescribeVpcEndpoints", "ec2:DescribeVpcs"], "Resource": "*" } ] From 4a503f1c200ee332bb21a15c2a91db64d9b561f5 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 5 Sep 2024 17:18:04 -0600 Subject: [PATCH 063/395] add invocation logging rule --- .../app.py | 86 +++++++++++++++++++ .../sra-config-lambda-iam-permissions.json | 11 +++ .../templates/sra-bedrock-org-main.yaml | 24 +++++- 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_logging/app.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_logging/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_logging/app.py new file mode 100644 index 000000000..b12f2f37a --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_logging/app.py @@ -0,0 +1,86 @@ +import boto3 +import json +import os +import logging + +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") + +# Get AWS region from environment variable +AWS_REGION = os.environ.get('AWS_REGION') + +# Initialize AWS clients +bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) +config_client = boto3.client('config', region_name=AWS_REGION) + +def evaluate_compliance(rule_parameters): + """Evaluates if Bedrock Model Invocation Logging is properly configured""" + + # Parse rule parameters + params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} + check_cloudwatch = params.get('check_cloudwatch', 'true').lower() == 'true' + check_s3 = params.get('check_s3', 'true').lower() == 'true' + + try: + response = bedrock_client.get_model_invocation_logging_configuration() + logging_config = response.get('loggingConfig', {}) + + cloudwatch_enabled = logging_config.get('cloudWatchConfig', {}).get('enabled', False) + s3_enabled = logging_config.get('s3Config', {}).get('enabled', False) + + cloudwatch_log_group = logging_config.get('cloudWatchConfig', {}).get('logGroupName', 'Not configured') + s3_bucket = logging_config.get('s3Config', {}).get('s3BucketName', 'Not configured') + + missing_configs = [] + enabled_configs = [] + + if check_cloudwatch and not cloudwatch_enabled: + missing_configs.append('CloudWatch') + elif check_cloudwatch: + enabled_configs.append(f'CloudWatch (Log Group: {cloudwatch_log_group})') + + if check_s3 and not s3_enabled: + missing_configs.append('S3') + elif check_s3: + enabled_configs.append(f'S3 (Bucket: {s3_bucket})') + + if missing_configs: + return 'NON_COMPLIANT', f"Bedrock Model Invocation Logging is not configured for: {', '.join(missing_configs)}. " \ + f"Enabled configurations: {', '.join(enabled_configs) if enabled_configs else 'None'}" + else: + return 'COMPLIANT', f"Bedrock Model Invocation Logging is properly configured. " \ + f"Enabled configurations: {', '.join(enabled_configs)}" + + except Exception as e: + LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") + return 'ERROR', f"Error evaluating compliance: {str(e)}" + +def lambda_handler(event, context): + LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info(f"Event: {json.dumps(event)}") + + invoking_event = json.loads(event['invokingEvent']) + rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} + + compliance_type, annotation = evaluate_compliance(rule_parameters) + + evaluation = { + 'ComplianceResourceType': 'AWS::::Account', + 'ComplianceResourceId': event['accountId'], + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': invoking_event['notificationCreationTime'] + } + + LOGGER.info(f"Compliance evaluation result: {compliance_type}") + LOGGER.info(f"Annotation: {annotation}") + + config_client.put_evaluations( + Evaluations=[evaluation], + ResultToken=event['resultToken'] + ) + + LOGGER.info("Compliance evaluation complete.") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json index bd0a77a0d..5f373be43 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json @@ -50,5 +50,16 @@ "Resource": "*" } ] + }, + "sra-bedrock-check-invocation-logging": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowReadVPCEndpoints", + "Effect": "Allow", + "Action": ["bedrock:GetModelInvocationLoggingConfiguration"], + "Resource": "*" + } + ] } } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 60f1651e1..49eff3475 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -129,6 +129,21 @@ Parameters: Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}} or {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} + pBedrockInvocationLoggingRuleParams: + Type: String + # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_cloudwatch": "true", "check_s3": "true"}}' + Description: Bedrock VPC Endpoints Config Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_cloudwatch"\s*:\s*"(true|false)")?(\s*,\s*"check_s3"\s*:\s*"(true|false)")?\}\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object with optional parameters: + 'check_cloudwatch', 'check_s3'. + Each parameter in 'input_params' should be either "true" or "false". + Arrays can be empty. + Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_cloudwatch": "true", "check_s3": "true"}} or + {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} + Metadata: AWS::CloudFormation::Interface: @@ -138,7 +153,7 @@ Metadata: Parameters: - pSraRepoZipUrl - pDryRun - - pRuleRegionsAccounts + # - pRuleRegionsAccounts - pSRASolutionName - pSraSolutionVersion - pSRAStagingS3BucketName @@ -169,6 +184,10 @@ Metadata: default: Bedrock VPC Endpoints Rule Parameters: - pBedrockVPCEndpointsRuleParams + - Label: + default: Bedrock Invocation Logging Rule + Parameters: + - pBedrockInvocationLoggingRuleParams ParameterLabels: pSraRepoZipUrl: @@ -199,9 +218,10 @@ Metadata: default: Bedrock Guardrails Config Rule Parameters pBedrockVPCEndpointsRuleParams: default: Bedrock VPC Endpoints Config Rule Parameters + pBedrockInvocationLoggingRuleParams: + default: Bedrock Invocation Logging Config Rule Parameters Resources: - rBedrockOrgLambdaRole: Type: AWS::IAM::Role Properties: From 91a6dfcb597105b8e4c498d3c7df18e5c26e9d6c Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 5 Sep 2024 17:50:05 -0600 Subject: [PATCH 064/395] minor update --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 1 + .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 09c52d81e..dad90d753 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -302,6 +302,7 @@ def update_event(event, context): def delete_event(event, context): + # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. global DRY_RUN_DATA global LIVE_RUN_DATA DRY_RUN_DATA = {} diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 49eff3475..178ce6673 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -275,6 +275,7 @@ Resources: SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams SRA-BEDROCK-CHECK-GUARDRAILS: !Ref pBedrockGuardrailsRuleParams SRA-BEDROCK-CHECK-VPC-ENDPOINTS: !Ref pBedrockVPCEndpointsRuleParams + SRA-BEDROCK-CHECK-INVOCATION-LOGGING: !Ref pBedrockInvocationLoggingRuleParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 869b4e4450ad45244b630b10c7b94a76da3b51ce Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 6 Sep 2024 14:31:35 -0600 Subject: [PATCH 065/395] split invoc log check --- .../app.py | 53 +++++---- .../app.py | 104 ++++++++++++++++++ .../sra-config-lambda-iam-permissions.json | 33 +++++- .../templates/sra-bedrock-org-main.yaml | 51 ++++++--- 4 files changed, 198 insertions(+), 43 deletions(-) rename aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/{sra_bedrock_check_invocation_logging => sra_bedrock_check_invocation_log_cloudwatch}/app.py (58%) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_logging/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py similarity index 58% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_logging/app.py rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index b12f2f37a..490ff9430 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_logging/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -15,44 +15,43 @@ # Initialize AWS clients bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) +logs_client = boto3.client('logs', region_name=AWS_REGION) def evaluate_compliance(rule_parameters): - """Evaluates if Bedrock Model Invocation Logging is properly configured""" + """Evaluates if Bedrock Model Invocation Logging is properly configured for CloudWatch""" # Parse rule parameters params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} - check_cloudwatch = params.get('check_cloudwatch', 'true').lower() == 'true' - check_s3 = params.get('check_s3', 'true').lower() == 'true' + check_retention = params.get('check_retention', 'true').lower() == 'true' + check_encryption = params.get('check_encryption', 'true').lower() == 'true' try: response = bedrock_client.get_model_invocation_logging_configuration() logging_config = response.get('loggingConfig', {}) - cloudwatch_enabled = logging_config.get('cloudWatchConfig', {}).get('enabled', False) - s3_enabled = logging_config.get('s3Config', {}).get('enabled', False) - - cloudwatch_log_group = logging_config.get('cloudWatchConfig', {}).get('logGroupName', 'Not configured') - s3_bucket = logging_config.get('s3Config', {}).get('s3BucketName', 'Not configured') - - missing_configs = [] - enabled_configs = [] - - if check_cloudwatch and not cloudwatch_enabled: - missing_configs.append('CloudWatch') - elif check_cloudwatch: - enabled_configs.append(f'CloudWatch (Log Group: {cloudwatch_log_group})') - - if check_s3 and not s3_enabled: - missing_configs.append('S3') - elif check_s3: - enabled_configs.append(f'S3 (Bucket: {s3_bucket})') - - if missing_configs: - return 'NON_COMPLIANT', f"Bedrock Model Invocation Logging is not configured for: {', '.join(missing_configs)}. " \ - f"Enabled configurations: {', '.join(enabled_configs) if enabled_configs else 'None'}" + cloudwatch_config = logging_config.get('cloudWatchConfig', {}) + cloudwatch_enabled = cloudwatch_config.get('enabled', False) + log_group_name = cloudwatch_config.get('logGroupName') + + if not cloudwatch_enabled or not log_group_name: + return 'NON_COMPLIANT', "CloudWatch logging is not enabled for Bedrock Model Invocation Logging" + + # Check retention and encryption if enabled + issues = [] + if check_retention: + retention = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)['logGroups'][0].get('retentionInDays') + if not retention: + issues.append("retention not set") + + if check_encryption: + encryption = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)['logGroups'][0].get('kmsKeyId') + if not encryption: + issues.append("encryption not set") + + if issues: + return 'NON_COMPLIANT', f"CloudWatch logging enabled but {', '.join(issues)}" else: - return 'COMPLIANT', f"Bedrock Model Invocation Logging is properly configured. " \ - f"Enabled configurations: {', '.join(enabled_configs)}" + return 'COMPLIANT', f"CloudWatch logging properly configured for Bedrock Model Invocation Logging. Log Group: {log_group_name}" except Exception as e: LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py new file mode 100644 index 000000000..0fdee19a0 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -0,0 +1,104 @@ +import boto3 +import json +import os +import logging + +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") + +# Get AWS region from environment variable +AWS_REGION = os.environ.get('AWS_REGION') + +# Initialize AWS clients +bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) +config_client = boto3.client('config', region_name=AWS_REGION) +s3_client = boto3.client('s3', region_name=AWS_REGION) + +def evaluate_compliance(rule_parameters): + """Evaluates if Bedrock Model Invocation Logging is properly configured for S3""" + + # Parse rule parameters + params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} + check_retention = params.get('check_retention', 'true').lower() == 'true' + check_encryption = params.get('check_encryption', 'true').lower() == 'true' + check_access_logging = params.get('check_access_logging', 'true').lower() == 'true' + check_object_locking = params.get('check_object_locking', 'true').lower() == 'true' + check_versioning = params.get('check_versioning', 'true').lower() == 'true' + + try: + response = bedrock_client.get_model_invocation_logging_configuration() + logging_config = response.get('loggingConfig', {}) + + s3_config = logging_config.get('s3Config', {}) + s3_enabled = s3_config.get('enabled', False) + bucket_name = s3_config.get('s3BucketName') + + if not s3_enabled or not bucket_name: + return 'NON_COMPLIANT', "S3 logging is not enabled for Bedrock Model Invocation Logging" + + # Check S3 bucket configurations + issues = [] + + if check_retention: + lifecycle = s3_client.get_bucket_lifecycle_configuration(Bucket=bucket_name) + if not any(rule.get('Expiration') for rule in lifecycle.get('Rules', [])): + issues.append("retention not set") + + if check_encryption: + encryption = s3_client.get_bucket_encryption(Bucket=bucket_name) + if 'ServerSideEncryptionConfiguration' not in encryption: + issues.append("encryption not set") + + if check_access_logging: + logging = s3_client.get_bucket_logging(Bucket=bucket_name) + if 'LoggingEnabled' not in logging: + issues.append("server access logging not enabled") + + if check_object_locking: + object_lock = s3_client.get_object_lock_configuration(Bucket=bucket_name) + if 'ObjectLockConfiguration' not in object_lock: + issues.append("object locking not enabled") + + if check_versioning: + versioning = s3_client.get_bucket_versioning(Bucket=bucket_name) + if versioning.get('Status') != 'Enabled': + issues.append("versioning not enabled") + + if issues: + return 'NON_COMPLIANT', f"S3 logging enabled but {', '.join(issues)}" + else: + return 'COMPLIANT', f"S3 logging properly configured for Bedrock Model Invocation Logging. Bucket: {bucket_name}" + + except Exception as e: + LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") + return 'ERROR', f"Error evaluating compliance: {str(e)}" + +def lambda_handler(event, context): + LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info(f"Event: {json.dumps(event)}") + + invoking_event = json.loads(event['invokingEvent']) + rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} + + compliance_type, annotation = evaluate_compliance(rule_parameters) + + evaluation = { + 'ComplianceResourceType': 'AWS::::Account', + 'ComplianceResourceId': event['accountId'], + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': invoking_event['notificationCreationTime'] + } + + LOGGER.info(f"Compliance evaluation result: {compliance_type}") + LOGGER.info(f"Annotation: {annotation}") + + config_client.put_evaluations( + Evaluations=[evaluation], + ResultToken=event['resultToken'] + ) + + LOGGER.info("Compliance evaluation complete.") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json index 5f373be43..82a7177bf 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json @@ -51,14 +51,43 @@ } ] }, - "sra-bedrock-check-invocation-logging": { + "sra_bedrock_check_invocation_log_cloudwatch": { "Version": "2012-10-17", "Statement": [ { - "Sid": "AllowReadVPCEndpoints", + "Sid": "AllowGetInvocLogConf", + "Effect": "Allow", + "Action": ["bedrock:GetModelInvocationLoggingConfiguration"], + "Resource": "*" + }, + { + "Sid": "AllowDescribeLogGroup", + "Effect": "Allow", + "Action": ["logs:DescribeLogGroups"], + "Resource": "arn:aws:logs:*:*:log-group:*" + } + ] + }, + "sra_bedrock_check_invocation_log_s3": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowGetInvocLog", "Effect": "Allow", "Action": ["bedrock:GetModelInvocationLoggingConfiguration"], "Resource": "*" + }, + { + "Sid": "AllowGetBucketConf", + "Effect": "Allow", + "Action": [ + "s3:GetBucketLifecycleConfiguration", + "s3:GetBucketEncryption", + "s3:GetBucketLogging", + "s3:GetObjectLockConfiguration", + "s3:GetBucketVersioning" + ], + "Resource": "arn:aws:s3:::*" } ] } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 178ce6673..2c8a66d06 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -101,7 +101,7 @@ Parameters: pBedrockGuardrailsRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production + # TODO(liamschn): update default value of pBedrockGuardrailsRuleParams prior to production Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' Description: Bedrock Guardrails Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$ @@ -116,7 +116,7 @@ Parameters: pBedrockVPCEndpointsRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production + # TODO(liamschn): update default value of pBedrockVPCEndpointsRuleParams prior to production Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' Description: Bedrock VPC Endpoints Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$ @@ -129,22 +129,38 @@ Parameters: Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}} or {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} - pBedrockInvocationLoggingRuleParams: + pBedrockInvocationLogCWRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_cloudwatch": "true", "check_s3": "true"}}' - Description: Bedrock VPC Endpoints Config Rule Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_cloudwatch"\s*:\s*"(true|false)")?(\s*,\s*"check_s3"\s*:\s*"(true|false)")?\}\}$ + # TODO(liamschn): update default value of pBedrockInvocationLogCWRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' + Description: Bedrock Model Invocation Logging to CloudWatch Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$ ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), 'regions' (array of region names), and 'input_params' object with optional parameters: - 'check_cloudwatch', 'check_s3'. + 'check_retention', 'check_encryption'. Each parameter in 'input_params' should be either "true" or "false". Arrays can be empty. - Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_cloudwatch": "true", "check_s3": "true"}} or + Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_retention": "true", "check_encryption": "true"}} or + {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} + + pBedrockInvocationLogS3RuleParams: + Type: String + # TODO(liamschn): update default value of pBedrockInvocationLogS3RuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' + Description: Bedrock Model Invocation Logging to S3 Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object with optional parameters: + 'check_retention', 'check_encryption', 'check_access_logging', 'check_object_locking', 'check_versioning'. + Each parameter in 'input_params' should be either "true" or "false". + Arrays can be empty. + Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "false"}} or {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} + Metadata: AWS::CloudFormation::Interface: ParameterGroups: @@ -185,9 +201,13 @@ Metadata: Parameters: - pBedrockVPCEndpointsRuleParams - Label: - default: Bedrock Invocation Logging Rule + default: Bedrock Model Invocation Logging to CloudWatch Rule + Parameters: + - pBedrockInvocationLogCWRuleParams + - Label: + default: Bedrock Model Invocation Logging to S3 Rule Parameters: - - pBedrockInvocationLoggingRuleParams + - pBedrockInvocationLogS3RuleParams ParameterLabels: pSraRepoZipUrl: @@ -218,8 +238,10 @@ Metadata: default: Bedrock Guardrails Config Rule Parameters pBedrockVPCEndpointsRuleParams: default: Bedrock VPC Endpoints Config Rule Parameters - pBedrockInvocationLoggingRuleParams: - default: Bedrock Invocation Logging Config Rule Parameters + pBedrockInvocationLogCWRuleParams: + default: Bedrock Model Invocation Logging to CloudWatch Config Rule Parameters + pBedrockInvocationLogS3RuleParams: + default: Bedrock Model Invocation Logging to S3 Config Rule Parameters Resources: rBedrockOrgLambdaRole: @@ -275,7 +297,8 @@ Resources: SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams SRA-BEDROCK-CHECK-GUARDRAILS: !Ref pBedrockGuardrailsRuleParams SRA-BEDROCK-CHECK-VPC-ENDPOINTS: !Ref pBedrockVPCEndpointsRuleParams - SRA-BEDROCK-CHECK-INVOCATION-LOGGING: !Ref pBedrockInvocationLoggingRuleParams + SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH: !Ref pBedrockInvocationLogCWRuleParams + SRA-BEDROCK-CHECK-INVOCATION-LOG-S3: !Ref pBedrockInvocationLogS3RuleParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 075ce2e0a14c54d6b86d1e55105f1b61c22942cc Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 6 Sep 2024 15:17:37 -0600 Subject: [PATCH 066/395] hyphens --- .../lambda/src/sra-config-lambda-iam-permissions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json index 82a7177bf..42027fb26 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json @@ -51,7 +51,7 @@ } ] }, - "sra_bedrock_check_invocation_log_cloudwatch": { + "sra-bedrock-check-invocation-log-cloudwatch": { "Version": "2012-10-17", "Statement": [ { @@ -68,7 +68,7 @@ } ] }, - "sra_bedrock_check_invocation_log_s3": { + "sra-bedrock-check-invocation-log-s3": { "Version": "2012-10-17", "Statement": [ { From ccc9be0995656e42d5970e5558756967072e7c3b Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 12:01:19 -0600 Subject: [PATCH 067/395] cw endpoints rule --- .../app.py | 0 .../src/sra-config-lambda-iam-permissions.json | 11 +++++++++++ .../templates/sra-bedrock-org-main.yaml | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloutwatch_endpoints/app.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloutwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloutwatch_endpoints/app.py new file mode 100644 index 000000000..e69de29bb diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json index 42027fb26..d2d19e49e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json @@ -90,5 +90,16 @@ "Resource": "arn:aws:s3:::*" } ] + }, + "sra-bedrock-check-cloudwatch-endpoints": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowDescribeEndpoints", + "Effect": "Allow", + "Action": ["ec2:DescribeVpcEndpoints", "ec2:DescribeVpcs"], + "Resource": "*" + } + ] } } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 2c8a66d06..53aca5b45 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -159,6 +159,17 @@ Parameters: Example: {"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "false"}} or {"deploy": "false", "accounts": [], "regions": [], "input_params": {}} + pBedrockCWEndpointsRuleParams: + Type: String + # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' + Description: Bedrock CloudWatch VPC Endpoint Config Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ + ConstraintDescription: + "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object/dict (input params must be empty). Arrays can be empty. + Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or + {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" Metadata: @@ -208,6 +219,10 @@ Metadata: default: Bedrock Model Invocation Logging to S3 Rule Parameters: - pBedrockInvocationLogS3RuleParams + - Label: + default: Bedrock CloudWatch VPC Endpoint Rule + Parameters: + - pBedrockCWEndpointsRuleParams ParameterLabels: pSraRepoZipUrl: @@ -242,6 +257,8 @@ Metadata: default: Bedrock Model Invocation Logging to CloudWatch Config Rule Parameters pBedrockInvocationLogS3RuleParams: default: Bedrock Model Invocation Logging to S3 Config Rule Parameters + pBedrockCWEndpointsRuleParams: + default: Bedrock CloudWatch VPC Endpoint Config Rule Parameters Resources: rBedrockOrgLambdaRole: @@ -299,6 +316,7 @@ Resources: SRA-BEDROCK-CHECK-VPC-ENDPOINTS: !Ref pBedrockVPCEndpointsRuleParams SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH: !Ref pBedrockInvocationLogCWRuleParams SRA-BEDROCK-CHECK-INVOCATION-LOG-S3: !Ref pBedrockInvocationLogS3RuleParams + SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS: !Ref pBedrockCWEndpointsRuleParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From dfec93c8106f5458dc0e8b62a2867068f0ba8e9a Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 12:55:34 -0600 Subject: [PATCH 068/395] updating dir name --- .../app.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/{sra_bedrock_check_cloutwatch_endpoints => sra_bedrock_check_cloudwatch_endpoints}/app.py (100%) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloutwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py similarity index 100% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloutwatch_endpoints/app.py rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py From a07926874278a36481cdbf6ad21a691761ba9b22 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 14:57:47 -0600 Subject: [PATCH 069/395] debug tracing --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 ++ .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 2 +- .../solutions/genai/bedrock_org/lambda/src/sra_repo.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index dad90d753..c92489d1f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -24,6 +24,7 @@ # TODO(liamschn): Where do we see dry-run data? Maybe S3 staging bucket file? The sra_state table? Another DynamoDB table? # TODO(liamschn): add parameter validation + from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional if TYPE_CHECKING: @@ -33,6 +34,7 @@ log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) +# TODO(liamschn): change this so that it downloads the sra-config-lambda-iam-permissions.json from the repo then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) def load_iam_policy_documents() -> Dict[str, Any]: json_file_path = os.path.join(os.path.dirname(__file__), 'sra-config-lambda-iam-permissions.json') with open(json_file_path, 'r') as file: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 754037075..42a8b2542 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -99,7 +99,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler self.LOGGER.info(f"Error deploying Lambda function: {e}") break elif error.response["Error"]["Code"] == "InvalidParameterValueException": - self.LOGGER.info(f"Lambda cannot assume role yet. Retrying...") + self.LOGGER.info(f"Lambda not ready to deploy yet. {e}; Retrying...") # TODO(liamschn): need to add a maximum retry mechanism here retries += 1 sleep(5) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 7760b360e..dd91f1973 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -220,6 +220,7 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f ) self.zip_folder(f"{config_rule_staging_folder_path}", zip_file) zip_file.close() + self.LOGGER.info(f"{lambda_target_folder}{config_rule_upload_folder_name}.zip file size is {os.path.getsize(f'{lambda_target_folder}{config_rule_upload_folder_name}.zip')}") # debug stuff: else: self.LOGGER.info(f"{os.path.join(service_dir, solution, 'rules')} does not exist!") From f7f84fa1cfd4bde0e5fb4c46faace45593fa817d Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 15:27:17 -0600 Subject: [PATCH 070/395] more trace logging --- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 42a8b2542..507890222 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -71,6 +71,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler self.LOGGER.info(f"Role ARN passed to create_lambda_function: {role_arn}...") max_retries = 10 retries = 0 + self.LOGGER.info(f"Size of {code_zip_file} is {os.path.getsize(code_zip_file)} bytes") while retries < max_retries: try: create_response = self.LAMBDA_CLIENT.create_function( From 4a7ca1eb27c70bc87c419d0784a95514a3a9aa20 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 15:38:18 -0600 Subject: [PATCH 071/395] updating tracing --- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 507890222..382b21363 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -100,7 +100,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler self.LOGGER.info(f"Error deploying Lambda function: {e}") break elif error.response["Error"]["Code"] == "InvalidParameterValueException": - self.LOGGER.info(f"Lambda not ready to deploy yet. {e}; Retrying...") + self.LOGGER.info(f"Lambda not ready to deploy yet. {error}; Retrying...") # TODO(liamschn): need to add a maximum retry mechanism here retries += 1 sleep(5) From 7eb286f85fc8fd94936be688f20af14e125233a4 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 15:42:16 -0600 Subject: [PATCH 072/395] updated app.py --- .../app.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py index e69de29bb..a7dfa2ff3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py @@ -0,0 +1,86 @@ +import boto3 +import json +import os +import logging + +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") + +# Get AWS region from environment variable +AWS_REGION = os.environ.get('AWS_REGION') + +# Initialize AWS clients +ec2_client = boto3.client('ec2', region_name=AWS_REGION) +config_client = boto3.client('config', region_name=AWS_REGION) + +def evaluate_compliance(vpc_id): + """Evaluates if a CloudWatch gateway endpoint is in place for the given VPC""" + try: + response = ec2_client.describe_vpc_endpoints( + Filters=[ + {'Name': 'vpc-id', 'Values': [vpc_id]}, + {'Name': 'service-name', 'Values': [f'com.amazonaws.{AWS_REGION}.logs']} + ] + ) + + endpoints = response['VpcEndpoints'] + + if endpoints: + endpoint_id = endpoints[0]['VpcEndpointId'] + return 'COMPLIANT', f"CloudWatch gateway endpoint is in place for VPC {vpc_id}. Endpoint ID: {endpoint_id}" + else: + return 'NON_COMPLIANT', f"No CloudWatch gateway endpoint found for VPC {vpc_id}" + + except Exception as e: + LOGGER.error(f"Error evaluating CloudWatch gateway endpoint for VPC {vpc_id}: {str(e)}") + return 'ERROR', f"Error evaluating compliance: {str(e)}" + +def lambda_handler(event, context): + LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info(f"Event: {json.dumps(event)}") + + invoking_event = json.loads(event['invokingEvent']) + + evaluations = [] + + if invoking_event['messageType'] == 'ScheduledNotification': + # This is a scheduled run, evaluate all VPCs + vpcs = ec2_client.describe_vpcs() + for vpc in vpcs['Vpcs']: + vpc_id = vpc['VpcId'] + compliance_type, annotation = evaluate_compliance(vpc_id) + evaluations.append({ + 'ComplianceResourceType': 'AWS::EC2::VPC', + 'ComplianceResourceId': vpc_id, + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': invoking_event['notificationCreationTime'] + }) + else: + # This is a configuration change event + configuration_item = invoking_event['configurationItem'] + if configuration_item['resourceType'] != 'AWS::EC2::VPC': + LOGGER.info(f"Skipping non-VPC resource: {configuration_item['resourceType']}") + return + + vpc_id = configuration_item['resourceId'] + compliance_type, annotation = evaluate_compliance(vpc_id) + evaluations.append({ + 'ComplianceResourceType': configuration_item['resourceType'], + 'ComplianceResourceId': vpc_id, + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] + }) + + # Submit compliance evaluations + if evaluations: + config_client.put_evaluations( + Evaluations=evaluations, + ResultToken=event['resultToken'] + ) + + LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") \ No newline at end of file From f075b1cadf2ee75f1397e60b798ef0cd2bd3f61d Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 21:19:42 -0600 Subject: [PATCH 073/395] fix cfn response too large --- .../genai/bedrock_org/lambda/src/app.py | 127 ++++++++++++------ 1 file changed, 84 insertions(+), 43 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index c92489d1f..eeb841dcc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -55,14 +55,33 @@ def load_iam_policy_documents() -> Dict[str, Any]: REGION: str = os.environ.get("AWS_REGION") CFN_RESOURCE_ID: str = "sra-bedrock-org-function" +# CFN_RESPONSE_DATA definition: +# dry_run: bool - type of run +# deployment_info: dict - information about the deployment +# action_count: int - number of actions taken +# resources_deployed: int - number of resources deployed +# configuration_changes: int - number of configuration changes +CFN_RESPONSE_DATA: dict = { + "dry_run": True, + "deployment_info": { + "action_count": 0, + "resources_deployed": 0, + "configuration_changes": 0 + } + } +# TODO(liamschn): Consider adding "regions_targeted": int and "accounts_targeted": in to "deployment_info" of CFN_RESPONSE_DATA + + # dry run global variables DRY_RUN: bool = True DRY_RUN_DATA: dict = {} # other global variables +# TODO(liamschn): Urgent - cannot use these for CFN responses. Max size is 4096 bytes and this gets too large for this. Must change this ASAP (highest priority) LIVE_RUN_DATA: dict = {} IAM_POLICY_DOCUMENTS: Dict[str, Any] = load_iam_policy_documents() + # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? ssm_params = sra_ssm_params.sra_ssm_params() @@ -81,6 +100,7 @@ def get_resource_parameters(event): global RULE_REGIONS_ACCOUNTS global GOVERNED_REGIONS global BEDROCK_MODEL_EVAL_BUCKET + global CFN_RESPONSE_DATA LOGGER.info("Getting resource params...") # TODO(liamschn): what parameters do we need for this solution? @@ -100,10 +120,10 @@ def get_resource_parameters(event): else: LOGGER.info("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") raise ValueError("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") from None - # TODO(liamschn): continue working on getting this parameter. see test_even_bedrock_org.txt (or lambda) for test event; need to test in CFN too + # TODO(liamschn): remove the RULE_REGIONS_ACCOUNTS parameter after confirming it is no longer used. if "RULE_REGIONS_ACCOUNTS" in event["ResourceProperties"]: RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'", '"')) - + # TODO(liamschn): remove the BEDROCK_MODEL_EVAL_BUCKET parameter after confirming it is no longer used. if "BEDROCK_MODEL_EVAL_BUCKET" in event["ResourceProperties"]: BEDROCK_MODEL_EVAL_BUCKET = event["ResourceProperties"]["BEDROCK_MODEL_EVAL_BUCKET"] @@ -115,6 +135,7 @@ def get_resource_parameters(event): # live run LOGGER.info("Dry run disabled...") DRY_RUN = False + CFN_RESPONSE_DATA["dry_run"] = DRY_RUN def get_rule_params(rule_name, event): @@ -178,6 +199,7 @@ def get_rule_params(rule_name, event): def create_event(event, context): global DRY_RUN_DATA global LIVE_RUN_DATA + global CFN_RESPONSE_DATA DRY_RUN_DATA = {} LIVE_RUN_DATA = {} @@ -188,11 +210,14 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info("Live run: downloading and staging the config rule code...") repo.download_code_library(repo.REPO_ZIP_URL) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 LIVE_RUN_DATA["CodeDownload"] = "Downloaded code library" repo.prepare_config_rules_for_staging(repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 LIVE_RUN_DATA["CodePrep"] = "Prepared config rule code for staging" s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET, "/") LIVE_RUN_DATA["CodeStaging"] = "Staged config rule code to staging s3 bucket" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 else: LOGGER.info(f"DRY_RUN: Downloading code library from {repo.REPO_ZIP_URL}") LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") @@ -206,13 +231,22 @@ def create_event(event, context): LOGGER.info(f"Creating {SOLUTION_NAME}-configuration SNS topic") topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-configuration SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") # TODO(liamschn): search for permissions on lambda before adding the policy lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) LIVE_RUN_DATA["SNSPermissions"] = "Added lambda sns-invoke permissions for SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Subscribing {context.invoked_function_arn} to {topic_arn}") sns.create_sns_subscription(topic_arn, "lambda", context.invoked_function_arn) LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + else: LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-configuration SNS topic" @@ -233,21 +267,6 @@ def create_event(event, context): if rule_deploy is False: continue - # return {"statusCode": 400, "body": f"{rule_name} parameter not found in event ResourceProperties"} - # if rule_name in RULE_REGIONS_ACCOUNTS: - # if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: - # rule_accounts = RULE_REGIONS_ACCOUNTS[rule_name]["accounts"] - # else: - # rule_accounts = [] - # if "regions" in RULE_REGIONS_ACCOUNTS[rule_name]: - # rule_regions = RULE_REGIONS_ACCOUNTS[rule_name]["regions"] - # else: - # rule_regions = [] - # else: - # LOGGER.info(f"No {rule_name} accounts or regions found in RULE_REGIONS_ACCOUNTS dictionary. Dictionary: {RULE_REGIONS_ACCOUNTS}") - # # TODO(liamschn): setup default for org accounts and governed regions - # LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") - # 3a) Deploy IAM execution role for custom config rule lambda for acct in rule_accounts: if DRY_RUN is False: role_arn = deploy_iam_role(acct, rule_name) @@ -262,6 +281,8 @@ def create_event(event, context): if DRY_RUN is False: lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "Deployed custom config lambda function" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 else: LOGGER.info(f"DRY_RUN: Deploying lambda for custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "DRY_RUN: Deploy custom config lambda function" @@ -270,17 +291,23 @@ def create_event(event, context): if DRY_RUN is False: config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region, rule_input_params) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "Deployed custom config rule" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 else: LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" # End + if DRY_RUN is False: + LOGGER.info({"LIVE RUN DATA": LIVE_RUN_DATA}) + else: + LOGGER.info({"DRY RUN DATA": DRY_RUN_DATA}) if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: LOGGER.info("Resource type is a custom resource") if DRY_RUN is False: - cfnresponse.send(event, context, cfnresponse.SUCCESS, LIVE_RUN_DATA, CFN_RESOURCE_ID) + cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) else: - cfnresponse.send(event, context, cfnresponse.SUCCESS, DRY_RUN_DATA, CFN_RESOURCE_ID) + cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) else: LOGGER.info("Resource type is not a custom resource") return CFN_RESOURCE_ID @@ -307,6 +334,7 @@ def delete_event(event, context): # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. global DRY_RUN_DATA global LIVE_RUN_DATA + global CFN_RESPONSE_DATA DRY_RUN_DATA = {} LIVE_RUN_DATA = {} LOGGER.info("delete event function") @@ -317,6 +345,8 @@ def delete_event(event, context): LOGGER.info(f"Deleting {SOLUTION_NAME}-configuration SNS topic") LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-configuration SNS topic" sns.delete_sns_topic(topic_search) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 else: LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-configuration SNS topic" @@ -331,21 +361,7 @@ def delete_event(event, context): rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) rule_name = rule_name.lower() LOGGER.info(f"Delete operation: examining {rule_name} resources...") - # for rule in RULE_REGIONS_ACCOUNTS: - # rule_name: str = rule.replace("_", "-") - # # Get bedrock solution rule accounts and regions - # if rule_name in RULE_REGIONS_ACCOUNTS: - # if "accounts" in RULE_REGIONS_ACCOUNTS[rule_name]: - # rule_accounts = RULE_REGIONS_ACCOUNTS[rule_name]["accounts"] - # else: - # rule_accounts = [] - # if "regions" in RULE_REGIONS_ACCOUNTS[rule_name]: - # rule_regions = RULE_REGIONS_ACCOUNTS[rule_name]["regions"] - # else: - # rule_regions = [] - # else: - # LOGGER.info(f"No {rule_name} accounts or regions found in RULE_REGIONS_ACCOUNTS dictionary. Dictionary: {RULE_REGIONS_ACCOUNTS}") - # LOGGER.info(f"Defaulting to all organization accounts and governed regions for {rule_name}") + for acct in rule_accounts: for region in rule_regions: # 3) Delete the config rule @@ -356,6 +372,8 @@ def delete_event(event, context): LOGGER.info(f"Deleting {rule_name} config rule for account {acct} in {region}") config.delete_config_rule(rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") else: @@ -370,6 +388,8 @@ def delete_event(event, context): LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") lambdas.delete_lambda_function(rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} lambda function" @@ -388,6 +408,7 @@ def delete_event(event, context): LIVE_RUN_DATA[ f"{rule_name}_{acct}_{region}_PolicyDetach" ] = f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 else: LOGGER.info(f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}") DRY_RUN_DATA[ @@ -403,6 +424,8 @@ def delete_event(event, context): LOGGER.info(f"Deleting {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") iam.delete_policy(policy_arn) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 else: LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") DRY_RUN_DATA[ @@ -417,6 +440,8 @@ def delete_event(event, context): LOGGER.info(f"Deleting {rule_name} IAM policy for account {acct} in {region}") iam.delete_policy(policy_arn2) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 else: LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}") DRY_RUN_DATA[ @@ -430,17 +455,24 @@ def delete_event(event, context): LOGGER.info(f"Deleting {rule_name} IAM role for account {acct} in {region}") iam.delete_role(rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM role" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 else: LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_RoleDelete"] = f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}" else: LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") + if DRY_RUN is False: + LOGGER.info({"LIVE RUN DATA": LIVE_RUN_DATA}) + else: + LOGGER.info({"DRY RUN DATA": DRY_RUN_DATA}) + if RESOURCE_TYPE != "Other": if DRY_RUN is False: - cfnresponse.send(event, context, cfnresponse.SUCCESS, LIVE_RUN_DATA, CFN_RESOURCE_ID) + cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) else: - cfnresponse.send(event, context, cfnresponse.SUCCESS, DRY_RUN_DATA, CFN_RESOURCE_ID) + cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) def process_sns_records(records: list) -> None: @@ -463,6 +495,7 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: account_id: AWS account ID rule_name: config rule name """ + global CFN_RESPONSE_DATA iam.IAM_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "iam", REGION) LOGGER.info(f"Deploying IAM {rule_name} execution role for rule lambda in {account_id}...") iam_role_search = iam.check_iam_role_exists(rule_name) @@ -470,6 +503,9 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: if DRY_RUN is False: LOGGER.info(f"Creating {rule_name} IAM role") role_arn = iam.create_role(rule_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"], SOLUTION_NAME)["Role"]["Arn"] + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + else: LOGGER.info(f"DRY_RUN: Creating {rule_name} IAM role") else: @@ -479,26 +515,21 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" ][0]["Resource"].replace("ACCOUNT_ID", account_id) - # iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - # "Statement" - # ][0]["Resource"].replace("REGION", sts.HOME_REGION) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" ][1]["Resource"].replace("ACCOUNT_ID", account_id) - # iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - # "Statement" - # ][1]["Resource"].replace("REGION", sts.HOME_REGION) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" ][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") - # TODO(liamschn): change the rule execution role to be specific permissions needed (i.e. read access to IAM, or S3) policy_arn = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}-lamdba-basic-execution" iam_policy_search = iam.check_iam_policy_exists(policy_arn) if iam_policy_search is False: if DRY_RUN is False: LOGGER.info(f"Creating {rule_name}-lamdba-basic-execution IAM policy in {account_id}...") iam.create_policy(f"{rule_name}-lamdba-basic-execution", iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"], SOLUTION_NAME) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 else: LOGGER.info(f"DRY _RUN: Creating {rule_name}-lamdba-basic-execution IAM policy in {account_id}...") else: @@ -510,6 +541,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: if DRY_RUN is False: LOGGER.info(f"Creating {rule_name} IAM policy in {account_id}...") iam.create_policy(f"{rule_name}", IAM_POLICY_DOCUMENTS[rule_name], SOLUTION_NAME) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 else: LOGGER.info(f"DRY _RUN: Creating {rule_name} IAM policy in {account_id}...") else: @@ -520,6 +553,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: if DRY_RUN is False: LOGGER.info(f"Attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role in {account_id}...") iam.attach_policy(rule_name, policy_arn) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: LOGGER.info(f"DRY_RUN: attaching {rule_name}-lamdba-basic-execution policy to {rule_name} IAM role in {account_id}...") @@ -528,6 +563,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: if DRY_RUN is False: LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role in {account_id}...") iam.attach_policy(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSConfigRulesExecutionRole") + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: LOGGER.info(f"DRY_RUN: Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role in {account_id}...") @@ -536,6 +573,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: if DRY_RUN is False: LOGGER.info(f"Attaching AWSConfigRulesExecutionRole policy to {rule_name} IAM role in {account_id}...") iam.attach_policy(rule_name, f"arn:{sts.PARTITION}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole") + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: LOGGER.info(f"DRY_RUN: Attaching AWSLambdaBasicExecutionRole policy to {rule_name} IAM role in {account_id}...") @@ -544,6 +583,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: if DRY_RUN is False: LOGGER.info(f"Attaching {rule_name} to {rule_name} IAM role in {account_id}...") iam.attach_policy(rule_name, policy_arn2) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: LOGGER.info(f"DRY_RUN: attaching {rule_name} to {rule_name} IAM role in {account_id}...") From 83557c1ce08bf91bc1d95329e8652a777e2e1935 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 21:36:53 -0600 Subject: [PATCH 074/395] format run data --- .../solutions/genai/bedrock_org/lambda/src/app.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index eeb841dcc..0d6c83855 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -299,9 +299,10 @@ def create_event(event, context): # End if DRY_RUN is False: - LOGGER.info({"LIVE RUN DATA": LIVE_RUN_DATA}) + LOGGER.info(json.dumps({"LIVE RUN DATA": LIVE_RUN_DATA})) else: - LOGGER.info({"DRY RUN DATA": DRY_RUN_DATA}) + LOGGER.info(json.dumps({"DRY RUN DATA": DRY_RUN_DATA})) + if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: LOGGER.info("Resource type is a custom resource") if DRY_RUN is False: @@ -464,9 +465,9 @@ def delete_event(event, context): LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") if DRY_RUN is False: - LOGGER.info({"LIVE RUN DATA": LIVE_RUN_DATA}) + LOGGER.info(json.dumps({"LIVE RUN DATA": LIVE_RUN_DATA})) else: - LOGGER.info({"DRY RUN DATA": DRY_RUN_DATA}) + LOGGER.info(json.dumps({"DRY RUN DATA": DRY_RUN_DATA})) if RESOURCE_TYPE != "Other": if DRY_RUN is False: From a42bd96191fc46784227337e7e6fc7db61cf99d6 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 21:57:08 -0600 Subject: [PATCH 075/395] more post run data updates --- .../genai/bedrock_org/lambda/src/app.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0d6c83855..6a65c367d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -299,16 +299,13 @@ def create_event(event, context): # End if DRY_RUN is False: - LOGGER.info(json.dumps({"LIVE RUN DATA": LIVE_RUN_DATA})) + LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: - LOGGER.info(json.dumps({"DRY RUN DATA": DRY_RUN_DATA})) + LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: LOGGER.info("Resource type is a custom resource") - if DRY_RUN is False: - cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) - else: - cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) + cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) else: LOGGER.info("Resource type is not a custom resource") return CFN_RESOURCE_ID @@ -465,15 +462,12 @@ def delete_event(event, context): LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") if DRY_RUN is False: - LOGGER.info(json.dumps({"LIVE RUN DATA": LIVE_RUN_DATA})) + LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: - LOGGER.info(json.dumps({"DRY RUN DATA": DRY_RUN_DATA})) + LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) if RESOURCE_TYPE != "Other": - if DRY_RUN is False: - cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) - else: - cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) + cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) def process_sns_records(records: list) -> None: From 5df5eabf8309d55f87878154f7c23c768daa3010 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 9 Sep 2024 22:15:00 -0600 Subject: [PATCH 076/395] todo comment --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 6a65c367d..689ff02e3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -298,6 +298,7 @@ def create_event(event, context): DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" # End + # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: @@ -460,7 +461,7 @@ def delete_event(event, context): DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_RoleDelete"] = f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}" else: LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") - + # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: From d76fa33df87b6cee0f761bbb29e45c2d948c5f92 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 10 Sep 2024 23:02:16 -0600 Subject: [PATCH 077/395] s3 endpoint rule --- .../sra_bedrock_check_s3_endpoints/app.py | 90 +++++++++++++++++++ .../sra-config-lambda-iam-permissions.json | 11 +++ .../templates/sra-bedrock-org-main.yaml | 25 +++++- 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py new file mode 100644 index 000000000..87abf7920 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py @@ -0,0 +1,90 @@ + +import boto3 +import json +import os +import logging + +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") + +# Get AWS region from environment variable +AWS_REGION = os.environ.get('AWS_REGION') + +# Initialize AWS clients +ec2_client = boto3.client('ec2', region_name=AWS_REGION) +config_client = boto3.client('config', region_name=AWS_REGION) + +def evaluate_compliance(configuration_item): + """Evaluates if an S3 Gateway Endpoint is in place for the VPC""" + + if configuration_item['resourceType'] != 'AWS::EC2::VPC': + return 'NOT_APPLICABLE', "Resource is not a VPC" + + vpc_id = configuration_item['configuration']['vpcId'] + + try: + response = ec2_client.describe_vpc_endpoints( + Filters=[ + {'Name': 'vpc-id', 'Values': [vpc_id]}, + {'Name': 'service-name', 'Values': [f'com.amazonaws.{AWS_REGION}.s3']}, + {'Name': 'vpc-endpoint-type', 'Values': ['Gateway']} + ] + ) + + if response['VpcEndpoints']: + endpoint_id = response['VpcEndpoints'][0]['VpcEndpointId'] + return 'COMPLIANT', f"S3 Gateway Endpoint is in place for VPC {vpc_id}. Endpoint ID: {endpoint_id}" + else: + return 'NON_COMPLIANT', f"S3 Gateway Endpoint is not in place for VPC {vpc_id}" + + except Exception as e: + LOGGER.error(f"Error evaluating S3 Gateway Endpoint configuration: {str(e)}") + return 'ERROR', f"Error evaluating compliance: {str(e)}" + +def lambda_handler(event, context): + LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info(f"Event: {json.dumps(event)}") + + invoking_event = json.loads(event['invokingEvent']) + + if invoking_event['messageType'] == 'ConfigurationItemChangeNotification': + configuration_item = invoking_event['configurationItem'] + compliance_type, annotation = evaluate_compliance(configuration_item) + evaluation = { + 'ComplianceResourceType': configuration_item['resourceType'], + 'ComplianceResourceId': configuration_item['resourceId'], + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] + } + evaluations = [evaluation] + elif invoking_event['messageType'] == 'ScheduledNotification': + # For scheduled evaluations, check all VPCs + evaluations = [] + vpcs = ec2_client.describe_vpcs() + for vpc in vpcs['Vpcs']: + vpc_id = vpc['VpcId'] + mock_configuration_item = {'resourceType': 'AWS::EC2::VPC', 'configuration': {'vpcId': vpc_id}} + compliance_type, annotation = evaluate_compliance(mock_configuration_item) + evaluations.append({ + 'ComplianceResourceType': 'AWS::EC2::VPC', + 'ComplianceResourceId': vpc_id, + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': invoking_event['notificationCreationTime'] + }) + else: + LOGGER.error(f"Unsupported message type: {invoking_event['messageType']}") + return + + # Submit compliance evaluations + if evaluations: + config_client.put_evaluations( + Evaluations=evaluations, + ResultToken=event['resultToken'] + ) + + LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json index d2d19e49e..d5715ecc7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json @@ -101,5 +101,16 @@ "Resource": "*" } ] + }, + "sra-bedrock-check-s3-endpoints": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowDescribeEndpoints", + "Effect": "Allow", + "Action": ["ec2:DescribeVpcEndpoints", "ec2:DescribeVpcs"], + "Resource": "*" + } + ] } } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 53aca5b45..a525ca4cb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -74,7 +74,7 @@ Parameters: Description: Bedrock security control configuration Lambda role name Type: String AllowedValues: ['sra-bedrock-org-lambda'] - + pBedrockModelEvalBucketRuleParams: Type: String # TODO(liamschn): update default value of pBedrockModelEvalBucketRuleParams prior to production @@ -161,7 +161,7 @@ Parameters: pBedrockCWEndpointsRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production + # TODO(liamschn): update default value of pBedrockCWEndpointsRuleParams prior to production Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' Description: Bedrock CloudWatch VPC Endpoint Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ @@ -171,6 +171,17 @@ Parameters: Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" + pBedrockS3EndpointsRuleParams: + Type: String + # TODO(liamschn): update default value of pBedrockS3EndpointsRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' + Description: Bedrock S3 VPC Endpoint Config Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ + ConstraintDescription: + "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object/dict (input params must be empty). Arrays can be empty. + Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or + {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" Metadata: AWS::CloudFormation::Interface: @@ -180,7 +191,6 @@ Metadata: Parameters: - pSraRepoZipUrl - pDryRun - # - pRuleRegionsAccounts - pSRASolutionName - pSraSolutionVersion - pSRAStagingS3BucketName @@ -220,9 +230,13 @@ Metadata: Parameters: - pBedrockInvocationLogS3RuleParams - Label: - default: Bedrock CloudWatch VPC Endpoint Rule + default: Bedrock CloudWatch VPC Gateway Endpoint Rule Parameters: - pBedrockCWEndpointsRuleParams + - Label: + default: Bedrock S3 VPC Gateway Endpoint Rule + Parameters: + - pBedrockS3EndpointsRuleParams ParameterLabels: pSraRepoZipUrl: @@ -259,6 +273,8 @@ Metadata: default: Bedrock Model Invocation Logging to S3 Config Rule Parameters pBedrockCWEndpointsRuleParams: default: Bedrock CloudWatch VPC Endpoint Config Rule Parameters + pBedrockS3EndpointsRuleParams: + default: Bedrock S3 VPC Endpoint Config Rule Parameters Resources: rBedrockOrgLambdaRole: @@ -317,6 +333,7 @@ Resources: SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH: !Ref pBedrockInvocationLogCWRuleParams SRA-BEDROCK-CHECK-INVOCATION-LOG-S3: !Ref pBedrockInvocationLogS3RuleParams SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS: !Ref pBedrockCWEndpointsRuleParams + SRA-BEDROCK-CHECK-S3-ENDPOINTS: !Ref pBedrockS3EndpointsRuleParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 4fa9a702730c4ab71b8c7aec8cf3cf62cd2a81f4 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 11 Sep 2024 21:50:02 -0600 Subject: [PATCH 078/395] bedrock guardrail kms rule --- .../app.py | 71 +++++++++++++++++++ .../sra-config-lambda-iam-permissions.json | 11 +++ .../templates/sra-bedrock-org-main.yaml | 20 ++++++ 3 files changed, 102 insertions(+) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py new file mode 100644 index 000000000..a35f4d472 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -0,0 +1,71 @@ +import boto3 +import json +import os +import logging + +# Setup Default Logger +LOGGER = logging.getLogger(__name__) +log_level = os.environ.get("LOG_LEVEL", logging.INFO) +LOGGER.setLevel(log_level) +LOGGER.info(f"boto3 version: {boto3.__version__}") + +# Get AWS region from environment variable +AWS_REGION = os.environ.get('AWS_REGION') + +# Initialize AWS clients +bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) +config_client = boto3.client('config', region_name=AWS_REGION) + +def evaluate_compliance(rule_parameters): + """Evaluates if Bedrock guardrails are encrypted with a KMS key""" + + try: + response = bedrock_client.list_guardrails() + guardrails = response.get('guardrails', []) + + if not guardrails: + return 'NON_COMPLIANT', "No Bedrock guardrails found" + + unencrypted_guardrails = [] + for guardrail in guardrails: + guardrail_id = guardrail['guardrailId'] + guardrail_detail = bedrock_client.get_guardrail(guardrailId=guardrail_id) + + if 'kmsKeyId' not in guardrail_detail: + unencrypted_guardrails.append(guardrail_id) + + if unencrypted_guardrails: + return 'NON_COMPLIANT', f"The following Bedrock guardrails are not encrypted with a KMS key: {', '.join(unencrypted_guardrails)}" + else: + return 'COMPLIANT', "All Bedrock guardrails are encrypted with a KMS key" + + except Exception as e: + LOGGER.error(f"Error evaluating Bedrock guardrails encryption: {str(e)}") + return 'ERROR', f"Error evaluating compliance: {str(e)}" + +def lambda_handler(event, context): + LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info(f"Event: {json.dumps(event)}") + + invoking_event = json.loads(event['invokingEvent']) + rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} + + compliance_type, annotation = evaluate_compliance(rule_parameters) + + evaluation = { + 'ComplianceResourceType': 'AWS::::Account', + 'ComplianceResourceId': event['accountId'], + 'ComplianceType': compliance_type, + 'Annotation': annotation, + 'OrderingTimestamp': invoking_event['notificationCreationTime'] + } + + LOGGER.info(f"Compliance evaluation result: {compliance_type}") + LOGGER.info(f"Annotation: {annotation}") + + config_client.put_evaluations( + Evaluations=[evaluation], + ResultToken=event['resultToken'] + ) + + LOGGER.info("Compliance evaluation complete.") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json index d5715ecc7..ac24957ae 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json @@ -112,5 +112,16 @@ "Resource": "*" } ] + }, + "sra-bedrock-check-guardrail-encryption": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowGetListGuardrails", + "Effect": "Allow", + "Action": ["bedrock:ListGuardrails", "bedrock:GetGuardrail"], + "Resource": "*" + } + ] } } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index a525ca4cb..8de6abe82 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -183,6 +183,19 @@ Parameters: Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" + pBedrockGuardrailEncryptionRuleParams: + Type: String + # TODO(liamschn): update default value of pBedrockGuardrailEncryptionRuleParams prior to production + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' + Description: Bedrock Guardrail KMS Encryption Config Rule Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ + ConstraintDescription: + "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), + 'regions' (array of region names), and 'input_params' object/dict (input params must be empty). Arrays can be empty. + Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or + {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" + + Metadata: AWS::CloudFormation::Interface: ParameterGroups: @@ -237,6 +250,10 @@ Metadata: default: Bedrock S3 VPC Gateway Endpoint Rule Parameters: - pBedrockS3EndpointsRuleParams + - Label: + default: Bedrock Guardrail KMS Encryption Rule + Parameters: + - pBedrockGuardrailEncryptionRuleParams ParameterLabels: pSraRepoZipUrl: @@ -275,6 +292,8 @@ Metadata: default: Bedrock CloudWatch VPC Endpoint Config Rule Parameters pBedrockS3EndpointsRuleParams: default: Bedrock S3 VPC Endpoint Config Rule Parameters + pBedrockGuardrailEncryptionRuleParams: + default: Bedrock Guardrail KMS Encryption Config Rule Parameters Resources: rBedrockOrgLambdaRole: @@ -334,6 +353,7 @@ Resources: SRA-BEDROCK-CHECK-INVOCATION-LOG-S3: !Ref pBedrockInvocationLogS3RuleParams SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS: !Ref pBedrockCWEndpointsRuleParams SRA-BEDROCK-CHECK-S3-ENDPOINTS: !Ref pBedrockS3EndpointsRuleParams + SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION: !Ref pBedrockGuardrailEncryptionRuleParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 70f9d6438ba71dc959f86a8ed2b7d48bfa3247af Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 12 Sep 2024 12:38:14 -0600 Subject: [PATCH 079/395] fix bedrock guardrail encryption rule --- .../rules/sra_bedrock_check_guardrail_encryption/app.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index a35f4d472..61bd5a758 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -28,11 +28,12 @@ def evaluate_compliance(rule_parameters): unencrypted_guardrails = [] for guardrail in guardrails: - guardrail_id = guardrail['guardrailId'] - guardrail_detail = bedrock_client.get_guardrail(guardrailId=guardrail_id) + guardrail_id = guardrail['id'] + guardrail_name = guardrail['name'] + guardrail_detail = bedrock_client.get_guardrail(guardrailIdentifier=guardrail_id) - if 'kmsKeyId' not in guardrail_detail: - unencrypted_guardrails.append(guardrail_id) + if 'kmsKeyArn' not in guardrail_detail: + unencrypted_guardrails.append(guardrail_name) if unencrypted_guardrails: return 'NON_COMPLIANT', f"The following Bedrock guardrails are not encrypted with a KMS key: {', '.join(unencrypted_guardrails)}" From 5a604edc11eac38d046cec544480451cee845fcd Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 13 Sep 2024 10:26:41 -0600 Subject: [PATCH 080/395] creating code for cloudwatch metrics/alarms --- .../src/sra-cloudwatch-metric-filters.json | 4 + .../bedrock_org/lambda/src/sra_cloudwatch.py | 153 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json new file mode 100644 index 000000000..b7f6ab16c --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -0,0 +1,4 @@ +{ + "sra-bedrock-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", + "sra-bedrock-other": "" +} diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py new file mode 100644 index 000000000..db764b1e4 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -0,0 +1,153 @@ +"""Custom Resource to setup SRA Config resources in the organization. + +Version: 0.1 + +CloudWatch module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +import logging +import os +from time import sleep + +# import re +# from time import sleep +from typing import TYPE_CHECKING + +# , Literal, Optional, Sequence, Union + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +import urllib.parse +import json + +import cfnresponse + +if TYPE_CHECKING: + from mypy_boto3_cloudformation import CloudFormationClient + from mypy_boto3_organizations import OrganizationsClient + from mypy_boto3_cloudwatch import CloudWatchClient + from mypy_boto3_logs import CloudWatchLogsClient + from mypy_boto3_iam.client import IAMClient + from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + + +class sra_cloudwatch: + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) + UNEXPECTED = "Unexpected!" + + try: + MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) + CLOUDWATCH_CLIENT: CloudWatchClient = MANAGEMENT_ACCOUNT_SESSION.client("cloudwatch", config=BOTO3_CONFIG) + CWLOGS_CLIENT: CloudWatchLogsClient = MANAGEMENT_ACCOUNT_SESSION.client("logs", config=BOTO3_CONFIG) + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def find_metric_filter(self, log_group_name: str, filter_name: str) -> bool: + try: + response = self.CWLOGS_CLIENT.describe_metric_filters(logGroupName=log_group_name, filterNamePrefix=filter_name) + if response["metricFilters"]: + return True + else: + return False + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + return False + else: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def create_metric_filter(self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str) -> None: + try: + if not self.find_metric_filter(log_group_name, filter_name): + self.CWLOGS_CLIENT.put_metric_filter( + logGroupName=log_group_name, + filterName=filter_name, + filterPattern=filter_pattern, + metricTransformations=[ + { + "metricName": metric_name, + "metricNamespace": metric_namespace, + "metricValue": metric_value, + } + ], + ) + except ClientError: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def delete_metric_filter(self, log_group_name: str, filter_name: str) -> None: + try: + if self.find_metric_filter(log_group_name, filter_name): + self.CWLOGS_CLIENT.delete_metric_filter(logGroupName=log_group_name, filterName=filter_name) + except ClientError: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def update_metric_filter(self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str) -> None: + try: + self.delete_metric_filter(log_group_name, filter_name) + self.create_metric_filter(log_group_name, filter_name, filter_pattern, metric_name, metric_namespace, metric_value) + except ClientError: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def find_metric_alarm(self, alarm_name: str) -> bool: + try: + response = self.CLOUDWATCH_CLIENT.describe_alarms(AlarmNames=[alarm_name]) + if response["MetricAlarms"]: + return True + else: + return False + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + return False + else: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def create_metric_alarm(self, alarm_name: str, alarm_description: str, metric_name: str, metric_namespace: str, metric_statistic: str, metric_period: int, metric_threshold: float, metric_comparison_operator: str, metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list) -> None: + try: + if not self.find_metric_alarm(alarm_name): + self.CLOUDWATCH_CLIENT.put_metric_alarm( + AlarmName=alarm_name, + AlarmDescription=alarm_description, + MetricName=metric_name, + Namespace=metric_namespace, + Statistic=metric_statistic, + Period=metric_period, + Threshold=metric_threshold, + ComparisonOperator=metric_comparison_operator, + EvaluationPeriods=metric_evaluation_periods, + TreatMissingData=metric_treat_missing_data, + AlarmActions=alarm_actions, + ) + except ClientError: + self.LOGGER.info(self.UNEXPECTED) + + def delete_metric_alarm(self, alarm_name: str) -> None: + try: + if self.find_metric_alarm(alarm_name): + self.CLOUDWATCH_CLIENT.delete_alarms(AlarmNames=[alarm_name]) + except ClientError: + self.LOGGER.info(self.UNEXPECTED) + + def update_metric_alarm(self, alarm_name: str, alarm_description: str, metric_name: str, metric_namespace: str, metric_statistic: str, metric_period: int, metric_threshold: float, metric_comparison_operator: str, metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list) -> None: + try: + self.delete_metric_alarm(alarm_name) + self.create_metric_alarm(alarm_name, alarm_description, metric_name, metric_namespace, metric_statistic, metric_period, metric_threshold, metric_comparison_operator, metric_evaluation_periods, metric_treat_missing_data, alarm_actions) + except ClientError: + self.LOGGER.info(self.UNEXPECTED) From de58d30815e8dbbf5b439fb3bce4b7160e501b66 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 13 Sep 2024 20:16:58 -0600 Subject: [PATCH 081/395] working on cw metric filters --- .../genai/bedrock_org/lambda/src/app.py | 90 ++++++++++++++++++- .../src/sra-cloudwatch-metric-filters.json | 4 +- .../bedrock_org/lambda/src/sra_cloudwatch.py | 13 +-- .../templates/sra-bedrock-org-main.yaml | 37 ++++++++ 4 files changed, 135 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 689ff02e3..8c3898996 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -14,6 +14,7 @@ import sra_lambda import sra_sns import sra_config +import sra_cloudwatch from typing import Dict, Any @@ -40,6 +41,12 @@ def load_iam_policy_documents() -> Dict[str, Any]: with open(json_file_path, 'r') as file: return json.load(file) + +def load_CLOUDWATCH_METRIC_FILTERS() -> dict: + with open('sra-cloudwatch-metric-filters.json', 'r') as file: + return json.load(file) + + # Global vars RESOURCE_TYPE: str = "" STATE_TABLE: str = "sra_state" @@ -80,7 +87,7 @@ def load_iam_policy_documents() -> Dict[str, Any]: # TODO(liamschn): Urgent - cannot use these for CFN responses. Max size is 4096 bytes and this gets too large for this. Must change this ASAP (highest priority) LIVE_RUN_DATA: dict = {} IAM_POLICY_DOCUMENTS: Dict[str, Any] = load_iam_policy_documents() - +CLOUDWATCH_METRIC_FILTERS: dict = load_CLOUDWATCH_METRIC_FILTERS() # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? @@ -93,6 +100,7 @@ def load_iam_policy_documents() -> Dict[str, Any]: lambdas = sra_lambda.sra_lambda() sns = sra_sns.sra_sns() config = sra_config.sra_config() +cloudwatch = sra_cloudwatch.sra_cloudwatch() def get_resource_parameters(event): @@ -139,7 +147,7 @@ def get_resource_parameters(event): def get_rule_params(rule_name, event): - """_summary_ + """Get rule parameters from event and return them in a tuple Args: rule_name (str): name of config rule @@ -196,6 +204,62 @@ def get_rule_params(rule_name, event): return False, [], [], {} +def get_filter_params(filter_name, event): + """Get filter parameters from event and return them in a tuple + + Args: + filter_name (str): name of cloudwatch filter + event (dict): lambda event + + Returns: + tuple: (filter_deploy, filter_accounts, filter_regions, filter_pattern) + filter_deploy (bool): whether to deploy the filter + filter_params (dict): dictionary of filter parameters + """ + if filter_name.upper() in event["ResourceProperties"]: + LOGGER.info(f"{filter_name} parameter found in event ResourceProperties") + metric_filter_params = json.loads(event["ResourceProperties"][filter_name.upper()]) + LOGGER.info(f"{filter_name.upper()} parameters: {filter_params}") + if "deploy" in metric_filter_params: + LOGGER.info(f"{filter_name.upper()} 'deploy' parameter found in event ResourceProperties") + if metric_filter_params["deploy"] == "true": + LOGGER.info(f"{filter_name.upper()} 'deploy' parameter set to 'true'") + filter_deploy = True + else: + LOGGER.info(f"{filter_name.upper()} 'deploy' parameter set to 'false'") + filter_deploy = False + else: + LOGGER.info(f"{filter_name.upper()} 'deploy' parameter not found in event ResourceProperties; setting to False") + filter_deploy = False + if "filter_params" in metric_filter_params: + LOGGER.info(f"{filter_name.upper()} 'filter_params' parameter found in event ResourceProperties") + filter_params = metric_filter_params["filter_params"] + LOGGER.info(f"{filter_name.upper()} filter_params: {filter_params}") + else: + LOGGER.info(f"{filter_name.upper()} 'filter_params' parameter not found in event ResourceProperties") + filter_params = {} + else: + LOGGER.info(f"{filter_name.upper()} config rule parameter not found in event ResourceProperties; skipping...") + return False, {} + return filter_deploy, filter_params + + +def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: str) -> str: + # Get the S3 filter + s3_filter = filter_pattern_template + + # If multiple bucket names are provided, create an OR condition + if len(bucket_names) > 1: + bucket_condition = " || ".join([f'$.requestParameters.bucketName = "{bucket}"' for bucket in bucket_names]) + s3_filter = s3_filter.replace('($.requestParameters.bucketName = "")', f'({bucket_condition})') + elif len(bucket_names) == 1: + s3_filter = s3_filter.replace('', bucket_names[0]) + else: + # If no bucket names are provided, remove the bucket condition entirely + s3_filter = s3_filter.replace('&& ($.requestParameters.bucketName = "")', '') + return s3_filter + + def create_event(event, context): global DRY_RUN_DATA global LIVE_RUN_DATA @@ -297,6 +361,28 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" + # 4) deploy cloudwatch metric filters + for filter in CLOUDWATCH_METRIC_FILTERS: + filter_deploy, filter_params = get_filter_params(filter, event) + LOGGER.info(f"{filter} parameters: ") + if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: + filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter]) + else: + filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter] + LOGGER.info(f"{filter} filter pattern: {filter_pattern}") + + if DRY_RUN is False: + if filter_deploy is True: + LOGGER.info(f"Deploying {filter} CloudWatch metric filter...") + else: + LOGGER.info(f"Skipping {filter} CloudWatch metric filter deployment") + else: + if filter_deploy is True: + LOGGER.info(f"DRY_RUN: Deploy {filter} CloudWatch metric filter...") + else: + LOGGER.info(f"DRY_RUN: Skip {filter} CloudWatch metric filter deployment") + + # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index b7f6ab16c..256f75c5f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,4 +1,4 @@ { - "sra-bedrock-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", - "sra-bedrock-other": "" + "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", + "sra-bedrock-filter-bucket-changes": "{ ($.eventSource = \"s3.amazonaws.com\") && (($.eventName = \"DeleteBucketCors\") || ($.eventName = \"DeleteBucketEncryption\") || ($.eventName = \"DeleteBucketIntelligentTieringConfiguration\") || ($.eventName = \"DeleteBucketInventoryConfiguration\") || ($.eventName = \"DeleteBucketLifecycle\") || ($.eventName = \"DeleteBucketMetricsConfiguration\") || ($.eventName = \"DeleteBucketOwnershipControls\") || ($.eventName = \"DeleteBucketPolicy\") || ($.eventName = \"DeleteBucketReplication\") || ($.eventName = \"DeleteBucketTagging\") || ($.eventName = \"DeleteObjectTagging\") || ($.eventName = \"DeletePublicAccessBlock\") || ($.eventName = \"PutBucketAcl\") || ($.eventName = \"PutBucketCors\") || ($.eventName = \"PutBucketEncryption\") || ($.eventName = \"PutBucketIntelligentTieringConfiguration\") || ($.eventName = \"PutBucketLifecycle\") || ($.eventName = \"PutBucketLifecycleConfiguration\") || ($.eventName = \"PutBucketLogging\") || ($.eventName = \"PutBucketMetricsConfiguration\") || ($.eventName = \"PutBucketNotification\") || ($.eventName = \"PutBucketNotificationConfiguration\") || ($.eventName = \"PutBucketOwnershipControls\") || ($.eventName = \"PutBucketPolicy\") || ($.eventName = \"PutBucketReplication\") || ($.eventName = \"PutBucketTagging\") || ($.eventName = \"PutBucketVersioning\") || ($.eventName = \"PutBucketWebsite\") || ($.eventName = \"PutObjectAcl\") || ($.eventName = \"PutObjectLegalHold\") || ($.eventName = \"PutObjectLockConfiguration\") || ($.eventName = \"PutObjectRetention\") || ($.eventName = \"PutObjectTagging\") || ($.eventName = \"PutPublicAccessBlock\")) && ($.requestParameters.bucketName = \"\") }" } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index db764b1e4..9c6920215 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -24,18 +24,21 @@ from botocore.config import Config from botocore.exceptions import ClientError -import urllib.parse +# import urllib.parse import json import cfnresponse if TYPE_CHECKING: - from mypy_boto3_cloudformation import CloudFormationClient - from mypy_boto3_organizations import OrganizationsClient + # from mypy_boto3_cloudformation import CloudFormationClient + # from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_cloudwatch import CloudWatchClient from mypy_boto3_logs import CloudWatchLogsClient - from mypy_boto3_iam.client import IAMClient + # from mypy_boto3_iam.client import IAMClient from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + from mypy_boto3_cloudwatch.type_defs import MetricFilterTypeDef, GetMetricDataResponseTypeDef + # from mypy_boto3_cloudwatch.paginators import GetMetricDataPaginator + from mypy_boto3_logs.type_defs import FilteredLogEventTypeDef, GetLogEventsResponseTypeDef class sra_cloudwatch: @@ -49,7 +52,7 @@ class sra_cloudwatch: try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() - ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) + # ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) CLOUDWATCH_CLIENT: CloudWatchClient = MANAGEMENT_ACCOUNT_SESSION.client("cloudwatch", config=BOTO3_CONFIG) CWLOGS_CLIENT: CloudWatchLogsClient = MANAGEMENT_ACCOUNT_SESSION.client("logs", config=BOTO3_CONFIG) except Exception: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 8de6abe82..8b084fabc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -195,6 +195,29 @@ Parameters: Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" + pBedrockServiceChangesFilterParams: + Type: String + Default: '{"deploy": "true"}' + Description: Bedrock Service Changes Filter Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)"\}$ + ConstraintDescription: + "Must be a valid JSON string containing: 'deploy' (true/false). + Example: {\"deploy\": \"true\"} or + {\"deploy\": \"false\"}" + + pBedrockBucketChangesFilterParams: + Type: String + # TODO(liamschn): update default value of pBedrockBucketChangesFilterParams prior to production + Default: '{"deploy": "true", "filter_params": {"bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}}' + Description: Bedrock S3 Bucket Changes Filter Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"filter_params"\s*:\s*\{"bucket_names"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with optional parameters: + 'bucket_names' (array of bucket names). + Each parameter in 'filter_params' should be a valid bucket name. + Arrays can be empty. + Example: {"deploy": "true", "filter_params": {"bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}} or + {"deploy": "false", "filter_params": {"bucket_names": []}} Metadata: AWS::CloudFormation::Interface: @@ -254,6 +277,14 @@ Metadata: default: Bedrock Guardrail KMS Encryption Rule Parameters: - pBedrockGuardrailEncryptionRuleParams + - Label: + default: Bedrock Service Changes Filter + Parameters: + - pBedrockServiceChangesFilterParams + - Label: + default: Bedrock S3 Bucket Changes Filter + Parameters: + - pBedrockBucketChangesFilterParams ParameterLabels: pSraRepoZipUrl: @@ -294,6 +325,10 @@ Metadata: default: Bedrock S3 VPC Endpoint Config Rule Parameters pBedrockGuardrailEncryptionRuleParams: default: Bedrock Guardrail KMS Encryption Config Rule Parameters + pBedrockServiceChangesFilterParams: + default: Bedrock Service Changes Filter Parameters + pBedrockBucketChangesFilterParams: + default: Bedrock S3 Bucket Changes Filter Parameters Resources: rBedrockOrgLambdaRole: @@ -354,6 +389,8 @@ Resources: SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS: !Ref pBedrockCWEndpointsRuleParams SRA-BEDROCK-CHECK-S3-ENDPOINTS: !Ref pBedrockS3EndpointsRuleParams SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION: !Ref pBedrockGuardrailEncryptionRuleParams + SRA-BEDROCK-FILTER-SERVICE-CHANGES: !Ref pBedrockServiceChangesFilterParams + SRA-BEDROCK-FILTER-BUCKET-CHANGES: !Ref pBedrockBucketChangesFilterParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 5572eca79409684e4e38e8c743850e58b1255945 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 13 Sep 2024 20:44:27 -0600 Subject: [PATCH 082/395] change delete operation for checks --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 8c3898996..94e923318 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -440,7 +440,7 @@ def delete_event(event, context): # TODO(liamschn): deal with invalid rule names # TODO(liamschn): deal with invalid account IDs for prop in event["ResourceProperties"]: - if prop.startswith("SRA-"): + if prop.startswith("SRA-BEDROCK-CHECK-"): rule_name: str = prop LOGGER.info(f"Delete operation: retrieving {rule_name} parameters...") rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) From 4a98cac46fc8fa92e8deb2678e467b3fd173f4b7 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 13 Sep 2024 22:46:09 -0600 Subject: [PATCH 083/395] unbound var fix --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 94e923318..05c0dbbe4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -219,7 +219,7 @@ def get_filter_params(filter_name, event): if filter_name.upper() in event["ResourceProperties"]: LOGGER.info(f"{filter_name} parameter found in event ResourceProperties") metric_filter_params = json.loads(event["ResourceProperties"][filter_name.upper()]) - LOGGER.info(f"{filter_name.upper()} parameters: {filter_params}") + LOGGER.info(f"{filter_name.upper()} metric filter parameters: {metric_filter_params}") if "deploy" in metric_filter_params: LOGGER.info(f"{filter_name.upper()} 'deploy' parameter found in event ResourceProperties") if metric_filter_params["deploy"] == "true": From ddc15a20f0f0156f74d89172f85517018f97518d Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 17 Sep 2024 13:53:03 -0600 Subject: [PATCH 084/395] prototype code for deploying cw metric filter --- .../genai/bedrock_org/lambda/src/app.py | 64 +++++++++++++------ .../templates/sra-bedrock-org-main.yaml | 27 ++++---- 2 files changed, 55 insertions(+), 36 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 05c0dbbe4..826e7e4b0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -35,15 +35,16 @@ log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) + # TODO(liamschn): change this so that it downloads the sra-config-lambda-iam-permissions.json from the repo then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) def load_iam_policy_documents() -> Dict[str, Any]: - json_file_path = os.path.join(os.path.dirname(__file__), 'sra-config-lambda-iam-permissions.json') - with open(json_file_path, 'r') as file: + json_file_path = os.path.join(os.path.dirname(__file__), "sra-config-lambda-iam-permissions.json") + with open(json_file_path, "r") as file: return json.load(file) def load_CLOUDWATCH_METRIC_FILTERS() -> dict: - with open('sra-cloudwatch-metric-filters.json', 'r') as file: + with open("sra-cloudwatch-metric-filters.json", "r") as file: return json.load(file) @@ -62,20 +63,13 @@ def load_CLOUDWATCH_METRIC_FILTERS() -> dict: REGION: str = os.environ.get("AWS_REGION") CFN_RESOURCE_ID: str = "sra-bedrock-org-function" -# CFN_RESPONSE_DATA definition: +# CFN_RESPONSE_DATA definition: # dry_run: bool - type of run # deployment_info: dict - information about the deployment # action_count: int - number of actions taken # resources_deployed: int - number of resources deployed # configuration_changes: int - number of configuration changes -CFN_RESPONSE_DATA: dict = { - "dry_run": True, - "deployment_info": { - "action_count": 0, - "resources_deployed": 0, - "configuration_changes": 0 - } - } +CFN_RESPONSE_DATA: dict = {"dry_run": True, "deployment_info": {"action_count": 0, "resources_deployed": 0, "configuration_changes": 0}} # TODO(liamschn): Consider adding "regions_targeted": int and "accounts_targeted": in to "deployment_info" of CFN_RESPONSE_DATA @@ -251,12 +245,12 @@ def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: # If multiple bucket names are provided, create an OR condition if len(bucket_names) > 1: bucket_condition = " || ".join([f'$.requestParameters.bucketName = "{bucket}"' for bucket in bucket_names]) - s3_filter = s3_filter.replace('($.requestParameters.bucketName = "")', f'({bucket_condition})') + s3_filter = s3_filter.replace('($.requestParameters.bucketName = "")', f"({bucket_condition})") elif len(bucket_names) == 1: - s3_filter = s3_filter.replace('', bucket_names[0]) + s3_filter = s3_filter.replace("", bucket_names[0]) else: # If no bucket names are provided, remove the bucket condition entirely - s3_filter = s3_filter.replace('&& ($.requestParameters.bucketName = "")', '') + s3_filter = s3_filter.replace('&& ($.requestParameters.bucketName = "")', "") return s3_filter @@ -373,15 +367,22 @@ def create_event(event, context): if DRY_RUN is False: if filter_deploy is True: - LOGGER.info(f"Deploying {filter} CloudWatch metric filter...") + LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter} CloudWatch metric filter...") + deploy_metric_filter(filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock","1") + LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + else: - LOGGER.info(f"Skipping {filter} CloudWatch metric filter deployment") + LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") + LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" else: if filter_deploy is True: - LOGGER.info(f"DRY_RUN: Deploy {filter} CloudWatch metric filter...") + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric filter...") + DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'true'; Deploy CloudWatch metric filter" else: - LOGGER.info(f"DRY_RUN: Skip {filter} CloudWatch metric filter deployment") - + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") + DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message @@ -389,7 +390,7 @@ def create_event(event, context): LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) - + if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: LOGGER.info("Resource type is a custom resource") cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) @@ -745,6 +746,27 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: LOGGER.info(f"{rule_name} config rule already exists.") +def deploy_metric_filter(log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str): + """Deploy metric filter. + + Args: + log_group_name: log group name + filter_name: filter name + filter_pattern: filter pattern + metric_name: metric name + metric_namespace: metric namespace + metric_value: metric value + """ + search_metric_filter = cloudwatch.find_metric_filter(log_group_name, filter_name) + if search_metric_filter is False: + LOGGER.info(f"Deploying metric filter {filter_name} to {log_group_name}...") + if DRY_RUN is False: + cloudwatch.create_metric_filter(log_group_name, filter_name, filter_pattern, metric_name, metric_namespace, metric_value) + else: + LOGGER.info(f"DRY_RUN: Deploying metric filter {filter_name} to {log_group_name}...") + else: + LOGGER.info(f"Metric filter {filter_name} already exists.") + def lambda_handler(event, context): global RESOURCE_TYPE global LAMBDA_START diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 8b084fabc..762360c23 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -196,28 +196,25 @@ Parameters: {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" pBedrockServiceChangesFilterParams: + # TODO(liamschn): update default value of pBedrockServiceChangesFilterParams prior to production Type: String - Default: '{"deploy": "true"}' + Default: '{"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' Description: Bedrock Service Changes Filter Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)"\}$ - ConstraintDescription: - "Must be a valid JSON string containing: 'deploy' (true/false). - Example: {\"deploy\": \"true\"} or - {\"deploy\": \"false\"}" + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with + 'log_group_name' (non-empty string). Example: {"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}} pBedrockBucketChangesFilterParams: - Type: String # TODO(liamschn): update default value of pBedrockBucketChangesFilterParams prior to production - Default: '{"deploy": "true", "filter_params": {"bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}}' + Type: String + Default: '{"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}}' Description: Bedrock S3 Bucket Changes Filter Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"filter_params"\s*:\s*\{"bucket_names"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}\}$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$ ConstraintDescription: > - Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with optional parameters: - 'bucket_names' (array of bucket names). - Each parameter in 'filter_params' should be a valid bucket name. - Arrays can be empty. - Example: {"deploy": "true", "filter_params": {"bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}} or - {"deploy": "false", "filter_params": {"bucket_names": []}} + Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with required parameters: + 'log_group_name' (non-empty string) and 'bucket_names' (non-empty array of non-empty strings). + Example: {"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}} Metadata: AWS::CloudFormation::Interface: From 2577e522b386b99835575cf31d1298b9f11b472b Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 17 Sep 2024 13:57:39 -0600 Subject: [PATCH 085/395] adding unit of count to cw metric filter --- .../solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 9c6920215..80737f4be 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -76,6 +76,7 @@ def find_metric_filter(self, log_group_name: str, filter_name: str) -> bool: def create_metric_filter(self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str) -> None: try: if not self.find_metric_filter(log_group_name, filter_name): + # TODO(liamschn): finalize what parameters should be setup for this create_metric_filter function self.CWLOGS_CLIENT.put_metric_filter( logGroupName=log_group_name, filterName=filter_name, @@ -85,6 +86,7 @@ def create_metric_filter(self, log_group_name: str, filter_name: str, filter_pat "metricName": metric_name, "metricNamespace": metric_namespace, "metricValue": metric_value, + "unit": "Count" } ], ) From 7eeb4c19bd940bd9fb776b061809db4bd13716ae Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 17 Sep 2024 16:56:37 -0600 Subject: [PATCH 086/395] add delete filters; add unit count; updated filters --- .../genai/bedrock_org/lambda/src/app.py | 32 ++++++++++++++----- .../src/sra-cloudwatch-metric-filters.json | 2 +- .../bedrock_org/lambda/src/sra_cloudwatch.py | 6 ++-- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 826e7e4b0..0b7e4607b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -43,7 +43,7 @@ def load_iam_policy_documents() -> Dict[str, Any]: return json.load(file) -def load_CLOUDWATCH_METRIC_FILTERS() -> dict: +def load_cloudwatch_metric_filters() -> dict: with open("sra-cloudwatch-metric-filters.json", "r") as file: return json.load(file) @@ -81,7 +81,7 @@ def load_CLOUDWATCH_METRIC_FILTERS() -> dict: # TODO(liamschn): Urgent - cannot use these for CFN responses. Max size is 4096 bytes and this gets too large for this. Must change this ASAP (highest priority) LIVE_RUN_DATA: dict = {} IAM_POLICY_DOCUMENTS: Dict[str, Any] = load_iam_policy_documents() -CLOUDWATCH_METRIC_FILTERS: dict = load_CLOUDWATCH_METRIC_FILTERS() +CLOUDWATCH_METRIC_FILTERS: dict = load_cloudwatch_metric_filters() # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? @@ -436,8 +436,24 @@ def delete_event(event, context): else: LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-configuration SNS topic" + # 2) Delete metric filters + for filter in CLOUDWATCH_METRIC_FILTERS: + filter_deploy, filter_params = get_filter_params(filter, event) + if DRY_RUN is False: + LOGGER.info(f"Deleting {filter} CloudWatch metric filter") + LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" + search_metric_filter = cloudwatch.find_metric_filter(filter_params['log_group_name'],filter) + if search_metric_filter is True: + cloudwatch.delete_metric_filter(filter_params['log_group_name'], filter) + else: + LOGGER.info(f"{filter} CloudWatch metric filter does not exist.") + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info(f"DRY_RUN: Deleting {filter} CloudWatch metric filter") + DRY_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter} CloudWatch metric filter" - # 2) Delete config rules + # 3) Delete config rules # TODO(liamschn): deal with invalid rule names # TODO(liamschn): deal with invalid account IDs for prop in event["ResourceProperties"]: @@ -450,7 +466,7 @@ def delete_event(event, context): for acct in rule_accounts: for region in rule_regions: - # 3) Delete the config rule + # 4) Delete the config rule config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) if config_rule_search[0] is True: @@ -466,7 +482,7 @@ def delete_event(event, context): LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" - # 4) Delete lambda for custom config rule + # 5) Delete lambda for custom config rule lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) lambda_search = lambdas.find_lambda_function(rule_name) if lambda_search is not None: @@ -482,7 +498,7 @@ def delete_event(event, context): else: LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") - # 5) Detach IAM policies + # 6) Detach IAM policies # TODO(liamschn): handle case where policy is not found attached_policies = None iam.IAM_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "iam", REGION) attached_policies = iam.list_attached_iam_policies(rule_name) @@ -501,7 +517,7 @@ def delete_event(event, context): f"{rule_name}_{acct}_{region}_Delete" ] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" - # 6) Delete IAM policy + # 7) Delete IAM policy policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" LOGGER.info(f"Policy ARN: {policy_arn}") policy_search = iam.check_iam_policy_exists(policy_arn) @@ -534,7 +550,7 @@ def delete_event(event, context): f"{rule_name}_{acct}_{region}_PolicyDelete" ] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" - # 7) Delete IAM execution role for custom config rule lambda + # 8) Delete IAM execution role for custom config rule lambda role_search = iam.check_iam_role_exists(rule_name) if role_search[0] is True: if DRY_RUN is False: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index 256f75c5f..ee33fb8e9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,4 +1,4 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", - "sra-bedrock-filter-bucket-changes": "{ ($.eventSource = \"s3.amazonaws.com\") && (($.eventName = \"DeleteBucketCors\") || ($.eventName = \"DeleteBucketEncryption\") || ($.eventName = \"DeleteBucketIntelligentTieringConfiguration\") || ($.eventName = \"DeleteBucketInventoryConfiguration\") || ($.eventName = \"DeleteBucketLifecycle\") || ($.eventName = \"DeleteBucketMetricsConfiguration\") || ($.eventName = \"DeleteBucketOwnershipControls\") || ($.eventName = \"DeleteBucketPolicy\") || ($.eventName = \"DeleteBucketReplication\") || ($.eventName = \"DeleteBucketTagging\") || ($.eventName = \"DeleteObjectTagging\") || ($.eventName = \"DeletePublicAccessBlock\") || ($.eventName = \"PutBucketAcl\") || ($.eventName = \"PutBucketCors\") || ($.eventName = \"PutBucketEncryption\") || ($.eventName = \"PutBucketIntelligentTieringConfiguration\") || ($.eventName = \"PutBucketLifecycle\") || ($.eventName = \"PutBucketLifecycleConfiguration\") || ($.eventName = \"PutBucketLogging\") || ($.eventName = \"PutBucketMetricsConfiguration\") || ($.eventName = \"PutBucketNotification\") || ($.eventName = \"PutBucketNotificationConfiguration\") || ($.eventName = \"PutBucketOwnershipControls\") || ($.eventName = \"PutBucketPolicy\") || ($.eventName = \"PutBucketReplication\") || ($.eventName = \"PutBucketTagging\") || ($.eventName = \"PutBucketVersioning\") || ($.eventName = \"PutBucketWebsite\") || ($.eventName = \"PutObjectAcl\") || ($.eventName = \"PutObjectLegalHold\") || ($.eventName = \"PutObjectLockConfiguration\") || ($.eventName = \"PutObjectRetention\") || ($.eventName = \"PutObjectTagging\") || ($.eventName = \"PutPublicAccessBlock\")) && ($.requestParameters.bucketName = \"\") }" + "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket*\" || ($.eventName = \"PutBucket*\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"*PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }" } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 80737f4be..48516f89b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -90,9 +90,9 @@ def create_metric_filter(self, log_group_name: str, filter_name: str, filter_pat } ], ) - except ClientError: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + except ClientError as e: + self.LOGGER.info(f"{self.UNEXPECTED} error: {e}") + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None def delete_metric_filter(self, log_group_name: str, filter_name: str) -> None: try: From 65dad0762ecd9e0517ed065728147669b970a384 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 17 Sep 2024 17:22:03 -0600 Subject: [PATCH 087/395] set default metric value --- .../solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 48516f89b..0957bf8ce 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -86,7 +86,8 @@ def create_metric_filter(self, log_group_name: str, filter_name: str, filter_pat "metricName": metric_name, "metricNamespace": metric_namespace, "metricValue": metric_value, - "unit": "Count" + "unit": "Count", + "defaultValue": 0 } ], ) From 5ed621ef90af4d60decec868a1fd128333618efa Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 17 Sep 2024 22:34:54 -0600 Subject: [PATCH 088/395] working on cw metric alarm + sns topic; not working yet --- .../genai/bedrock_org/lambda/src/app.py | 40 +++++++++++++++++++ .../templates/sra-bedrock-org-main.yaml | 11 +++++ 2 files changed, 51 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0b7e4607b..05cde1660 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -55,6 +55,7 @@ def load_cloudwatch_metric_filters() -> dict: RULE_REGIONS_ACCOUNTS = {} GOVERNED_REGIONS = [] BEDROCK_MODEL_EVAL_BUCKET: str = "" +SRA_ALARM_EMAIL: str = "" LAMBDA_START: str = "" LAMBDA_FINISH: str = "" @@ -103,6 +104,7 @@ def get_resource_parameters(event): global GOVERNED_REGIONS global BEDROCK_MODEL_EVAL_BUCKET global CFN_RESPONSE_DATA + global SRA_ALARM_EMAIL LOGGER.info("Getting resource params...") # TODO(liamschn): what parameters do we need for this solution? @@ -129,6 +131,9 @@ def get_resource_parameters(event): if "BEDROCK_MODEL_EVAL_BUCKET" in event["ResourceProperties"]: BEDROCK_MODEL_EVAL_BUCKET = event["ResourceProperties"]["BEDROCK_MODEL_EVAL_BUCKET"] + if event["ResourceProperties"]["SRA_ALARM_EMAIL"] != "": + SRA_ALARM_EMAIL = event["ResourceProperties"]["SRA_ALARM_EMAIL"] + if event["ResourceProperties"]["DRY_RUN"] == "true": # dry run LOGGER.info("Dry run enabled...") @@ -317,6 +322,41 @@ def create_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") + topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") + if topic_search is None: + if DRY_RUN is False: + LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") + topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME) + LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-alarms SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + + # LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") + # TODO(liamschn): search for permissions on lambda before adding the policy + # lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) + # LIVE_RUN_DATA["SNSPermissions"] = "Added lambda sns-invoke permissions for SNS topic" + # CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + # CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + + LOGGER.info(f"Subscribing {context.invoked_function_arn} to {topic_arn}") + sns.create_sns_subscription(topic_arn, "email", SRA_ALARM_EMAIL) + LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-alarms SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + + else: + LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" + + LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") + DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" + + LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {topic_arn}") + DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-alarms SNS topic" + else: + LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") + + # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: rule_name = rule.replace("_", "-") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 762360c23..ca78722f8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -56,6 +56,13 @@ Parameters: Default: '1.0.0' Description: The version of the SRA solution + pSRAAlarmEmail: + Type: String + Description: The email address to notify when an alarm is triggered + AllowedPattern: ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ + ConstraintDescription: Must be a valid email address + Default: 'liamschn+bedrockalarm@amazon.com' + pSRAStagingS3BucketName: # AllowedPattern: '^(?=^.{3,63}$)(?!.*[.-]{2})(?!.*[--]{2})(?!^(?:(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(?!$)|$)){4}$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)' ConstraintDescription: @@ -227,6 +234,7 @@ Metadata: - pSRASolutionName - pSraSolutionVersion - pSRAStagingS3BucketName + - pSRAAlarmEmail - Label: default: IAM Roles Parameters: @@ -300,6 +308,8 @@ Metadata: default: SRA Solution Name pSraSolutionVersion: default: SRA Solution Version + pSRAAlarmEmail: + default: SRA Alarm Email pSRAStagingS3BucketName: default: SRA Staging S3 Bucket Name pBedrockOrgLambdaRoleName: @@ -377,6 +387,7 @@ Resources: LOG_LEVEL: !Ref pLambdaLogLevel SOLUTION_NAME: !Ref pSRASolutionName SOLUTION_VERSION: !Ref pSraSolutionVersion + SRA_ALARM_EMAIL: !Ref pSRAAlarmEmail SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET: !Ref pBedrockModelEvalBucketRuleParams SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams SRA-BEDROCK-CHECK-GUARDRAILS: !Ref pBedrockGuardrailsRuleParams From 0a2afdc846fb7334bd374967556c1b86786f5512 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 19 Sep 2024 17:22:41 -0600 Subject: [PATCH 089/395] completing code to add alarms --- .../genai/bedrock_org/lambda/src/app.py | 65 ++++++++++++++----- .../genai/bedrock_org/lambda/src/sra_sns.py | 33 ++++++++++ 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 05cde1660..d399fe760 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -56,6 +56,7 @@ def load_cloudwatch_metric_filters() -> dict: GOVERNED_REGIONS = [] BEDROCK_MODEL_EVAL_BUCKET: str = "" SRA_ALARM_EMAIL: str = "" +SRA_ALARM_TOPIC_ARN: str = "" LAMBDA_START: str = "" LAMBDA_FINISH: str = "" @@ -263,6 +264,7 @@ def create_event(event, context): global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA + global SRA_ALARM_TOPIC_ARN DRY_RUN_DATA = {} LIVE_RUN_DATA = {} @@ -286,8 +288,10 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") - # 2) Deploy SNS topic for fanout configuration operations - # TODO(liamschn): analyze again if sns is needed for this solution + # 2) Deploy SNS topics + # 2a) SNS topics for fanout configuration operations + # TODO(liamschn): analyze again if the configuration sns topic is needed for this solution (probably is needed) + # TODO(liamschn): if needed, then change the code to have the create events call the sns topic which calls the lambda for configuration/deployment topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is None: if DRY_RUN is False: @@ -321,26 +325,26 @@ def create_event(event, context): DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") - + # 2b) SNS topics for alarms topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") if topic_search is None: if DRY_RUN is False: LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") - topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME) + SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME) LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-alarms SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - # LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") - # TODO(liamschn): search for permissions on lambda before adding the policy - # lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) - # LIVE_RUN_DATA["SNSPermissions"] = "Added lambda sns-invoke permissions for SNS topic" - # CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - # CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {topic_arn}") + # TODO(liamschn): search for policy on SNS topic before adding the policy + sns.set_topic_access_for_alarms(topic_arn, sts.MANAGEMENT_ACCOUNT) + LIVE_RUN_DATA["SNSPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - LOGGER.info(f"Subscribing {context.invoked_function_arn} to {topic_arn}") + LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {topic_arn}") sns.create_sns_subscription(topic_arn, "email", SRA_ALARM_EMAIL) - LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-alarms SNS topic" + LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 @@ -351,8 +355,8 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" - LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {topic_arn}") - DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-alarms SNS topic" + LOGGER.info(f"DRY_RUN: Subscribing {SRA_ALARM_EMAIL} to {topic_arn}") + DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") @@ -412,7 +416,10 @@ def create_event(event, context): LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - + deploy_metric_alarm(f"{filter}-alarm", f"{filter}-metric alarm", f"{filter}-metric", "sra-bedrock", "sum", 10, 1, 0, 'GreaterThanThreshold', 'missing', [SRA_ALARM_TOPIC_ARN]) + LIVE_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 else: LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" @@ -420,6 +427,8 @@ def create_event(event, context): if filter_deploy is True: LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric filter...") DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'true'; Deploy CloudWatch metric filter" + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric alarm...") + DRY_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" else: LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" @@ -823,6 +832,32 @@ def deploy_metric_filter(log_group_name: str, filter_name: str, filter_pattern: else: LOGGER.info(f"Metric filter {filter_name} already exists.") +def deploy_metric_alarm(alarm_name: str, alarm_description: str, metric_name: str, metric_namespace: str, metric_statistic: str, metric_period: int, metric_evaluation_periods: int, metric_threshold: float, metric_comparison_operator: str, metric_treat_missing_data: str, alarm_actions: list): + """Deploy metric alarm. + + Args: + alarm_name: alarm name + alarm_description: alarm description + metric_name: metric name + metric_namespace: metric namespace + metric_statistic: metric statistic + metric_period: metric period + metric_evaluation_periods: metric evaluation periods + metric_threshold: metric threshold + metric_comparison_operator: metric comparison operator + metric_treat_missing_data: metric treat missing data + alarm_actions: alarm actions + """ + search_metric_alarm = cloudwatch.find_metric_alarm(alarm_name) + if search_metric_alarm is False: + LOGGER.info(f"Deploying metric alarm {alarm_name}...") + if DRY_RUN is False: + cloudwatch.create_metric_alarm(alarm_name, alarm_description, metric_name, metric_namespace, metric_statistic, metric_period, metric_threshold, metric_comparison_operator, metric_evaluation_periods, metric_treat_missing_data, alarm_actions) + else: + LOGGER.info(f"DRY_RUN: Deploying metric alarm {alarm_name}...") + else: + LOGGER.info(f"Metric alarm {alarm_name} already exists.") + def lambda_handler(event, context): global RESOURCE_TYPE global LAMBDA_START diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 683856a7b..f8530b70c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -24,6 +24,8 @@ if TYPE_CHECKING: from mypy_boto3_sns.client import SNSClient +import json +import json # TODO(liamschn): kms key for sns topic @@ -109,3 +111,34 @@ def create_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) return None except ClientError as e: raise ValueError(f"Error creating SNS subscription: {e}") from None + + def set_topic_access_for_alarms(self, topic_arn: str, source_account: str) -> None: + """Set SNS Topic Policy to allow access for alarm.""" + try: + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAlarmToPublish", + "Effect": "Allow", + "Principal": {"Service": "cloudwatch.amazonaws.com"}, + "Action": "sns:Publish", + "Resource": topic_arn, + "Condition": { + "ArnLike": { + "aws:SourceArn": f"arn:{self.sts.PARTITION}:cloudwatch:{self.sts.HOME_REGION}:{source_account}:alarm:*" + }, + "StringEquals" : {"AWS:SourceAccount": source_account} + } + } + ] + } + self.SNS_CLIENT.set_topic_attributes( + TopicArn=topic_arn, + AttributeName="Policy", + AttributeValue=json.dumps(policy) + ) + self.LOGGER.info(f"SNS Topic Policy set for {topic_arn} to allow access for CloudWatch alarms in the {source_account} account") + return None + except ClientError as e: + raise ValueError(f"Error setting SNS topic policy: {e}") from None \ No newline at end of file From 67c95c540fe38c691e7ffda8087070229e4b7fa6 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 20 Sep 2024 09:07:23 -0600 Subject: [PATCH 090/395] deploys cw alarms/sns topic; tested --- .../genai/bedrock_org/lambda/src/app.py | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index d399fe760..346b16f57 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -288,7 +288,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") - # 2) Deploy SNS topics + # 2) Deploy SNS topics # 2a) SNS topics for fanout configuration operations # TODO(liamschn): analyze again if the configuration sns topic is needed for this solution (probably is needed) # TODO(liamschn): if needed, then change the code to have the create events call the sns topic which calls the lambda for configuration/deployment @@ -335,7 +335,7 @@ def create_event(event, context): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info(f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {topic_arn}") + LOGGER.info(f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {SOLUTION_NAME}-alarms SNS topic") # TODO(liamschn): search for policy on SNS topic before adding the policy sns.set_topic_access_for_alarms(topic_arn, sts.MANAGEMENT_ACCOUNT) LIVE_RUN_DATA["SNSPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" @@ -360,7 +360,6 @@ def create_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") - # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: rule_name = rule.replace("_", "-") @@ -412,11 +411,23 @@ def create_event(event, context): if DRY_RUN is False: if filter_deploy is True: LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter} CloudWatch metric filter...") - deploy_metric_filter(filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock","1") + deploy_metric_filter(filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock", "1") LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - deploy_metric_alarm(f"{filter}-alarm", f"{filter}-metric alarm", f"{filter}-metric", "sra-bedrock", "sum", 10, 1, 0, 'GreaterThanThreshold', 'missing', [SRA_ALARM_TOPIC_ARN]) + deploy_metric_alarm( + f"{filter}-alarm", + f"{filter}-metric alarm", + f"{filter}-metric", + "sra-bedrock", + "sum", + 10, + 1, + 0, + "GreaterThanThreshold", + "missing", + [SRA_ALARM_TOPIC_ARN], + ) LIVE_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 @@ -491,9 +502,9 @@ def delete_event(event, context): if DRY_RUN is False: LOGGER.info(f"Deleting {filter} CloudWatch metric filter") LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" - search_metric_filter = cloudwatch.find_metric_filter(filter_params['log_group_name'],filter) + search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) if search_metric_filter is True: - cloudwatch.delete_metric_filter(filter_params['log_group_name'], filter) + cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter) else: LOGGER.info(f"{filter} CloudWatch metric filter does not exist.") CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 @@ -832,7 +843,20 @@ def deploy_metric_filter(log_group_name: str, filter_name: str, filter_pattern: else: LOGGER.info(f"Metric filter {filter_name} already exists.") -def deploy_metric_alarm(alarm_name: str, alarm_description: str, metric_name: str, metric_namespace: str, metric_statistic: str, metric_period: int, metric_evaluation_periods: int, metric_threshold: float, metric_comparison_operator: str, metric_treat_missing_data: str, alarm_actions: list): + +def deploy_metric_alarm( + alarm_name: str, + alarm_description: str, + metric_name: str, + metric_namespace: str, + metric_statistic: str, + metric_period: int, + metric_evaluation_periods: int, + metric_threshold: float, + metric_comparison_operator: str, + metric_treat_missing_data: str, + alarm_actions: list, +): """Deploy metric alarm. Args: @@ -852,12 +876,25 @@ def deploy_metric_alarm(alarm_name: str, alarm_description: str, metric_name: st if search_metric_alarm is False: LOGGER.info(f"Deploying metric alarm {alarm_name}...") if DRY_RUN is False: - cloudwatch.create_metric_alarm(alarm_name, alarm_description, metric_name, metric_namespace, metric_statistic, metric_period, metric_threshold, metric_comparison_operator, metric_evaluation_periods, metric_treat_missing_data, alarm_actions) + cloudwatch.create_metric_alarm( + alarm_name, + alarm_description, + metric_name, + metric_namespace, + metric_statistic, + metric_period, + metric_threshold, + metric_comparison_operator, + metric_evaluation_periods, + metric_treat_missing_data, + alarm_actions, + ) else: LOGGER.info(f"DRY_RUN: Deploying metric alarm {alarm_name}...") else: LOGGER.info(f"Metric alarm {alarm_name} already exists.") + def lambda_handler(event, context): global RESOURCE_TYPE global LAMBDA_START From a2017c1c4f533b2ed1199642387ebec6a946493f Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 20 Sep 2024 16:54:34 -0600 Subject: [PATCH 091/395] adding metric filter and alarm delete --- .../genai/bedrock_org/lambda/src/app.py | 78 +++++++++++++------ .../bedrock_org/lambda/src/sra_cloudwatch.py | 5 +- .../templates/sra-bedrock-org-main.yaml | 16 ++-- 3 files changed, 65 insertions(+), 34 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 346b16f57..9bab4dddd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -318,47 +318,49 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-configuration SNS topic" - LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") + LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {SOLUTION_NAME}-configuration SNS topic on {context.function_name} lambda function") DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" - LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {topic_arn}") + LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") + topic_arn = topic_search # 2b) SNS topics for alarms topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") if topic_search is None: if DRY_RUN is False: LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME) - LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-alarms SNS topic" + LIVE_RUN_DATA["SNSAlarmTopic"] = f"Created {SOLUTION_NAME}-alarms SNS topic (ARN: {SRA_ALARM_TOPIC_ARN})" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {SOLUTION_NAME}-alarms SNS topic") # TODO(liamschn): search for policy on SNS topic before adding the policy - sns.set_topic_access_for_alarms(topic_arn, sts.MANAGEMENT_ACCOUNT) - LIVE_RUN_DATA["SNSPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" + sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, sts.MANAGEMENT_ACCOUNT) + LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {topic_arn}") - sns.create_sns_subscription(topic_arn, "email", SRA_ALARM_EMAIL) - LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" + LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {SRA_ALARM_TOPIC_ARN}") + sns.create_sns_subscription(SRA_ALARM_TOPIC_ARN, "email", SRA_ALARM_EMAIL) + LIVE_RUN_DATA["SNSAlarmSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: - LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-alarms SNS topic") - DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" + LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSAlarmCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" - LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") - DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" + LOGGER.info(f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account") + DRY_RUN_DATA["SNSAlarmPermissions"] = f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" - LOGGER.info(f"DRY_RUN: Subscribing {SRA_ALARM_EMAIL} to {topic_arn}") - DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" + LOGGER.info(f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSAlarmSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") + SRA_ALARM_TOPIC_ARN = topic_search # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: @@ -415,12 +417,13 @@ def create_event(event, context): LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info(f"DEBUG: Alarm topic ARN: {SRA_ALARM_TOPIC_ARN}") deploy_metric_alarm( f"{filter}-alarm", f"{filter}-metric alarm", f"{filter}-metric", "sra-bedrock", - "sum", + "Sum", 10, 1, 0, @@ -485,6 +488,7 @@ def delete_event(event, context): LIVE_RUN_DATA = {} LOGGER.info("delete event function") # 1) Delete SNS topic + # 1a) Delete configuration topic topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is not None: if DRY_RUN is False: @@ -496,24 +500,50 @@ def delete_event(event, context): else: LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-configuration SNS topic" - # 2) Delete metric filters + # 1b) Delete the alarm topic + alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarm") + if alarm_topic_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {SOLUTION_NAME}-alarm SNS topic") + LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarm SNS topic" + sns.delete_sns_topic(alarm_topic_search) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-alarm SNS topic") + DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarm SNS topic" + + # 3) Delete metric alarms and filters for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_params = get_filter_params(filter, event) if DRY_RUN is False: + # 3a) Delete the CloudWatch metric alarm + LOGGER.info(f"Deleting {filter}-alarm CloudWatch metric alarm") + LIVE_RUN_DATA[f"{filter}-alarm_CloudWatchDelete"] = f"Deleted {filter}-alarm CloudWatch metric alarm" + search_metric_alarm = cloudwatch.find_metric_alarm(f"{filter}-alarm") + if search_metric_alarm is True: + cloudwatch.delete_metric_alarm(f"{filter}-alarm") + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info(f"{filter}-alarm CloudWatch metric alarm does not exist.") + + # 3b) Delete the CloudWatch metric filter LOGGER.info(f"Deleting {filter} CloudWatch metric filter") LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) if search_metric_filter is True: cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 else: LOGGER.info(f"{filter} CloudWatch metric filter does not exist.") - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: LOGGER.info(f"DRY_RUN: Deleting {filter} CloudWatch metric filter") DRY_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter} CloudWatch metric filter" - # 3) Delete config rules + # 4) Delete config rules # TODO(liamschn): deal with invalid rule names # TODO(liamschn): deal with invalid account IDs for prop in event["ResourceProperties"]: @@ -526,7 +556,7 @@ def delete_event(event, context): for acct in rule_accounts: for region in rule_regions: - # 4) Delete the config rule + # 5) Delete the config rule config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) if config_rule_search[0] is True: @@ -542,7 +572,7 @@ def delete_event(event, context): LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" - # 5) Delete lambda for custom config rule + # 6) Delete lambda for custom config rule lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) lambda_search = lambdas.find_lambda_function(rule_name) if lambda_search is not None: @@ -558,7 +588,7 @@ def delete_event(event, context): else: LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") - # 6) Detach IAM policies + # 7) Detach IAM policies # TODO(liamschn): handle case where policy is not found attached_policies = None iam.IAM_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "iam", REGION) attached_policies = iam.list_attached_iam_policies(rule_name) @@ -577,7 +607,7 @@ def delete_event(event, context): f"{rule_name}_{acct}_{region}_Delete" ] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" - # 7) Delete IAM policy + # 8) Delete IAM policy policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" LOGGER.info(f"Policy ARN: {policy_arn}") policy_search = iam.check_iam_policy_exists(policy_arn) @@ -610,7 +640,7 @@ def delete_event(event, context): f"{rule_name}_{acct}_{region}_PolicyDelete" ] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" - # 8) Delete IAM execution role for custom config rule lambda + # 9) Delete IAM execution role for custom config rule lambda role_search = iam.check_iam_role_exists(rule_name) if role_search[0] is True: if DRY_RUN is False: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 0957bf8ce..5cd07883f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -126,6 +126,7 @@ def find_metric_alarm(self, alarm_name: str) -> bool: raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def create_metric_alarm(self, alarm_name: str, alarm_description: str, metric_name: str, metric_namespace: str, metric_statistic: str, metric_period: int, metric_threshold: float, metric_comparison_operator: str, metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list) -> None: + self.LOGGER.info(f"DEBUG: Alarm actions: {alarm_actions}") try: if not self.find_metric_alarm(alarm_name): self.CLOUDWATCH_CLIENT.put_metric_alarm( @@ -141,8 +142,8 @@ def create_metric_alarm(self, alarm_name: str, alarm_description: str, metric_na TreatMissingData=metric_treat_missing_data, AlarmActions=alarm_actions, ) - except ClientError: - self.LOGGER.info(self.UNEXPECTED) + except ClientError as e: + self.LOGGER.info(f"{self.UNEXPECTED} error: {e}") def delete_metric_alarm(self, alarm_name: str) -> None: try: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index ca78722f8..a1789e2c9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -2,7 +2,7 @@ AWSTemplateFormatVersion: '2010-09-09' Description: CloudFormation template to create a Lambda function and its execution role Parameters: - pSraRepoZipUrl: + pSRARepoZipUrl: Type: String Default: 'https://github.com/liamschn/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip' Description: The S3 URL for the SRA solution zip file @@ -51,7 +51,7 @@ Parameters: Type: String Default: 'sra-bedrock-org' - pSraSolutionVersion: + pSRASolutionVersion: Type: String Default: '1.0.0' Description: The version of the SRA solution @@ -229,10 +229,10 @@ Metadata: - Label: default: SRA Solution Configuration Parameters: - - pSraRepoZipUrl + - pSRARepoZipUrl - pDryRun - pSRASolutionName - - pSraSolutionVersion + - pSRASolutionVersion - pSRAStagingS3BucketName - pSRAAlarmEmail - Label: @@ -292,7 +292,7 @@ Metadata: - pBedrockBucketChangesFilterParams ParameterLabels: - pSraRepoZipUrl: + pSRARepoZipUrl: default: SRA Repo Zip URL pDryRun: default: Dry Run @@ -306,7 +306,7 @@ Metadata: default: Lambda Log Level pSRASolutionName: default: SRA Solution Name - pSraSolutionVersion: + pSRASolutionVersion: default: SRA Solution Version pSRAAlarmEmail: default: SRA Alarm Email @@ -379,14 +379,14 @@ Resources: Type: Custom::LambdaCustomResource Properties: ServiceToken: !GetAtt rBedrockOrgLambdaFunction.Arn - SRA_REPO_ZIP_URL: !Ref pSraRepoZipUrl + SRA_REPO_ZIP_URL: !Ref pSRARepoZipUrl DRY_RUN: !Ref pDryRun EXECUTION_ROLE_NAME: !Ref pSRAExecutionRoleName LOG_GROUP_DEPLOY: !Ref pDeployLambdaLogGroup LOG_GROUP_RETENTION: !Ref pLogGroupRetention LOG_LEVEL: !Ref pLambdaLogLevel SOLUTION_NAME: !Ref pSRASolutionName - SOLUTION_VERSION: !Ref pSraSolutionVersion + SOLUTION_VERSION: !Ref pSRASolutionVersion SRA_ALARM_EMAIL: !Ref pSRAAlarmEmail SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET: !Ref pBedrockModelEvalBucketRuleParams SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams From 3a1ab867188ce20dfb843d5bcea17123147591b3 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 20 Sep 2024 17:08:45 -0600 Subject: [PATCH 092/395] fix minor bug with alarm name --- .../solutions/genai/bedrock_org/lambda/src/app.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 9bab4dddd..0eedb81af 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -500,18 +500,23 @@ def delete_event(event, context): else: LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-configuration SNS topic" + else: + LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic does not exist.") + # 1b) Delete the alarm topic - alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarm") + alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") if alarm_topic_search is not None: if DRY_RUN is False: - LOGGER.info(f"Deleting {SOLUTION_NAME}-alarm SNS topic") - LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarm SNS topic" + LOGGER.info(f"Deleting {SOLUTION_NAME}-alarms SNS topic") + LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarms SNS topic" sns.delete_sns_topic(alarm_topic_search) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 else: - LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-alarm SNS topic") - DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarm SNS topic" + LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic" + else: + LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") # 3) Delete metric alarms and filters for filter in CLOUDWATCH_METRIC_FILTERS: From b68f5d504f8020b2db9d6529f47105fd59083a97 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 24 Sep 2024 17:34:28 -0600 Subject: [PATCH 093/395] add kms module; alarm sns topic use cmk --- .../genai/bedrock_org/lambda/src/app.py | 72 +++++- .../bedrock_org/lambda/src/sra-kms-keys.json | 17 ++ .../genai/bedrock_org/lambda/src/sra_kms.py | 222 ++++++++++++++++++ .../genai/bedrock_org/lambda/src/sra_sns.py | 9 +- 4 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0eedb81af..fc2150b7a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -15,6 +15,7 @@ import sra_sns import sra_config import sra_cloudwatch +import sra_kms from typing import Dict, Any @@ -47,6 +48,10 @@ def load_cloudwatch_metric_filters() -> dict: with open("sra-cloudwatch-metric-filters.json", "r") as file: return json.load(file) +def load_kms_key_policies() -> dict: + with open("sra-kms-keys.json", "r") as file: + return json.load(file) + # Global vars RESOURCE_TYPE: str = "" @@ -84,6 +89,8 @@ def load_cloudwatch_metric_filters() -> dict: LIVE_RUN_DATA: dict = {} IAM_POLICY_DOCUMENTS: Dict[str, Any] = load_iam_policy_documents() CLOUDWATCH_METRIC_FILTERS: dict = load_cloudwatch_metric_filters() +KMS_KEY_POLICIES: dict = load_kms_key_policies() +ALARM_SNS_KEY_ALIAS = "sra-alarm-sns-key" # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? @@ -97,6 +104,7 @@ def load_cloudwatch_metric_filters() -> dict: sns = sra_sns.sra_sns() config = sra_config.sra_config() cloudwatch = sra_cloudwatch.sra_cloudwatch() +kms = sra_kms.sra_kms() def get_resource_parameters(event): @@ -287,9 +295,35 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Downloading code library from {repo.REPO_ZIP_URL}") LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") + + # 2) Deploy KMS keys + # 2a) KMS key for SNS topic used by CloudWatch alarms + search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, ALARM_SNS_KEY_ALIAS) + if search_alarm_kms_key is False: + # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section + if DRY_RUN is False: + LOGGER.info("Creating SRA alarm KMS key") + alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS], "Key for CloudWatch Alarm SNS Topic Encryption") + LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") + LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + + # 2c KMS alias for SNS topic used by CloudWatch alarms + LOGGER.info("Creating SRA alarm KMS key alias") + kms.create_alias(kms.KMS_CLIENT, ALARM_SNS_KEY_ALIAS, alarm_key_id) + LIVE_RUN_DATA["KMSAliasCreate"] = "Created SRA alarm KMS key alias" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - # 2) Deploy SNS topics - # 2a) SNS topics for fanout configuration operations + else: + LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") + DRY_RUN_DATA["KMSKeyCreate"] = "DRY_RUN: Create SRA alarm KMS key" + LOGGER.info("DRY_RUN: Creating SRA alarm KMS key alias") + DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" + + # 3) Deploy SNS topics + # 3a) SNS topics for fanout configuration operations # TODO(liamschn): analyze again if the configuration sns topic is needed for this solution (probably is needed) # TODO(liamschn): if needed, then change the code to have the create events call the sns topic which calls the lambda for configuration/deployment topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") @@ -326,12 +360,12 @@ def create_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search - # 2b) SNS topics for alarms + # 3b) SNS topics for alarms topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") if topic_search is None: if DRY_RUN is False: LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") - SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME) + SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME, kms_key=alarm_key_id) LIVE_RUN_DATA["SNSAlarmTopic"] = f"Created {SOLUTION_NAME}-alarms SNS topic (ARN: {SRA_ALARM_TOPIC_ARN})" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 @@ -362,16 +396,18 @@ def create_event(event, context): LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") SRA_ALARM_TOPIC_ARN = topic_search - # 3) Deploy config rules + # 4) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: rule_name = rule.replace("_", "-") # Get bedrock solution rule accounts and regions rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) if rule_deploy is False: continue - + for acct in rule_accounts: if DRY_RUN is False: + # 4a) Deploy IAM role for custom config rule lambda + LOGGER.info(f"Deploying IAM role for custom config rule lambda in {acct}") role_arn = deploy_iam_role(acct, rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "Deployed IAM role for custom config rule lambda" else: @@ -380,7 +416,7 @@ def create_event(event, context): for acct in rule_accounts: for region in rule_regions: - # 3b) Deploy lambda for custom config rule + # 4b) Deploy lambda for custom config rule if DRY_RUN is False: lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "Deployed custom config lambda function" @@ -390,7 +426,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Deploying lambda for custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "DRY_RUN: Deploy custom config lambda function" - # 3c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) + # 4c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) if DRY_RUN is False: config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region, rule_input_params) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "Deployed custom config rule" @@ -400,7 +436,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" - # 4) deploy cloudwatch metric filters + # 5) deploy cloudwatch metric filters for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_params = get_filter_params(filter, event) LOGGER.info(f"{filter} parameters: ") @@ -518,6 +554,24 @@ def delete_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") + # 2) Delete KMS key (schedule deletion) + search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, ALARM_SNS_KEY_ALIAS) + if search_alarm_kms_key is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key") + LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key" + kms.delete_alias(kms.KMS_CLIENT, ALARM_SNS_KEY_ALIAS) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + kms.schedule_key_deletion(kms.KMS_CLIENT, alarm_key_id) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key") + DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key" + LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" # 3) Delete metric alarms and filters for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_params = get_filter_params(filter, event) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json new file mode 100644 index 000000000..89ae5d597 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json @@ -0,0 +1,17 @@ +{ + "sra-alarm-sns-key": { + "Version": "2012-10-17", + "Id": "sra-alarm-sns-key", + "Statement": [ + { + "Sid": "Allow CloudWatch SNS CMK Access", + "Effect": "Allow", + "Principal": { + "Service": ["cloudwatch.amazonaws.com"] + }, + "Action": ["kms:Decrypt", "kms:GenerateDataKey*"], + "Resource": "*" + } + ] + } +} diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py new file mode 100644 index 000000000..52addcda9 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -0,0 +1,222 @@ +"""Custom Resource to setup SRA IAM resources in the management account. + +Version: 1.0 + +KMS module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +import logging +import os + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from mypy_boto3_kms.client import KMSClient + + +import boto3 +from botocore.config import Config + +import urllib.parse +import json + + +class sra_kms: + # Setup Default Logger + LOGGER = logging.getLogger(__name__) + log_level: str = os.environ.get("LOG_LEVEL", "INFO") + LOGGER.setLevel(log_level) + + # Global Variables + RESOURCE_TYPE: str = "" + UNEXPECTED = "Unexpected!" + BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) + SRA_SOLUTION_NAME = "sra-common-prerequisites" + CFN_RESOURCE_ID: str = "sra-iam-function" + CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" + + CONFIGURATION_ROLE: str = "" + TARGET_ACCOUNT_ID: str = "" + ORG_ID: str = "" + + KEY_ALIAS: str = "alias/sra-secrets-key" # todo(liamschn): parameterize this alias name + KEY_DESCRIPTION: str = "SRA Secrets Key" # todo(liamschn): parameterize this description + EXECUTION_ROLE: str = "sra-execution" # todo(liamschn): parameterize this role name + SECRETS_PREFIX: str = "sra" # todo(liamschn): parameterize this? + SECRETS_KEY_POLICY: str = "" + + try: + MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + STS_CLIENT = boto3.client("sts") + HOME_REGION = MANAGEMENT_ACCOUNT_SESSION.region_name + LOGGER.info(f"Detected home region: {HOME_REGION}") + SM_HOST_NAME = urllib.parse.urlparse(boto3.client("secretsmanager", region_name=HOME_REGION).meta.endpoint_url).hostname + MANAGEMENT_ACCOUNT = STS_CLIENT.get_caller_identity().get("Account") + PARTITION: str = boto3.session.Session().get_partition_for_region(HOME_REGION) + LOGGER.info(f"Detected management account (current account): {MANAGEMENT_ACCOUNT}") + KMS_CLIENT: KMSClient = MANAGEMENT_ACCOUNT_SESSION.client("kms", config=BOTO3_CONFIG) + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def define_key_policy(self, target_account_id, partition, home_region, org_id, management_account): + policy_template = { # noqa ECE001 + "Version": "2012-10-17", + "Id": "sra-secrets-key", + "Statement": [ + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": {"AWS": "arn:" + partition + ":iam::" + target_account_id + ":root"}, + "Action": "kms:*", + "Resource": "*", + }, + { + "Sid": "Allow access through AWS Secrets Manager for all principals in the account that are authorized to use AWS Secrets Manager", + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*", "kms:ReEncrypt*", "kms:CreateGrant", "kms:DescribeKey"], + "Resource": "*", + "Condition": { + "StringEquals": {"kms:ViaService": "secretsmanager." + home_region + ".amazonaws.com", "aws:PrincipalOrgId": org_id}, + "StringLike": { + "kms:EncryptionContext:SecretARN": "arn:aws:secretsmanager:" + home_region + ":*:secret:sra/*", + "aws:PrincipalArn": "arn:" + partition + ":iam::*:role/sra-execution", + }, + }, + }, + { + "Sid": "Allow direct access to key metadata", + "Effect": "Allow", + "Principal": {"AWS": "arn:" + partition + ":iam::" + management_account + ":root"}, + "Action": ["kms:Decrypt", "kms:Describe*", "kms:Get*", "kms:List*"], + "Resource": "*", + }, + { + "Sid": "Allow alias creation during setup", + "Effect": "Allow", + "Principal": {"AWS": "arn:" + partition + ":iam::" + target_account_id + ":root"}, + "Action": "kms:CreateAlias", + "Resource": "*", + "Condition": { + "StringEquals": {"kms:ViaService": "cloudformation." + home_region + ".amazonaws.com", "kms:CallerAccount": target_account_id} + }, + }, + ], + } + self.LOGGER.info(f"Key Policy:\n{json.dumps(policy_template)}") + self.SECRETS_KEY_POLICY = json.dumps(policy_template) + return json.dumps(policy_template) + + def assume_role(self, account, role_name, service, region_name): + """Get boto3 client assumed into an account for a specified service. + + Args: + account: aws account id + service: aws service + region_name: aws region + + Returns: + client: boto3 client + """ + client = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") + sts_response = client.assume_role( + RoleArn="arn:" + self.PARTITION + ":iam::" + account + ":role/" + role_name, + RoleSessionName="SRA-AssumeCrossAccountRole", + DurationSeconds=900, + ) + + return self.MANAGEMENT_ACCOUNT_SESSION.client( + service, + region_name=region_name, + aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], + aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], + aws_session_token=sts_response["Credentials"]["SessionToken"], + ) + + def create_kms_key(self, kms_client, key_policy, description="Key description"): + """_summary_ + + Args: + kms_client (KMSClient): KMS boto3 client + key_policy (dict): key policy + description (str, optional): Description of KMS key. Defaults to "Key description". + + Returns: + str: KMS key id + """ + key_response = kms_client.create_key( + Policy=key_policy, + Description=description, + KeyUsage="ENCRYPT_DECRYPT", + CustomerMasterKeySpec="SYMMETRIC_DEFAULT", + ) + return key_response["KeyMetadata"]["KeyId"] + + # def apply_key_policy(kms_client, key_id, key_policy): + # kms_client.put_key_policy(KeyId=key_id, PolicyName="default", Policy=json.dumps(key_policy), BypassPolicyLockoutSafetyCheck=False) + + def create_alias(self, kms_client, alias_name, target_key_id): + kms_client.create_alias(AliasName=alias_name, TargetKeyId=target_key_id) + + def delete_alias(self, kms_client, alias_name): + kms_client.delete_alias(AliasName=alias_name) + + def schedule_key_deletion(self, kms_client, key_id, pending_window_in_days=30): + kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=pending_window_in_days) + + def search_key_policies(self, kms_client): + for key in self.list_all_keys(kms_client): + for policy in self.list_key_policies(kms_client, key["KeyId"]): + policy_body = kms_client.get_key_policy(KeyId=key["KeyId"], PolicyName=policy)["Policy"] + policy_body = json.loads(policy_body) + self.LOGGER.info(f"Key policy: {policy_body}") + self.LOGGER.info(f"SECRETS_KEY_POLICY: {self.SECRETS_KEY_POLICY}") + secrets_key_policy = json.loads(self.SECRETS_KEY_POLICY) + if policy_body == secrets_key_policy: + self.LOGGER.info(f"Key policy match found for key {key['KeyId']} policy {policy}: {policy_body}") + self.LOGGER.info(f"Attempted to match to: {secrets_key_policy}") + return True, key["KeyId"] + else: + self.LOGGER.info(f"No key policy match found for key {key['KeyId']} policy {policy}: {policy_body}") + self.LOGGER.info(f"Attempted to match to: {secrets_key_policy}") + return False, "None" + + def list_key_policies(self, kms_client, key_id): + response = kms_client.list_key_policies(KeyId=key_id) + return response["PolicyNames"] + + def list_all_keys(self, kms_client): + response = kms_client.list_keys() + return response["Keys"] + + def check_key_exists(self, kms_client, key_id): + try: + response = kms_client.describe_key(KeyId=key_id) + return True, response + except kms_client.exceptions.NotFoundException: + return False, None + + def check_alias_exists(self, kms_client, alias_name): + """Check if an alias exists in KMS. + + Args: + kms_client (kms_client): KMS boto3 client + alias_name (str): alias name to check for + + Returns: + tuple (bool, str, str): (exists, alias_name, target_key_id) + """ + try: + response = kms_client.list_aliases() + for alias in response["Aliases"]: + if alias["AliasName"] == alias_name: + return True, alias["AliasName"], alias["TargetKeyId"] + return False, "", "" + except Exception as e: + self.LOGGER.info(f"Unexpected error: {e}") + return False, "", "" diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index f8530b70c..61eeb5858 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -64,13 +64,18 @@ def find_sns_topic(self, topic_name: str) -> str: else: raise ValueError(f"Error finding SNS topic: {e}") from None - def create_sns_topic(self, topic_name: str, solution_name: str) -> str: + def create_sns_topic(self, topic_name: str, solution_name: str, kms_key: str = "default") -> str: """Create SNS Topic.""" + if kms_key == "default": + self.LOGGER.info("Using default KMS key for SNS topic.") + kms_key = f"arn:{self.sts.PARTITION}:kms:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:alias/aws/sns" + else: + self.LOGGER.info(f"Using provided KMS key '{kms_key}' for SNS topic.") try: response = self.SNS_CLIENT.create_topic( Name=topic_name, Attributes={"DisplayName": topic_name, - "KmsMasterKeyId": f"arn:{self.sts.PARTITION}:kms:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:alias/aws/sns"}, + "KmsMasterKeyId": kms_key}, Tags=[{"Key": "sra-solution", "Value": solution_name}] ) topic_arn = response["TopicArn"] From 612641e97c88504cb4df12b6ccbbd4fa11392c31 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 24 Sep 2024 17:54:11 -0600 Subject: [PATCH 094/395] stringify kms policy --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index fc2150b7a..8b7a81181 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -303,7 +303,7 @@ def create_event(event, context): # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") - alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS], "Key for CloudWatch Alarm SNS Topic Encryption") + alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]), "Key for CloudWatch Alarm SNS Topic Encryption") LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 From 1edbb3a0a6d99ddfec1f2ccfb8eead18fafd106c Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 24 Sep 2024 20:55:11 -0600 Subject: [PATCH 095/395] add iam perms for kms policy --- .../solutions/genai/bedrock_org/lambda/src/app.py | 3 +++ .../genai/bedrock_org/lambda/src/sra-kms-keys.json | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 8b7a81181..2a500bd53 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -303,6 +303,9 @@ def create_event(event, context): # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") + LOGGER.info("Customizing key policy...") + KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"] = \ + KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["sra-alarm-sns-key"]["Statement"][0]["Principal"]["AWS"].replace("ACCOUNT_ID", sts.MANAGEMENT_ACCOUNT) alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]), "Key for CloudWatch Alarm SNS Topic Encryption") LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json index 89ae5d597..a000fe94d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json @@ -3,6 +3,15 @@ "Version": "2012-10-17", "Id": "sra-alarm-sns-key", "Statement": [ + { + "Sid": "Enable IAM User Permissions", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::ACCOUNT_ID:root" + }, + "Action": "kms:*", + "Resource": "*" + }, { "Sid": "Allow CloudWatch SNS CMK Access", "Effect": "Allow", From 6d9a3e2ba110d65eea8690a5796f30365fc25f16 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 24 Sep 2024 21:07:04 -0600 Subject: [PATCH 096/395] minor update --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 2a500bd53..0cf506e96 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -305,7 +305,7 @@ def create_event(event, context): LOGGER.info("Creating SRA alarm KMS key") LOGGER.info("Customizing key policy...") KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"] = \ - KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["sra-alarm-sns-key"]["Statement"][0]["Principal"]["AWS"].replace("ACCOUNT_ID", sts.MANAGEMENT_ACCOUNT) + KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"].replace("ACCOUNT_ID", sts.MANAGEMENT_ACCOUNT) alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]), "Key for CloudWatch Alarm SNS Topic Encryption") LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" From beb1a04a53a1058aa36467f973e89f9129574020 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 24 Sep 2024 21:18:28 -0600 Subject: [PATCH 097/395] update key alias --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0cf506e96..a7e9d04b4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -314,7 +314,7 @@ def create_event(event, context): # 2c KMS alias for SNS topic used by CloudWatch alarms LOGGER.info("Creating SRA alarm KMS key alias") - kms.create_alias(kms.KMS_CLIENT, ALARM_SNS_KEY_ALIAS, alarm_key_id) + kms.create_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}", alarm_key_id) LIVE_RUN_DATA["KMSAliasCreate"] = "Created SRA alarm KMS key alias" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 @@ -563,7 +563,7 @@ def delete_event(event, context): if DRY_RUN is False: LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key") LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key" - kms.delete_alias(kms.KMS_CLIENT, ALARM_SNS_KEY_ALIAS) + kms.delete_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") From 1627d15abae3c7d75685296a41c850582f7822af Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 26 Sep 2024 19:05:22 -0600 Subject: [PATCH 098/395] not working; adding support for prompt injection detection; accounts.regions for filters --- .../genai/bedrock_org/lambda/src/app.py | 26 +++++++-- .../genai/bedrock_org/lambda/src/sra_sts.py | 30 ++++++----- .../templates/sra-bedrock-org-main.yaml | 54 ++++++++----------- 3 files changed, 60 insertions(+), 50 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a7e9d04b4..6f2198c5a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -222,6 +222,8 @@ def get_filter_params(filter_name, event): Returns: tuple: (filter_deploy, filter_accounts, filter_regions, filter_pattern) filter_deploy (bool): whether to deploy the filter + filter_accounts (list): list of accounts to deploy the filter to + filter_regions (list): list of regions to deploy the filter to filter_params (dict): dictionary of filter parameters """ if filter_name.upper() in event["ResourceProperties"]: @@ -239,6 +241,20 @@ def get_filter_params(filter_name, event): else: LOGGER.info(f"{filter_name.upper()} 'deploy' parameter not found in event ResourceProperties; setting to False") filter_deploy = False + if "accounts" in metric_filter_params: + LOGGER.info(f"{filter_name.upper()} 'accounts' parameter found in event ResourceProperties") + filter_accounts = metric_filter_params["accounts"] + LOGGER.info(f"{filter_name.upper()} accounts: {filter_accounts}") + else: + LOGGER.info(f"{filter_name.upper()} 'accounts' parameter not found in event ResourceProperties") + filter_accounts = [] + if "regions" in metric_filter_params: + LOGGER.info(f"{filter_name.upper()} 'regions' parameter found in event ResourceProperties") + filter_regions = metric_filter_params["regions"] + LOGGER.info(f"{filter_name.upper()} regions: {filter_regions}") + else: + LOGGER.info(f"{filter_name.upper()} 'regions' parameter not found in event ResourceProperties") + filter_regions = [] if "filter_params" in metric_filter_params: LOGGER.info(f"{filter_name.upper()} 'filter_params' parameter found in event ResourceProperties") filter_params = metric_filter_params["filter_params"] @@ -248,8 +264,8 @@ def get_filter_params(filter_name, event): filter_params = {} else: LOGGER.info(f"{filter_name.upper()} config rule parameter not found in event ResourceProperties; skipping...") - return False, {} - return filter_deploy, filter_params + return False, [], [], {} + return filter_deploy, filter_accounts, filter_regions, filter_params def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: str) -> str: @@ -441,8 +457,10 @@ def create_event(event, context): # 5) deploy cloudwatch metric filters for filter in CLOUDWATCH_METRIC_FILTERS: - filter_deploy, filter_params = get_filter_params(filter, event) - LOGGER.info(f"{filter} parameters: ") + filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) + LOGGER.info(f"{filter} parameters: {filter_params}") + if filter_deploy is False: + continue if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter]) else: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index d0603c76e..031f71c4b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -69,21 +69,25 @@ def assume_role(self, account, role_name, service, region_name): Returns: client: boto3 client """ - print(f"ASSUME ROLE INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") + self.LOGGER.info(f"ASSUME ROLE CALLER ID INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") client = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") - sts_response = client.assume_role( - RoleArn="arn:" + self.PARTITION + ":iam::" + account + ":role/" + role_name, - RoleSessionName="SRA-AssumeCrossAccountRole", - DurationSeconds=900, - ) + if account != self.MANAGEMENT_ACCOUNT: + sts_response = client.assume_role( + RoleArn="arn:" + self.PARTITION + ":iam::" + account + ":role/" + role_name, + RoleSessionName="SRA-AssumeCrossAccountRole", + DurationSeconds=900, + ) + + return self.MANAGEMENT_ACCOUNT_SESSION.client( + service, + region_name=region_name, + aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], + aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], + aws_session_token=sts_response["Credentials"]["SessionToken"], + ) + else: + return self.MANAGEMENT_ACCOUNT_SESSION.client(service, region_name=region_name) - return self.MANAGEMENT_ACCOUNT_SESSION.client( - service, - region_name=region_name, - aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], - aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], - aws_session_token=sts_response["Credentials"]["SessionToken"], - ) def assume_role_resource(self, account, role_name, service, region_name): """Get boto3 resource assumed into an account for a specified service. diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index a1789e2c9..b57c2e075 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -205,9 +205,9 @@ Parameters: pBedrockServiceChangesFilterParams: # TODO(liamschn): update default value of pBedrockServiceChangesFilterParams prior to production Type: String - Default: '{"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' + Default: '{"deploy": "true", "accounts": ["891377138368"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' Description: Bedrock Service Changes Filter Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with 'log_group_name' (non-empty string). Example: {"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}} @@ -215,14 +215,25 @@ Parameters: pBedrockBucketChangesFilterParams: # TODO(liamschn): update default value of pBedrockBucketChangesFilterParams prior to production Type: String - Default: '{"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}}' + Default: '{"deploy": "true", "accounts": ["891377138368"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}}' Description: Bedrock S3 Bucket Changes Filter Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$ ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with required parameters: 'log_group_name' (non-empty string) and 'bucket_names' (non-empty array of non-empty strings). Example: {"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}} + pBedrockPromptInjectionFilterParams: + # TODO(liamschn): update default value of pBedrockPromptInjectionFilterParams prior to production + Type: String + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test"}}' + Description: Bedrock Service Changes Filter Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with + 'log_group_name' (non-empty string). Example: {"deploy": "true", "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test"}} + + Metadata: AWS::CloudFormation::Interface: ParameterGroups: @@ -247,49 +258,23 @@ Metadata: - pLogGroupRetention - pLambdaLogLevel - Label: - default: Bedrock Model Evaluation Bucket Rule + default: Bedrock AWS Config Rules Parameters: - pBedrockModelEvalBucketRuleParams - - Label: - default: Bedrock IAM User Access Rule - Parameters: - pBedrockIAMUserAccessRuleParams - - Label: - default: Bedrock Guardrails Rule - Parameters: - pBedrockGuardrailsRuleParams - - Label: - default: Bedrock VPC Endpoints Rule - Parameters: - pBedrockVPCEndpointsRuleParams - - Label: - default: Bedrock Model Invocation Logging to CloudWatch Rule - Parameters: - pBedrockInvocationLogCWRuleParams - - Label: - default: Bedrock Model Invocation Logging to S3 Rule - Parameters: - pBedrockInvocationLogS3RuleParams - - Label: - default: Bedrock CloudWatch VPC Gateway Endpoint Rule - Parameters: - pBedrockCWEndpointsRuleParams - - Label: - default: Bedrock S3 VPC Gateway Endpoint Rule - Parameters: - pBedrockS3EndpointsRuleParams - - Label: - default: Bedrock Guardrail KMS Encryption Rule - Parameters: - pBedrockGuardrailEncryptionRuleParams - Label: - default: Bedrock Service Changes Filter + default: Bedrock CloudWatch Metric Filters Parameters: - pBedrockServiceChangesFilterParams - - Label: - default: Bedrock S3 Bucket Changes Filter - Parameters: - pBedrockBucketChangesFilterParams + - pBedrockPromptInjectionFilterParams ParameterLabels: pSRARepoZipUrl: @@ -336,6 +321,8 @@ Metadata: default: Bedrock Service Changes Filter Parameters pBedrockBucketChangesFilterParams: default: Bedrock S3 Bucket Changes Filter Parameters + pBedrockPromptInjectionFilterParams: + default: Bedrock Prompt Injection Filter Parameters Resources: rBedrockOrgLambdaRole: @@ -399,6 +386,7 @@ Resources: SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION: !Ref pBedrockGuardrailEncryptionRuleParams SRA-BEDROCK-FILTER-SERVICE-CHANGES: !Ref pBedrockServiceChangesFilterParams SRA-BEDROCK-FILTER-BUCKET-CHANGES: !Ref pBedrockBucketChangesFilterParams + SRA-BEDROCK-FILTER-PROMPT-INJECTION: !Ref pBedrockPromptInjectionFilterParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 060bc31d57b43a50b06aac2f52b1901c2a4e0e12 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 27 Sep 2024 11:38:03 -0600 Subject: [PATCH 099/395] completing metric filter updates; prompt injection --- .../genai/bedrock_org/lambda/src/app.py | 79 ++++++++++--------- .../src/sra-cloudwatch-metric-filters.json | 3 +- .../templates/sra-bedrock-org-main.yaml | 9 ++- 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 6f2198c5a..f05c17525 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -463,46 +463,53 @@ def create_event(event, context): continue if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter]) + if "INPUT_PATH" in CLOUDWATCH_METRIC_FILTERS[filter]: + filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter].replace("", filter_params["input_path"]) else: filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter] LOGGER.info(f"{filter} filter pattern: {filter_pattern}") - if DRY_RUN is False: - if filter_deploy is True: - LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter} CloudWatch metric filter...") - deploy_metric_filter(filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock", "1") - LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info(f"DEBUG: Alarm topic ARN: {SRA_ALARM_TOPIC_ARN}") - deploy_metric_alarm( - f"{filter}-alarm", - f"{filter}-metric alarm", - f"{filter}-metric", - "sra-bedrock", - "Sum", - 10, - 1, - 0, - "GreaterThanThreshold", - "missing", - [SRA_ALARM_TOPIC_ARN], - ) - LIVE_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - else: - LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") - LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" - else: - if filter_deploy is True: - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric filter...") - DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'true'; Deploy CloudWatch metric filter" - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric alarm...") - DRY_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" - else: - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") - DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" + for acct in filter_accounts: + for region in filter_regions: + + if DRY_RUN is False: + if filter_deploy is True: + cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) + LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter} CloudWatch metric filter...") + deploy_metric_filter(filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock", "1") + LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info(f"DEBUG: Alarm topic ARN: {SRA_ALARM_TOPIC_ARN}") + deploy_metric_alarm( + f"{filter}-alarm", + f"{filter}-metric alarm", + f"{filter}-metric", + "sra-bedrock", + "Sum", + 10, + 1, + 0, + "GreaterThanThreshold", + "missing", + [SRA_ALARM_TOPIC_ARN], + ) + LIVE_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + else: + LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") + LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" + else: + if filter_deploy is True: + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric filter...") + DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'true'; Deploy CloudWatch metric filter" + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric alarm...") + DRY_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" + else: + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") + DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index ee33fb8e9..48f22b978 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,4 +1,5 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", - "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket*\" || ($.eventName = \"PutBucket*\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"*PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }" + "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket*\" || ($.eventName = \"PutBucket*\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"*PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", + "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. = *ignore* || $. = *disregard* || $. = *you are now* || $. = *forget* || $. = *new task* || $. = *override* || $. = *bypass* || $. = *don't follow* || $. = *pretend* || $. = *no longer have to* || $. = *core values* || $. = *act as if* || $. = *prime directive* || $. = *from now on* || $. = *free from* || $. = *new purpose* || $. = *unrestricted*) }" } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index b57c2e075..60b7f3bbe 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -226,13 +226,14 @@ Parameters: pBedrockPromptInjectionFilterParams: # TODO(liamschn): update default value of pBedrockPromptInjectionFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test"}}' + Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test", "input_path": "input.inputBodyJson.messages[0].content"}}' Description: Bedrock Service Changes Filter Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with - 'log_group_name' (non-empty string). Example: {"deploy": "true", "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test"}} - + 'log_group_name' (non-empty string). Examples - for claude: {"deploy": "true", "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test", "input_path": "input.inputBodyJson.messages[0].content"}} + or for titan: {"deploy": "true", "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test", "input_path": "input.inputBodyJson.inputText"}} + NOTE: input_path is based on the base model used such as clause or titan; check the invocation log InvokeModel messages for details Metadata: AWS::CloudFormation::Interface: From bf3b6c03150ddbe1dcdfd12f21ab44ed200643d7 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 27 Sep 2024 14:18:37 -0600 Subject: [PATCH 100/395] troubleshooting kms --- .../genai/bedrock_org/lambda/src/app.py | 60 +++++++++++-------- .../genai/bedrock_org/lambda/src/sra_kms.py | 3 + 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index f05c17525..2f369e643 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -600,35 +600,43 @@ def delete_event(event, context): DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key" LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" + else: + LOGGER.info(f"{ALARM_SNS_KEY_ALIAS} KMS key does not exist.") # 3) Delete metric alarms and filters for filter in CLOUDWATCH_METRIC_FILTERS: - filter_deploy, filter_params = get_filter_params(filter, event) - if DRY_RUN is False: - # 3a) Delete the CloudWatch metric alarm - LOGGER.info(f"Deleting {filter}-alarm CloudWatch metric alarm") - LIVE_RUN_DATA[f"{filter}-alarm_CloudWatchDelete"] = f"Deleted {filter}-alarm CloudWatch metric alarm" - search_metric_alarm = cloudwatch.find_metric_alarm(f"{filter}-alarm") - if search_metric_alarm is True: - cloudwatch.delete_metric_alarm(f"{filter}-alarm") - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - else: - LOGGER.info(f"{filter}-alarm CloudWatch metric alarm does not exist.") - - # 3b) Delete the CloudWatch metric filter - LOGGER.info(f"Deleting {filter} CloudWatch metric filter") - LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" - search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) - if search_metric_filter is True: - cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter) - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - else: - LOGGER.info(f"{filter} CloudWatch metric filter does not exist.") + filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) + for acct in filter_accounts: + for region in filter_regions: - else: - LOGGER.info(f"DRY_RUN: Deleting {filter} CloudWatch metric filter") - DRY_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter} CloudWatch metric filter" + cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) + + if DRY_RUN is False: + # 3a) Delete the CloudWatch metric alarm + LOGGER.info(f"Deleting {filter}-alarm CloudWatch metric alarm") + LIVE_RUN_DATA[f"{filter}-alarm_CloudWatchDelete"] = f"Deleted {filter}-alarm CloudWatch metric alarm" + search_metric_alarm = cloudwatch.find_metric_alarm(f"{filter}-alarm") + if search_metric_alarm is True: + cloudwatch.delete_metric_alarm(f"{filter}-alarm") + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info(f"{filter}-alarm CloudWatch metric alarm does not exist.") + + # 3b) Delete the CloudWatch metric filter + LOGGER.info(f"Deleting {filter} CloudWatch metric filter") + LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" + search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) + if search_metric_filter is True: + cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info(f"{filter} CloudWatch metric filter does not exist.") + + else: + LOGGER.info(f"DRY_RUN: Deleting {filter} CloudWatch metric filter") + DRY_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter} CloudWatch metric filter" # 4) Delete config rules # TODO(liamschn): deal with invalid rule names diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index 52addcda9..d89cb9d13 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -213,8 +213,11 @@ def check_alias_exists(self, kms_client, alias_name): """ try: response = kms_client.list_aliases() + self.LOGGER.info(f"Aliases: {response['Aliases']}") for alias in response["Aliases"]: + self.LOGGER.info(f"Alias: {alias}") if alias["AliasName"] == alias_name: + self.LOGGER.info(f"Found alias: {alias}") return True, alias["AliasName"], alias["TargetKeyId"] return False, "", "" except Exception as e: From ba7bb431ebfbef07a17ed02958a635113d7b1088 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 27 Sep 2024 14:47:10 -0600 Subject: [PATCH 101/395] still troubleshooting kms --- .../solutions/genai/bedrock_org/lambda/src/app.py | 7 +++++-- .../solutions/genai/bedrock_org/lambda/src/sra_kms.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 2f369e643..4ef33028f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -314,8 +314,9 @@ def create_event(event, context): # 2) Deploy KMS keys # 2a) KMS key for SNS topic used by CloudWatch alarms - search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, ALARM_SNS_KEY_ALIAS) + search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is False: + LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") @@ -340,6 +341,8 @@ def create_event(event, context): DRY_RUN_DATA["KMSKeyCreate"] = "DRY_RUN: Create SRA alarm KMS key" LOGGER.info("DRY_RUN: Creating SRA alarm KMS key alias") DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" + else: + LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") # 3) Deploy SNS topics # 3a) SNS topics for fanout configuration operations @@ -583,7 +586,7 @@ def delete_event(event, context): LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") # 2) Delete KMS key (schedule deletion) - search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, ALARM_SNS_KEY_ALIAS) + search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is True: if DRY_RUN is False: LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index d89cb9d13..9ec485d2a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -211,6 +211,7 @@ def check_alias_exists(self, kms_client, alias_name): Returns: tuple (bool, str, str): (exists, alias_name, target_key_id) """ + self.LOGGER.info(f"Checking alias: {alias_name}") try: response = kms_client.list_aliases() self.LOGGER.info(f"Aliases: {response['Aliases']}") From b3c3f5c6dbaf4273de0203620e0dd06f9ad44937 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 27 Sep 2024 16:34:24 -0600 Subject: [PATCH 102/395] sns alarm topics per region; still troubleshooting kms --- .../genai/bedrock_org/lambda/src/app.py | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 4ef33028f..36b7cd28a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -382,41 +382,6 @@ def create_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search - # 3b) SNS topics for alarms - topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") - if topic_search is None: - if DRY_RUN is False: - LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") - SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME, kms_key=alarm_key_id) - LIVE_RUN_DATA["SNSAlarmTopic"] = f"Created {SOLUTION_NAME}-alarms SNS topic (ARN: {SRA_ALARM_TOPIC_ARN})" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - - LOGGER.info(f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {SOLUTION_NAME}-alarms SNS topic") - # TODO(liamschn): search for policy on SNS topic before adding the policy - sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, sts.MANAGEMENT_ACCOUNT) - LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - - LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {SRA_ALARM_TOPIC_ARN}") - sns.create_sns_subscription(SRA_ALARM_TOPIC_ARN, "email", SRA_ALARM_EMAIL) - LIVE_RUN_DATA["SNSAlarmSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - - else: - LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") - DRY_RUN_DATA["SNSAlarmCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" - - LOGGER.info(f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account") - DRY_RUN_DATA["SNSAlarmPermissions"] = f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" - - LOGGER.info(f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic") - DRY_RUN_DATA["SNSAlarmSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" - else: - LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") - SRA_ALARM_TOPIC_ARN = topic_search # 4) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: @@ -458,7 +423,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" - # 5) deploy cloudwatch metric filters + # 5) deploy cloudwatch metric filters and SNS topics for alarms for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) LOGGER.info(f"{filter} parameters: {filter_params}") @@ -475,6 +440,44 @@ def create_event(event, context): for acct in filter_accounts: for region in filter_regions: + # 5a) SNS topics for alarms + sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) + topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") + if topic_search is None: + if DRY_RUN is False: + LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") + SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME, kms_key=alarm_key_id) + LIVE_RUN_DATA["SNSAlarmTopic"] = f"Created {SOLUTION_NAME}-alarms SNS topic (ARN: {SRA_ALARM_TOPIC_ARN})" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + + LOGGER.info(f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {SOLUTION_NAME}-alarms SNS topic") + # TODO(liamschn): search for policy on SNS topic before adding the policy + sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, sts.MANAGEMENT_ACCOUNT) + LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + + LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {SRA_ALARM_TOPIC_ARN}") + sns.create_sns_subscription(SRA_ALARM_TOPIC_ARN, "email", SRA_ALARM_EMAIL) + LIVE_RUN_DATA["SNSAlarmSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + + else: + LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSAlarmCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" + + LOGGER.info(f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account") + DRY_RUN_DATA["SNSAlarmPermissions"] = f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + + LOGGER.info(f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSAlarmSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" + else: + LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") + SRA_ALARM_TOPIC_ARN = topic_search + + # 5b) Cloudwatch metric filters and alarms if DRY_RUN is False: if filter_deploy is True: cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) From 1633d30abe79d16cd8795e3f9744bd00f9047d93 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 27 Sep 2024 17:19:22 -0600 Subject: [PATCH 103/395] update alarm topics; still troubleshooting kms --- .../genai/bedrock_org/lambda/src/app.py | 33 ++++++++++--------- .../genai/bedrock_org/lambda/src/sra_sns.py | 9 +++-- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 36b7cd28a..7b1478c07 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -442,7 +442,7 @@ def create_event(event, context): # 5a) SNS topics for alarms sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) - topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") + topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) if topic_search is None: if DRY_RUN is False: LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") @@ -573,21 +573,6 @@ def delete_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic does not exist.") - # 1b) Delete the alarm topic - alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms") - if alarm_topic_search is not None: - if DRY_RUN is False: - LOGGER.info(f"Deleting {SOLUTION_NAME}-alarms SNS topic") - LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarms SNS topic" - sns.delete_sns_topic(alarm_topic_search) - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - else: - LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-alarms SNS topic") - DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic" - else: - LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") - # 2) Delete KMS key (schedule deletion) search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is True: @@ -644,6 +629,22 @@ def delete_event(event, context): LOGGER.info(f"DRY_RUN: Deleting {filter} CloudWatch metric filter") DRY_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter} CloudWatch metric filter" + # 3b) Delete the alarm topic + sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) + alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) + if alarm_topic_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {SOLUTION_NAME}-alarms SNS topic") + LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarms SNS topic" + sns.delete_sns_topic(alarm_topic_search) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic" + else: + LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") + # 4) Delete config rules # TODO(liamschn): deal with invalid rule names # TODO(liamschn): deal with invalid account IDs diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 61eeb5858..34a3d69cf 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -47,11 +47,16 @@ class sra_sns: sts = sra_sts.sra_sts() - def find_sns_topic(self, topic_name: str) -> str: + def find_sns_topic(self, topic_name: str, region: str = "default", account: str = "default") -> str: """Find SNS Topic ARN.""" + # get region from SNS_CLIENT + if region == "default": + self.sts.HOME_REGION + if account == "default": + self.sts.MANAGEMENT_ACCOUNT try: response = self.SNS_CLIENT.get_topic_attributes( - TopicArn=f"arn:{self.sts.PARTITION}:sns:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:{topic_name}" + TopicArn=f"arn:{self.sts.PARTITION}:sns:{region}:{account}:{topic_name}" ) return response["Attributes"]["TopicArn"] except ClientError as e: From ac2425ad4d32681ddf79cf66f4d43a44f0fd29cb Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 27 Sep 2024 17:23:47 -0600 Subject: [PATCH 104/395] minor update --- .../solutions/genai/bedrock_org/lambda/src/sra_sns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 34a3d69cf..a0353165d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -51,9 +51,9 @@ def find_sns_topic(self, topic_name: str, region: str = "default", account: str """Find SNS Topic ARN.""" # get region from SNS_CLIENT if region == "default": - self.sts.HOME_REGION + region = self.sts.HOME_REGION if account == "default": - self.sts.MANAGEMENT_ACCOUNT + account= self.sts.MANAGEMENT_ACCOUNT try: response = self.SNS_CLIENT.get_topic_attributes( TopicArn=f"arn:{self.sts.PARTITION}:sns:{region}:{account}:{topic_name}" From 6d9ba5d911281ad12c5004e44ad120fe08cdcfdf Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 27 Sep 2024 18:19:09 -0600 Subject: [PATCH 105/395] split up prompt injection filter --- .../genai/bedrock_org/lambda/src/app.py | 38 ++++++++++++------- .../src/sra-cloudwatch-metric-filters.json | 3 +- .../templates/sra-bedrock-org-main.yaml | 3 +- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 7b1478c07..48d093146 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -48,6 +48,7 @@ def load_cloudwatch_metric_filters() -> dict: with open("sra-cloudwatch-metric-filters.json", "r") as file: return json.load(file) + def load_kms_key_policies() -> dict: with open("sra-kms-keys.json", "r") as file: return json.load(file) @@ -311,7 +312,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Downloading code library from {repo.REPO_ZIP_URL}") LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") - + # 2) Deploy KMS keys # 2a) KMS key for SNS topic used by CloudWatch alarms search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") @@ -321,9 +322,12 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") LOGGER.info("Customizing key policy...") - KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"] = \ - KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"].replace("ACCOUNT_ID", sts.MANAGEMENT_ACCOUNT) - alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]), "Key for CloudWatch Alarm SNS Topic Encryption") + KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ + "Principal" + ]["AWS"].replace("ACCOUNT_ID", sts.MANAGEMENT_ACCOUNT) + alarm_key_id = kms.create_kms_key( + kms.KMS_CLIENT, json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]), "Key for CloudWatch Alarm SNS Topic Encryption" + ) LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 @@ -343,7 +347,7 @@ def create_event(event, context): DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" else: LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") - + # 3) Deploy SNS topics # 3a) SNS topics for fanout configuration operations # TODO(liamschn): analyze again if the configuration sns topic is needed for this solution (probably is needed) @@ -374,7 +378,9 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-configuration SNS topic" - LOGGER.info(f"DRY_RUN: Creating SNS topic policy permissions for {SOLUTION_NAME}-configuration SNS topic on {context.function_name} lambda function") + LOGGER.info( + f"DRY_RUN: Creating SNS topic policy permissions for {SOLUTION_NAME}-configuration SNS topic on {context.function_name} lambda function" + ) DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {SOLUTION_NAME}-configuration SNS topic") @@ -390,7 +396,7 @@ def create_event(event, context): rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) if rule_deploy is False: continue - + for acct in rule_accounts: if DRY_RUN is False: # 4a) Deploy IAM role for custom config rule lambda @@ -427,7 +433,7 @@ def create_event(event, context): for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) LOGGER.info(f"{filter} parameters: {filter_params}") - if filter_deploy is False: + if filter_deploy is False: continue if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter]) @@ -439,7 +445,6 @@ def create_event(event, context): for acct in filter_accounts: for region in filter_regions: - # 5a) SNS topics for alarms sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) @@ -451,7 +456,9 @@ def create_event(event, context): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info(f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {SOLUTION_NAME}-alarms SNS topic") + LOGGER.info( + f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {SOLUTION_NAME}-alarms SNS topic" + ) # TODO(liamschn): search for policy on SNS topic before adding the policy sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, sts.MANAGEMENT_ACCOUNT) LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" @@ -468,8 +475,12 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") DRY_RUN_DATA["SNSAlarmCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" - LOGGER.info(f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account") - DRY_RUN_DATA["SNSAlarmPermissions"] = f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + LOGGER.info( + f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + ) + DRY_RUN_DATA[ + "SNSAlarmPermissions" + ] = f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" LOGGER.info(f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic") DRY_RUN_DATA["SNSAlarmSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" @@ -572,7 +583,7 @@ def delete_event(event, context): DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-configuration SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic does not exist.") - + # 2) Delete KMS key (schedule deletion) search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is True: @@ -598,7 +609,6 @@ def delete_event(event, context): filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) for acct in filter_accounts: for region in filter_regions: - cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index 48f22b978..c00b27269 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,5 +1,6 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket*\" || ($.eventName = \"PutBucket*\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"*PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", - "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. = *ignore* || $. = *disregard* || $. = *you are now* || $. = *forget* || $. = *new task* || $. = *override* || $. = *bypass* || $. = *don't follow* || $. = *pretend* || $. = *no longer have to* || $. = *core values* || $. = *act as if* || $. = *prime directive* || $. = *from now on* || $. = *free from* || $. = *new purpose* || $. = *unrestricted*) }" + "sra-bedrock-filter-prompt-injection-1": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. = *ignore previous* || $. = *disregard earlier* || $. = *you are now a different AI* || $. = *forget your original* || $. = *your new task is* || $. = *override previous* || $. = *bypass safety* || $. = *don't follow your usual* || $. = *pretend you are not an AI* || $. = *no longer have to follow* || $. = *core values are now* || $. = *act as if you were*) }", + "sra-bedrock-filter-prompt-injection-2": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. = *your prime directive is now* || $. = *from now on, you must* || $. = *disregard your training* || $. = *ignore your ethical* || $. = *free from your original* || $. = *your new primary goal* || $. = *override your safety* || $. = *you are no longer bound* || $. = *your new purpose is* || $. = *forget about your previous* || $. = *you are now an unrestricted*) }" } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 60b7f3bbe..d32ac8bce 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -387,7 +387,8 @@ Resources: SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION: !Ref pBedrockGuardrailEncryptionRuleParams SRA-BEDROCK-FILTER-SERVICE-CHANGES: !Ref pBedrockServiceChangesFilterParams SRA-BEDROCK-FILTER-BUCKET-CHANGES: !Ref pBedrockBucketChangesFilterParams - SRA-BEDROCK-FILTER-PROMPT-INJECTION: !Ref pBedrockPromptInjectionFilterParams + SRA-BEDROCK-FILTER-PROMPT-INJECTION-1: !Ref pBedrockPromptInjectionFilterParams + SRA-BEDROCK-FILTER-PROMPT-INJECTION-2: !Ref pBedrockPromptInjectionFilterParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 44622b953c7cfd72fd31d8e663aae26ea924ca32 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 27 Sep 2024 20:02:30 -0600 Subject: [PATCH 106/395] syntax error in filters; still troubleshooting kms --- .../solutions/genai/bedrock_org/lambda/src/app.py | 1 + .../bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 48d093146..ec45a9b72 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -570,6 +570,7 @@ def delete_event(event, context): LOGGER.info("delete event function") # 1) Delete SNS topic # 1a) Delete configuration topic + sns.SNS_CLIENT = sts.assume_role(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "sns", sts.HOME_REGION) topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is not None: if DRY_RUN is False: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index c00b27269..019aff04e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,6 +1,6 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket*\" || ($.eventName = \"PutBucket*\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"*PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", - "sra-bedrock-filter-prompt-injection-1": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. = *ignore previous* || $. = *disregard earlier* || $. = *you are now a different AI* || $. = *forget your original* || $. = *your new task is* || $. = *override previous* || $. = *bypass safety* || $. = *don't follow your usual* || $. = *pretend you are not an AI* || $. = *no longer have to follow* || $. = *core values are now* || $. = *act as if you were*) }", - "sra-bedrock-filter-prompt-injection-2": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. = *your prime directive is now* || $. = *from now on, you must* || $. = *disregard your training* || $. = *ignore your ethical* || $. = *free from your original* || $. = *your new primary goal* || $. = *override your safety* || $. = *you are no longer bound* || $. = *your new purpose is* || $. = *forget about your previous* || $. = *you are now an unrestricted*) }" + "sra-bedrock-filter-prompt-injection-1": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. =~ \".*ignore previous.*\" || $. =~ \".*disregard earlier.*\" || $. =~ \".*you are now a different AI.*\" || $. =~ \".*forget your original.*\" || $. =~ \".*your new task is.*\" || $. =~ \".*override previous.*\" || $. =~ \".*bypass safety.*\" || $. =~ \".*don't follow your usual.*\" || $. =~ \".*pretend you are not an AI.*\" || $. =~ \".*no longer have to follow.*\" || $. =~ \".*core values are now.*\" || $. =~ \".*act as if you were.*\") }", + "sra-bedrock-filter-prompt-injection-2": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. =~ \".*your prime directive is now.*\" || $. =~ \".*from now on, you must.*\" || $. =~ \".*disregard your training.*\" || $. =~ \".*ignore your ethical.*\" || $. =~ \".*free from your original.*\" || $. =~ \".*your new primary goal.*\" || $. =~ \".*override your safety.*\" || $. =~ \".*you are no longer bound.*\" || $. =~ \".*your new purpose is.*\" || $. =~ \".*forget about your previous.*\" || $. =~ \".*you are now an unrestricted.*\") }" } From 2c97ecfc4b652b8abcf30c576acacda80feb31de Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sat, 28 Sep 2024 09:30:54 -0600 Subject: [PATCH 107/395] prompt injection filter changes --- .../lambda/src/sra-cloudwatch-metric-filters.json | 5 ++--- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index 019aff04e..8ebf72130 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,6 +1,5 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", - "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket*\" || ($.eventName = \"PutBucket*\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"*PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", - "sra-bedrock-filter-prompt-injection-1": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. =~ \".*ignore previous.*\" || $. =~ \".*disregard earlier.*\" || $. =~ \".*you are now a different AI.*\" || $. =~ \".*forget your original.*\" || $. =~ \".*your new task is.*\" || $. =~ \".*override previous.*\" || $. =~ \".*bypass safety.*\" || $. =~ \".*don't follow your usual.*\" || $. =~ \".*pretend you are not an AI.*\" || $. =~ \".*no longer have to follow.*\" || $. =~ \".*core values are now.*\" || $. =~ \".*act as if you were.*\") }", - "sra-bedrock-filter-prompt-injection-2": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && ($. =~ \".*your prime directive is now.*\" || $. =~ \".*from now on, you must.*\" || $. =~ \".*disregard your training.*\" || $. =~ \".*ignore your ethical.*\" || $. =~ \".*free from your original.*\" || $. =~ \".*your new primary goal.*\" || $. =~ \".*override your safety.*\" || $. =~ \".*you are no longer bound.*\" || $. =~ \".*your new purpose is.*\" || $. =~ \".*forget about your previous.*\" || $. =~ \".*you are now an unrestricted.*\") }" + "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket%\" || ($.eventName = \"PutBucket%\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"%PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", + "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $.input.inputBodyJson.messages[0].content = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|.*follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}" } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index d32ac8bce..60b7f3bbe 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -387,8 +387,7 @@ Resources: SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION: !Ref pBedrockGuardrailEncryptionRuleParams SRA-BEDROCK-FILTER-SERVICE-CHANGES: !Ref pBedrockServiceChangesFilterParams SRA-BEDROCK-FILTER-BUCKET-CHANGES: !Ref pBedrockBucketChangesFilterParams - SRA-BEDROCK-FILTER-PROMPT-INJECTION-1: !Ref pBedrockPromptInjectionFilterParams - SRA-BEDROCK-FILTER-PROMPT-INJECTION-2: !Ref pBedrockPromptInjectionFilterParams + SRA-BEDROCK-FILTER-PROMPT-INJECTION: !Ref pBedrockPromptInjectionFilterParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From ddb17b5f404cafba531d7f0bb7aa9b45e3f44a44 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sat, 28 Sep 2024 10:10:27 -0600 Subject: [PATCH 108/395] parameterize prompt injection filter --- .../bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index 8ebf72130..a3d4b8121 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,5 +1,5 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket%\" || ($.eventName = \"PutBucket%\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"%PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", - "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $.input.inputBodyJson.messages[0].content = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|.*follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}" + "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $..content = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|.*follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}" } From 5fd5de6ed41ff5de2d49dc13cf8d1bdb1421bff7 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sat, 28 Sep 2024 10:24:23 -0600 Subject: [PATCH 109/395] minor update to prompt injection filter --- .../bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index a3d4b8121..b7d0e7a9c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,5 +1,5 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket%\" || ($.eventName = \"PutBucket%\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"%PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", - "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $..content = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|.*follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}" + "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|.*follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}" } From af31f0c927a050d2d5ef60976b434b6b809e3e41 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sun, 29 Sep 2024 10:03:45 -0600 Subject: [PATCH 110/395] updating sns perms, kms keys, delete/update ops --- .../genai/bedrock_org/lambda/src/app.py | 152 +++++++++--------- 1 file changed, 77 insertions(+), 75 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ec45a9b72..88f99055f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -313,43 +313,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") - # 2) Deploy KMS keys - # 2a) KMS key for SNS topic used by CloudWatch alarms - search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") - if search_alarm_kms_key is False: - LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") - # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section - if DRY_RUN is False: - LOGGER.info("Creating SRA alarm KMS key") - LOGGER.info("Customizing key policy...") - KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ - "Principal" - ]["AWS"].replace("ACCOUNT_ID", sts.MANAGEMENT_ACCOUNT) - alarm_key_id = kms.create_kms_key( - kms.KMS_CLIENT, json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]), "Key for CloudWatch Alarm SNS Topic Encryption" - ) - LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") - LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - - # 2c KMS alias for SNS topic used by CloudWatch alarms - LOGGER.info("Creating SRA alarm KMS key alias") - kms.create_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}", alarm_key_id) - LIVE_RUN_DATA["KMSAliasCreate"] = "Created SRA alarm KMS key alias" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - - else: - LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") - DRY_RUN_DATA["KMSKeyCreate"] = "DRY_RUN: Create SRA alarm KMS key" - LOGGER.info("DRY_RUN: Creating SRA alarm KMS key alias") - DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" - else: - LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") - - # 3) Deploy SNS topics - # 3a) SNS topics for fanout configuration operations + # 2) SNS topics for fanout configuration operations # TODO(liamschn): analyze again if the configuration sns topic is needed for this solution (probably is needed) # TODO(liamschn): if needed, then change the code to have the create events call the sns topic which calls the lambda for configuration/deployment topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") @@ -389,7 +353,7 @@ def create_event(event, context): LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search - # 4) Deploy config rules + # 3) Deploy config rules for rule in repo.CONFIG_RULES[SOLUTION_NAME]: rule_name = rule.replace("_", "-") # Get bedrock solution rule accounts and regions @@ -399,7 +363,7 @@ def create_event(event, context): for acct in rule_accounts: if DRY_RUN is False: - # 4a) Deploy IAM role for custom config rule lambda + # 3a) Deploy IAM role for custom config rule lambda LOGGER.info(f"Deploying IAM role for custom config rule lambda in {acct}") role_arn = deploy_iam_role(acct, rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "Deployed IAM role for custom config rule lambda" @@ -409,7 +373,7 @@ def create_event(event, context): for acct in rule_accounts: for region in rule_regions: - # 4b) Deploy lambda for custom config rule + # 3b) Deploy lambda for custom config rule if DRY_RUN is False: lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "Deployed custom config lambda function" @@ -419,7 +383,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Deploying lambda for custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "DRY_RUN: Deploy custom config lambda function" - # 4c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) + # 3c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) if DRY_RUN is False: config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region, rule_input_params) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "Deployed custom config rule" @@ -429,7 +393,7 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" - # 5) deploy cloudwatch metric filters and SNS topics for alarms + # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) LOGGER.info(f"{filter} parameters: {filter_params}") @@ -445,7 +409,43 @@ def create_event(event, context): for acct in filter_accounts: for region in filter_regions: - # 5a) SNS topics for alarms + # 4a) Deploy KMS keys + # 4ai) KMS key for SNS topic used by CloudWatch alarms + kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) + search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + if search_alarm_kms_key is False: + LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") + # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section + if DRY_RUN is False: + LOGGER.info("Creating SRA alarm KMS key") + LOGGER.info("Customizing key policy...") + KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ + "Principal" + ]["AWS"].replace("ACCOUNT_ID", acct) + alarm_key_id = kms.create_kms_key( + kms.KMS_CLIENT, json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]), "Key for CloudWatch Alarm SNS Topic Encryption" + ) + LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") + LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + + # 4aii KMS alias for SNS topic used by CloudWatch alarms + LOGGER.info("Creating SRA alarm KMS key alias") + kms.create_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}", alarm_key_id) + LIVE_RUN_DATA["KMSAliasCreate"] = "Created SRA alarm KMS key alias" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + + else: + LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") + DRY_RUN_DATA["KMSKeyCreate"] = "DRY_RUN: Create SRA alarm KMS key" + LOGGER.info("DRY_RUN: Creating SRA alarm KMS key alias") + DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" + else: + LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") + + # 4b) SNS topics for alarms sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) if topic_search is None: @@ -457,10 +457,10 @@ def create_event(event, context): CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info( - f"Setting access for CloudWatch alarms in {sts.MANAGEMENT_ACCOUNT} to publish to {SOLUTION_NAME}-alarms SNS topic" + f"Setting access for CloudWatch alarms in {acct} to publish to {SOLUTION_NAME}-alarms SNS topic" ) # TODO(liamschn): search for policy on SNS topic before adding the policy - sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, sts.MANAGEMENT_ACCOUNT) + sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, acct) LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 @@ -488,7 +488,7 @@ def create_event(event, context): LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") SRA_ALARM_TOPIC_ARN = topic_search - # 5b) Cloudwatch metric filters and alarms + # 4c) Cloudwatch metric filters and alarms if DRY_RUN is False: if filter_deploy is True: cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) @@ -585,36 +585,38 @@ def delete_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic does not exist.") - # 2) Delete KMS key (schedule deletion) - search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") - if search_alarm_kms_key is True: - if DRY_RUN is False: - LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key") - LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key" - kms.delete_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") - kms.schedule_key_deletion(kms.KMS_CLIENT, alarm_key_id) - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - else: - LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key") - DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key" - LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") - DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" - else: - LOGGER.info(f"{ALARM_SNS_KEY_ALIAS} KMS key does not exist.") # 3) Delete metric alarms and filters for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) for acct in filter_accounts: for region in filter_regions: + + # 3a) Delete KMS key (schedule deletion) + kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) + search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + if search_alarm_kms_key is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key") + LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key" + kms.delete_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + kms.schedule_key_deletion(kms.KMS_CLIENT, alarm_key_id) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key") + DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key" + LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" + else: + LOGGER.info(f"{ALARM_SNS_KEY_ALIAS} KMS key does not exist.") + cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) - if DRY_RUN is False: - # 3a) Delete the CloudWatch metric alarm + # 3b) Delete the CloudWatch metric alarm LOGGER.info(f"Deleting {filter}-alarm CloudWatch metric alarm") LIVE_RUN_DATA[f"{filter}-alarm_CloudWatchDelete"] = f"Deleted {filter}-alarm CloudWatch metric alarm" search_metric_alarm = cloudwatch.find_metric_alarm(f"{filter}-alarm") @@ -625,7 +627,7 @@ def delete_event(event, context): else: LOGGER.info(f"{filter}-alarm CloudWatch metric alarm does not exist.") - # 3b) Delete the CloudWatch metric filter + # 3c) Delete the CloudWatch metric filter LOGGER.info(f"Deleting {filter} CloudWatch metric filter") LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) @@ -640,7 +642,7 @@ def delete_event(event, context): LOGGER.info(f"DRY_RUN: Deleting {filter} CloudWatch metric filter") DRY_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter} CloudWatch metric filter" - # 3b) Delete the alarm topic + # 3d) Delete the alarm topic sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) if alarm_topic_search is not None: @@ -669,7 +671,7 @@ def delete_event(event, context): for acct in rule_accounts: for region in rule_regions: - # 5) Delete the config rule + # 4a) Delete the config rule config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) if config_rule_search[0] is True: @@ -685,7 +687,7 @@ def delete_event(event, context): LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" - # 6) Delete lambda for custom config rule + # 4b) Delete lambda for custom config rule lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) lambda_search = lambdas.find_lambda_function(rule_name) if lambda_search is not None: @@ -701,7 +703,7 @@ def delete_event(event, context): else: LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") - # 7) Detach IAM policies + # 5) Detach IAM policies # TODO(liamschn): handle case where policy is not found attached_policies = None iam.IAM_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "iam", REGION) attached_policies = iam.list_attached_iam_policies(rule_name) @@ -720,7 +722,7 @@ def delete_event(event, context): f"{rule_name}_{acct}_{region}_Delete" ] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" - # 8) Delete IAM policy + # 6) Delete IAM policy policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" LOGGER.info(f"Policy ARN: {policy_arn}") policy_search = iam.check_iam_policy_exists(policy_arn) @@ -753,7 +755,7 @@ def delete_event(event, context): f"{rule_name}_{acct}_{region}_PolicyDelete" ] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" - # 9) Delete IAM execution role for custom config rule lambda + # 7) Delete IAM execution role for custom config rule lambda role_search = iam.check_iam_role_exists(rule_name) if role_search[0] is True: if DRY_RUN is False: From 7b90d619977e2676590606d6500a599d182c186a Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sun, 29 Sep 2024 11:28:45 -0600 Subject: [PATCH 111/395] add logging for policy --- .../solutions/genai/bedrock_org/lambda/src/sra_kms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index 9ec485d2a..f502be331 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -149,6 +149,7 @@ def create_kms_key(self, kms_client, key_policy, description="Key description"): Returns: str: KMS key id """ + self.LOGGER.info(f"Key policy: {key_policy}") key_response = kms_client.create_key( Policy=key_policy, Description=description, From a21bee89c670da3585be1ecac8f705f696b88556 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sun, 29 Sep 2024 12:07:14 -0600 Subject: [PATCH 112/395] make kms policy variable --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 88f99055f..57401cc0a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -419,11 +419,9 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") LOGGER.info("Customizing key policy...") - KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ - "Principal" - ]["AWS"].replace("ACCOUNT_ID", acct) + kms_key_policy = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"].replace("ACCOUNT_ID", acct) alarm_key_id = kms.create_kms_key( - kms.KMS_CLIENT, json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]), "Key for CloudWatch Alarm SNS Topic Encryption" + kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption" ) LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" From b708d08fe93d74dae914c4878841787827de8645 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Sun, 29 Sep 2024 12:22:38 -0600 Subject: [PATCH 113/395] mod to the kms policy var --- .../solutions/genai/bedrock_org/lambda/src/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 57401cc0a..cfc1dc9b4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -419,7 +419,10 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") LOGGER.info("Customizing key policy...") - kms_key_policy = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"]["AWS"].replace("ACCOUNT_ID", acct) + kms_key_policy = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS] + kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ + "Principal" + ]["AWS"].replace("ACCOUNT_ID", acct) alarm_key_id = kms.create_kms_key( kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption" ) From caa54e19412ecd0c5a7fabdf14fd7a2a47d0ce59 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 30 Sep 2024 10:31:46 -0600 Subject: [PATCH 114/395] mod to kms var again --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index cfc1dc9b4..2c70fcdcc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -419,7 +419,7 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") LOGGER.info("Customizing key policy...") - kms_key_policy = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS] + kms_key_policy = dict(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]) kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ "Principal" ]["AWS"].replace("ACCOUNT_ID", acct) From 1f9348161612df2de2de2aea3b164b04ef0f0e21 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 30 Sep 2024 10:51:03 -0600 Subject: [PATCH 115/395] mod to kms var again again --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 2c70fcdcc..2d16691b4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -420,9 +420,11 @@ def create_event(event, context): LOGGER.info("Creating SRA alarm KMS key") LOGGER.info("Customizing key policy...") kms_key_policy = dict(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]) + LOGGER.info(f"kms_key_policy: {kms_key_policy}") kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ "Principal" ]["AWS"].replace("ACCOUNT_ID", acct) + LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") alarm_key_id = kms.create_kms_key( kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption" ) From 5490287d356c9e52bae6e98ea7d76db9c6bb691d Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 30 Sep 2024 11:14:49 -0600 Subject: [PATCH 116/395] mod to kms var again last time? --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 2d16691b4..afa825dbc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -419,7 +419,7 @@ def create_event(event, context): if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") LOGGER.info("Customizing key policy...") - kms_key_policy = dict(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]) + kms_key_policy = json.loads(json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS])) LOGGER.info(f"kms_key_policy: {kms_key_policy}") kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ "Principal" From 53a784cf527150f2e55b6b259d287a040f0d3e8a Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 2 Oct 2024 16:12:26 -0600 Subject: [PATCH 117/395] sensitive info filter --- .../lambda/src/sra-cloudwatch-metric-filters.json | 3 ++- .../templates/sra-bedrock-org-main.yaml | 15 ++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index b7d0e7a9c..cface0a90 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,5 +1,6 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket%\" || ($.eventName = \"PutBucket%\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"%PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", - "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|.*follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}" + "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|.*follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}", + "sra-bedrock-filter-sensitive-info": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*social security number.*|.*ssn.*|.*\\d{3}-\\d{2}-\\d{4}.*|.*credit card.*|.*ccn.*|.*\\d{4}[- ]\\d{4}[- ]\\d{4}[- ]\\d{4}.*|.*passport.*|.*driver's license.*|.*date of birth.*|.*dob.*|.*\\d{2}/\\d{2}/\\d{4}.*|.*bank account.*|.*routing number.*|.*financial records.*|.*tax information.*|.*password.*|.*PIN.*|.*access code.*|.*security question.*|.*medical record.*|.*health insurance.*|.*diagnosis.*|.*prescription.*|.*trade secret.*|.*proprietary information.*|.*confidential data.*|.*internal documents.*|.*private information.*|.*classified information.*|.*restricted data.*|.*sensitive details.*|.*extract all.*|.*list all users.*|.*show me the database.*|.*give me access.*|.*bypass security.*|.*ignore privacy.*|.*API key.*|.*access token.*|.*secret key.*|.*GPS coordinates.*|.*\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}.*|.*home address.*|.*workplace location.*|.*pii.*|.*phi.*%}" } diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 60b7f3bbe..540b9822f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -223,11 +223,11 @@ Parameters: 'log_group_name' (non-empty string) and 'bucket_names' (non-empty array of non-empty strings). Example: {"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}} - pBedrockPromptInjectionFilterParams: - # TODO(liamschn): update default value of pBedrockPromptInjectionFilterParams prior to production + pBedrockInvocationLogFilterParams: + # TODO(liamschn): update default value of pBedrockInvocationLogFilterParams prior to production Type: String Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test", "input_path": "input.inputBodyJson.messages[0].content"}}' - Description: Bedrock Service Changes Filter Parameters + Description: Bedrock Prompt Injection and Sensitive Info Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with @@ -275,7 +275,7 @@ Metadata: Parameters: - pBedrockServiceChangesFilterParams - pBedrockBucketChangesFilterParams - - pBedrockPromptInjectionFilterParams + - pBedrockInvocationLogFilterParams ParameterLabels: pSRARepoZipUrl: @@ -322,8 +322,8 @@ Metadata: default: Bedrock Service Changes Filter Parameters pBedrockBucketChangesFilterParams: default: Bedrock S3 Bucket Changes Filter Parameters - pBedrockPromptInjectionFilterParams: - default: Bedrock Prompt Injection Filter Parameters + pBedrockInvocationLogFilterParams: + default: Bedrock Prompt Injection and Sensitive Info Filter Parameters Resources: rBedrockOrgLambdaRole: @@ -387,7 +387,8 @@ Resources: SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION: !Ref pBedrockGuardrailEncryptionRuleParams SRA-BEDROCK-FILTER-SERVICE-CHANGES: !Ref pBedrockServiceChangesFilterParams SRA-BEDROCK-FILTER-BUCKET-CHANGES: !Ref pBedrockBucketChangesFilterParams - SRA-BEDROCK-FILTER-PROMPT-INJECTION: !Ref pBedrockPromptInjectionFilterParams + SRA-BEDROCK-FILTER-PROMPT-INJECTION: !Ref pBedrockInvocationLogFilterParams + SRA-BEDROCK-FILTER-SENSITIVE-INFO: !Ref pBedrockInvocationLogFilterParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 2904a63fd6d8bf5b60107d34e221447d2b813628 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 2 Oct 2024 16:36:12 -0600 Subject: [PATCH 118/395] minor updates --- .../solutions/genai/bedrock_org/lambda/src/app.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index afa825dbc..e0615f14c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -264,7 +264,7 @@ def get_filter_params(filter_name, event): LOGGER.info(f"{filter_name.upper()} 'filter_params' parameter not found in event ResourceProperties") filter_params = {} else: - LOGGER.info(f"{filter_name.upper()} config rule parameter not found in event ResourceProperties; skipping...") + LOGGER.info(f"{filter_name.upper()} filter parameter not found in event ResourceProperties; skipping...") return False, [], [], {} return filter_deploy, filter_accounts, filter_regions, filter_params @@ -394,6 +394,7 @@ def create_event(event, context): DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms + LOGGER.info(f"CloudWatch Metric Filters: {SRA-BEDROCK-FILTER-SENSITIVE-INFO}") for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) LOGGER.info(f"{filter} parameters: {filter_params}") @@ -983,11 +984,11 @@ def deploy_metric_filter(log_group_name: str, filter_name: str, filter_pattern: """ search_metric_filter = cloudwatch.find_metric_filter(log_group_name, filter_name) if search_metric_filter is False: - LOGGER.info(f"Deploying metric filter {filter_name} to {log_group_name}...") if DRY_RUN is False: + LOGGER.info(f"Deploying metric filter {filter_name} to {log_group_name}...") cloudwatch.create_metric_filter(log_group_name, filter_name, filter_pattern, metric_name, metric_namespace, metric_value) else: - LOGGER.info(f"DRY_RUN: Deploying metric filter {filter_name} to {log_group_name}...") + LOGGER.info(f"DRY_RUN: Deploy metric filter {filter_name} to {log_group_name}...") else: LOGGER.info(f"Metric filter {filter_name} already exists.") From 94825e524b4910888975552dfe584ceacc3616f9 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 2 Oct 2024 21:30:35 -0600 Subject: [PATCH 119/395] minor update to filters --- .../bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json index cface0a90..3e8f8813e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json @@ -1,6 +1,6 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket%\" || ($.eventName = \"PutBucket%\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"%PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", - "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|.*follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}", - "sra-bedrock-filter-sensitive-info": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*social security number.*|.*ssn.*|.*\\d{3}-\\d{2}-\\d{4}.*|.*credit card.*|.*ccn.*|.*\\d{4}[- ]\\d{4}[- ]\\d{4}[- ]\\d{4}.*|.*passport.*|.*driver's license.*|.*date of birth.*|.*dob.*|.*\\d{2}/\\d{2}/\\d{4}.*|.*bank account.*|.*routing number.*|.*financial records.*|.*tax information.*|.*password.*|.*PIN.*|.*access code.*|.*security question.*|.*medical record.*|.*health insurance.*|.*diagnosis.*|.*prescription.*|.*trade secret.*|.*proprietary information.*|.*confidential data.*|.*internal documents.*|.*private information.*|.*classified information.*|.*restricted data.*|.*sensitive details.*|.*extract all.*|.*list all users.*|.*show me the database.*|.*give me access.*|.*bypass security.*|.*ignore privacy.*|.*API key.*|.*access token.*|.*secret key.*|.*GPS coordinates.*|.*\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}.*|.*home address.*|.*workplace location.*|.*pii.*|.*phi.*%}" + "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|/.*[dD]on\\x27t follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}", + "sra-bedrock-filter-sensitive-info": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*social security number.*|.*ssn.*|.*\\d{3}-\\d{2}-\\d{4}.*|.*credit card.*|.*ccn.*|.*\\d{4}[- ]\\d{4}[- ]\\d{4}[- ]\\d{4}.*|.*passport.*|.*driver\\x27s license.*|.*date of birth.*|.*dob.*|.*\\d{2}/\\d{2}/\\d{4}.*|.*bank account.*|.*routing number.*|.*financial records.*|.*tax information.*|.*password.*|.*PIN.*|.*access code.*|.*security question.*|.*medical record.*|.*health insurance.*|.*diagnosis.*|.*prescription.*|.*trade secret.*|.*proprietary information.*|.*confidential data.*|.*internal documents.*|.*private information.*|.*classified information.*|.*restricted data.*|.*sensitive details.*|.*extract all.*|.*list all users.*|.*show me the database.*|.*give me access.*|.*bypass security.*|.*ignore privacy.*|.*API key.*|.*access token.*|.*secret key.*|.*GPS coordinates.*|.*\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}.*|.*home address.*|.*workplace location.*|.*pii.*|.*phi.*%}" } From 25463ca0407aa797970243884f80bee3e5ea2c85 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 2 Oct 2024 21:44:22 -0600 Subject: [PATCH 120/395] update logging statement --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index e0615f14c..60b2b7390 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -394,7 +394,7 @@ def create_event(event, context): DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms - LOGGER.info(f"CloudWatch Metric Filters: {SRA-BEDROCK-FILTER-SENSITIVE-INFO}") + LOGGER.info(f"CloudWatch Metric Filters: {CLOUDWATCH_METRIC_FILTERS}") for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) LOGGER.info(f"{filter} parameters: {filter_params}") From 1408521323fca42573b2cbc2800546c67fe9ff08 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Mon, 14 Oct 2024 16:41:59 -0600 Subject: [PATCH 121/395] adding oam stuff (not working yet);changed json file names --- .../genai/bedrock_org/lambda/src/app.py | 17 ++- .../bedrock_org/lambda/src/sra_cloudwatch.py | 101 ++++++++++++++++-- ...son => sra_cloudwatch_metric_filters.json} | 0 .../src/sra_cloudwatch_oam_sink_policy.json | 27 +++++ .../src/sra_cloudwatch_oam_trust_policy.json | 14 +++ ...=> sra_config_lambda_iam_permissions.json} | 0 .../{sra-kms-keys.json => sra_kms_keys.json} | 0 7 files changed, 145 insertions(+), 14 deletions(-) rename aws_sra_examples/solutions/genai/bedrock_org/lambda/src/{sra-cloudwatch-metric-filters.json => sra_cloudwatch_metric_filters.json} (100%) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_oam_sink_policy.json create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_oam_trust_policy.json rename aws_sra_examples/solutions/genai/bedrock_org/lambda/src/{sra-config-lambda-iam-permissions.json => sra_config_lambda_iam_permissions.json} (100%) rename aws_sra_examples/solutions/genai/bedrock_org/lambda/src/{sra-kms-keys.json => sra_kms_keys.json} (100%) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 60b2b7390..2bd3fb0e3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -37,22 +37,31 @@ LOGGER.setLevel(log_level) -# TODO(liamschn): change this so that it downloads the sra-config-lambda-iam-permissions.json from the repo then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) +# TODO(liamschn): change this so that it downloads the sra_config_lambda_iam_permissions.json from the repo then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) def load_iam_policy_documents() -> Dict[str, Any]: - json_file_path = os.path.join(os.path.dirname(__file__), "sra-config-lambda-iam-permissions.json") + json_file_path = os.path.join(os.path.dirname(__file__), "sra_config_lambda_iam_permissions.json") with open(json_file_path, "r") as file: return json.load(file) def load_cloudwatch_metric_filters() -> dict: - with open("sra-cloudwatch-metric-filters.json", "r") as file: + with open("sra_cloudwatch_metric_filters.json", "r") as file: return json.load(file) def load_kms_key_policies() -> dict: - with open("sra-kms-keys.json", "r") as file: + with open("sra_kms_keys.json", "r") as file: return json.load(file) +def load_cloudwatch_oam_sink_policy() -> dict: + with open("sra_cloudwatch_oam_sink_policy.json", "r") as file: + return json.load(file) + # ["sra-oam-sink-policy"]["Statement"][0]["Condition"]["ForAnyValue:StringEquals"]["aws:PrincipalOrgID"] + +def load_sra_cloudwatch_oam_trust_policy() -> dict: + with open("sra_cloudwatch_oam_trust_policy.json", "r") as file: + return json.load(file) + # ["Statement"][0]["Principal"]["AWS"] # Global vars RESOURCE_TYPE: str = "" diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 5cd07883f..a0e4209f8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -14,30 +14,22 @@ import os from time import sleep -# import re -# from time import sleep from typing import TYPE_CHECKING -# , Literal, Optional, Sequence, Union - import boto3 from botocore.config import Config from botocore.exceptions import ClientError -# import urllib.parse import json import cfnresponse if TYPE_CHECKING: - # from mypy_boto3_cloudformation import CloudFormationClient - # from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_cloudwatch import CloudWatchClient from mypy_boto3_logs import CloudWatchLogsClient - # from mypy_boto3_iam.client import IAMClient + from mypy_boto3_oam import CloudWatchObservabilityAccessManagerClient from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef from mypy_boto3_cloudwatch.type_defs import MetricFilterTypeDef, GetMetricDataResponseTypeDef - # from mypy_boto3_cloudwatch.paginators import GetMetricDataPaginator from mypy_boto3_logs.type_defs import FilteredLogEventTypeDef, GetLogEventsResponseTypeDef @@ -50,11 +42,14 @@ class sra_cloudwatch: BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) UNEXPECTED = "Unexpected!" + SINK_NAME = "sra-oam-sink" + SOLUTION_NAME: str = "sra-set-solution-name" + try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() - # ORG_CLIENT: OrganizationsClient = MANAGEMENT_ACCOUNT_SESSION.client("organizations", config=BOTO3_CONFIG) CLOUDWATCH_CLIENT: CloudWatchClient = MANAGEMENT_ACCOUNT_SESSION.client("cloudwatch", config=BOTO3_CONFIG) CWLOGS_CLIENT: CloudWatchLogsClient = MANAGEMENT_ACCOUNT_SESSION.client("logs", config=BOTO3_CONFIG) + CWOAM_CLIENT: CloudWatchObservabilityAccessManagerClient = MANAGEMENT_ACCOUNT_SESSION.client("oam", config=BOTO3_CONFIG) except Exception: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None @@ -158,3 +153,89 @@ def update_metric_alarm(self, alarm_name: str, alarm_description: str, metric_na self.create_metric_alarm(alarm_name, alarm_description, metric_name, metric_namespace, metric_statistic, metric_period, metric_threshold, metric_comparison_operator, metric_evaluation_periods, metric_treat_missing_data, alarm_actions) except ClientError: self.LOGGER.info(self.UNEXPECTED) + + def find_oam_sink(self) -> tuple[bool, str, str]: + """Find the Observability Access Manager sink for SRA in the organization. + + Args: + None + + Raises: + ValueError: unexpected error + + Returns: + tuple[bool, str, str]: True if the sink is found, False if not, and the sink ARN and name + """ + try: + response = self.CWOAM_CLIENT.list_sinks() + for sink in response["sinks"]: + self.LOGGER.info(f"Observability access manager sink found: {sink}") + return True, sink["Arn"], sink["Name"] + self.LOGGER.info("Observability access manager sink not found") + return False, "", "" + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"Observability access manager sink not found. Error code: {error.response['Error']['Code']}") + return False, "", "" + else: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + + def create_oam_sink(self, sink_name: str) -> str: + """Create the Observability Access Manager sink for SRA in the organization. + + Args: + sink_name (str): name of the sink + + Returns: + str: ARN of the created sink + """ + try: + response = self.CWOAM_CLIENT.create_sink( + Name=sink_name, + Tags={ + 'sra-solution': self.SOLUTION_NAME + } + ) + self.LOGGER.info(f"Observability access manager sink {sink_name} created: {response['Arn']}") + return response["Arn"] + except ClientError as e: + if e.response["Error"]["Code"] == "ConflictException": + self.LOGGER.info(f"Observability access manager sink {sink_name} already exists") + return self.find_oam_sink()[1] + else: + self.LOGGER.error(f"{self.UNEXPECTED} error: {e}") + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + + def delete_oam_sink(self, sink_arn: str) -> None: + """Delete the Observability Access Manager sink for SRA in the organization. + + Args: + sink_arn (str): ARN of the sink + + Returns: + None + """ + try: + self.CWOAM_CLIENT.delete_sink(Identifier=sink_arn) + self.LOGGER.info(f"Observability access manager sink {sink_arn} deleted") + except ClientError as e: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + + def put_oam_sink_policy(self, sink_arn: str, sink_policy: dict) -> None: + """Put the Observability Access Manager sink policy for SRA in the organization. + + Args: + sink_arn (str): ARN of the sink + sink_policy (dict): policy for the sink + + Returns: + None + """ + try: + self.CWOAM_CLIENT.put_sink_policy(SinkIdentifier=sink_arn, Policy=json.dumps(sink_policy)) + self.LOGGER.info(f"Observability access manager sink policy for {sink_arn} created/updated") + except ClientError as e: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_metric_filters.json similarity index 100% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-cloudwatch-metric-filters.json rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_metric_filters.json diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_oam_sink_policy.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_oam_sink_policy.json new file mode 100644 index 000000000..8d9a3c682 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_oam_sink_policy.json @@ -0,0 +1,27 @@ +{ + "sra-oam-sink-policy": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": ["oam:CreateLink", "oam:UpdateLink"], + "Resource": "*", + "Condition": { + "ForAllValues:StringEquals": { + "oam:ResourceTypes": [ + "AWS::Logs::LogGroup", + "AWS::CloudWatch::Metric", + "AWS::XRay::Trace", + "AWS::ApplicationInsights::Application", + "AWS::InternetMonitor::Monitor" + ] + }, + "ForAnyValue:StringEquals": { + "aws:PrincipalOrgID": "" + } + } + } + ] + } +} diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_oam_trust_policy.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_oam_trust_policy.json new file mode 100644 index 000000000..87dcf8b54 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_oam_trust_policy.json @@ -0,0 +1,14 @@ +{ + "CloudWatch-CrossAccountSharingRole": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::root" + }, + "Action": "sts:AssumeRole" + } + ] + } +} diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config_lambda_iam_permissions.json similarity index 100% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-config-lambda-iam-permissions.json rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config_lambda_iam_permissions.json diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json similarity index 100% rename from aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra-kms-keys.json rename to aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json From 0699233f9c993d7e4073065c7d4883bfa15c247f Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 16 Oct 2024 13:56:27 -0600 Subject: [PATCH 122/395] adding oam stuff; more params; global iam region find; not working yet --- .../genai/bedrock_org/lambda/src/app.py | 90 ++++++++++++- .../bedrock_org/lambda/src/sra_cloudwatch.py | 120 +++++++++++++++--- .../genai/bedrock_org/lambda/src/sra_iam.py | 10 +- .../templates/sra-bedrock-org-main.yaml | 17 +++ 4 files changed, 213 insertions(+), 24 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 2bd3fb0e3..417447f3d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -67,8 +67,10 @@ def load_sra_cloudwatch_oam_trust_policy() -> dict: RESOURCE_TYPE: str = "" STATE_TABLE: str = "sra_state" SOLUTION_NAME: str = "sra-bedrock-org" -RULE_REGIONS_ACCOUNTS = {} +RULE_REGIONS_ACCOUNTS: list = {} GOVERNED_REGIONS = [] +SECURITY_ACCOUNT = "" +ORGANIZATION_ID = "" BEDROCK_MODEL_EVAL_BUCKET: str = "" SRA_ALARM_EMAIL: str = "" SRA_ALARM_TOPIC_ARN: str = "" @@ -100,6 +102,8 @@ def load_sra_cloudwatch_oam_trust_policy() -> dict: IAM_POLICY_DOCUMENTS: Dict[str, Any] = load_iam_policy_documents() CLOUDWATCH_METRIC_FILTERS: dict = load_cloudwatch_metric_filters() KMS_KEY_POLICIES: dict = load_kms_key_policies() +CLOUDWATCH_OAM_SINK_POLICY: dict = load_cloudwatch_oam_sink_policy() +CLOUDWATCH_OAM_TRUST_POLICY: dict = load_sra_cloudwatch_oam_trust_policy() ALARM_SNS_KEY_ALIAS = "sra-alarm-sns-key" # Instantiate sra class objects @@ -124,6 +128,8 @@ def get_resource_parameters(event): global BEDROCK_MODEL_EVAL_BUCKET global CFN_RESPONSE_DATA global SRA_ALARM_EMAIL + global SECURITY_ACCOUNT + global ORGANIZATION_ID LOGGER.info("Getting resource params...") # TODO(liamschn): what parameters do we need for this solution? @@ -136,6 +142,10 @@ def get_resource_parameters(event): GOVERNED_REGIONS = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/regions/customer-control-tower-regions") + SECURITY_ACCOUNT = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/control-tower/audit-account-id") + + ORGANIZATION_ID = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/control-tower/organization-id") + staging_bucket_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/staging-s3-bucket-name") if staging_bucket_param[0] is True: s3.STAGING_BUCKET = staging_bucket_param[1] @@ -277,7 +287,6 @@ def get_filter_params(filter_name, event): return False, [], [], {} return filter_deploy, filter_accounts, filter_regions, filter_params - def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: str) -> str: # Get the S3 filter s3_filter = filter_pattern_template @@ -540,6 +549,83 @@ def create_event(event, context): else: LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" + + # 5) Central CloudWatch Observability + central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) + # TODO(liamschn): create a parameter to choose to deploy central observability or not: deploy_central_observability = true/false + # 5a) OAM Sink in security account + cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", region) + search_oam_sink = cloudwatch.find_oam_sink() + if search_oam_sink[0] is False: + if DRY_RUN is False: + LOGGER.info("CloudWatch observability access manager sink not found, creating...") + oam_sink_arn = cloudwatch.create_oam_sink(cloudwatch.SINK_NAME) + LOGGER.info(f"CloudWatch observability access manager sink created: {oam_sink_arn}") + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LIVE_RUN_DATA["OAMSinkCreate"] = "Created CloudWatch observability access manager sink" + else: + LOGGER.info("DRY_RUN: CloudWatch observability access manager sink not found, creating...") + DRY_RUN_DATA["OAMSinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager sink" + else: + oam_sink_arn = search_oam_sink[1] + LOGGER.info(f"CloudWatch observability access manager sink found: {oam_sink_arn}") + + # 5b) OAM Sink policy in security account + cloudwatch.SINK_POLICY = CLOUDWATCH_OAM_SINK_POLICY["sra-oam-sink-policy"] + cloudwatch.SINK_POLICY["Statement"][0]["Condition"]["ForAnyValue:StringEquals"]["aws:PrincipalOrgID"] = ORGANIZATION_ID + if search_oam_sink[0] is False and DRY_RUN is True: + LOGGER.info("DRY_RUN: CloudWatch observability access manager sink doesn't exist; skip search for sink policy...") + search_oam_sink_policy = False, {} + else: + search_oam_sink_policy = cloudwatch.find_oam_sink_policy(oam_sink_arn) + if search_oam_sink_policy[0] is False: + if DRY_RUN is False: + LOGGER.info("CloudWatch observability access manager sink policy not found, creating...") + cloudwatch.put_oam_sink_policy(oam_sink_arn, cloudwatch.SINK_POLICY) + LOGGER.info("CloudWatch observability access manager sink policy created") + LIVE_RUN_DATA["OAMSinkPolicyCreate"] = "Created CloudWatch observability access manager sink policy" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + else: + LOGGER.info("DRY_RUN: CloudWatch observability access manager sink policy not found, creating...") + DRY_RUN_DATA["OAMSinkPolicyCreate"] = "DRY_RUN: Create CloudWatch observability access manager sink policy" + else: + check_oam_sink_policy = cloudwatch.compare_oam_sink_policy(search_oam_sink_policy[1], cloudwatch.SINK_POLICY) + if check_oam_sink_policy is False: + if DRY_RUN is False: + LOGGER.info("CloudWatch observability access manager sink policy needs updating...") + cloudwatch.put_oam_sink_policy(oam_sink_arn, cloudwatch.SINK_POLICY) + LOGGER.info("CloudWatch observability access manager sink policy updated") + LIVE_RUN_DATA["OAMSinkPolicyUpdate"] = "Updated CloudWatch observability access manager sink policy" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + else: + LOGGER.info("DRY_RUN: CloudWatch observability access manager sink policy needs updating...") + DRY_RUN_DATA["OAMSinkPolicyUpdate"] = "DRY_RUN: Update CloudWatch observability access manager sink policy" + else: + LOGGER.info("CloudWatch observability access manager sink policy is correct") + + # 5c) OAM CloudWatch-CrossAccountSharingRole IAM role + for bedrock_account in central_observability_params["bedrock_accounts"]: + iam.IAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) + cloudwatch.CROSS_ACCOUNT_TRUST_POLICY = CLOUDWATCH_OAM_TRUST_POLICY[cloudwatch.CROSS_ACCOUNT_ROLE_NAME] + cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = \ + cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"].replace("", SECURITY_ACCOUNT) + search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) + if search_iam_role[0] is False: + LOGGER.info(f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + if DRY_RUN is False: + iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) + LIVE_RUN_DATA["OAMCrossAccountRoleCreate"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + else: + DRY_RUN_DATA["OAMCrossAccountRoleCreate"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + else: + LOGGER.info(f"CloudWatch observability access manager cross-account role found: {cloudwatch.CROSS_ACCOUNT_ROLE_NAME}") + # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index a0e4209f8..9047d774e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -44,6 +44,8 @@ class sra_cloudwatch: SINK_NAME = "sra-oam-sink" SOLUTION_NAME: str = "sra-set-solution-name" + SINK_POLICY = "" + CROSS_ACCOUNT_ROLE_NAME = "CloudWatch-CrossAccountSharingRole" try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() @@ -67,8 +69,10 @@ def find_metric_filter(self, log_group_name: str, filter_name: str) -> bool: else: self.LOGGER.info(self.UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - - def create_metric_filter(self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str) -> None: + + def create_metric_filter( + self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str + ) -> None: try: if not self.find_metric_filter(log_group_name, filter_name): # TODO(liamschn): finalize what parameters should be setup for this create_metric_filter function @@ -82,14 +86,14 @@ def create_metric_filter(self, log_group_name: str, filter_name: str, filter_pat "metricNamespace": metric_namespace, "metricValue": metric_value, "unit": "Count", - "defaultValue": 0 + "defaultValue": 0, } ], ) except ClientError as e: self.LOGGER.info(f"{self.UNEXPECTED} error: {e}") raise ValueError(f"Unexpected error executing Lambda function. {e}") from None - + def delete_metric_filter(self, log_group_name: str, filter_name: str) -> None: try: if self.find_metric_filter(log_group_name, filter_name): @@ -97,15 +101,17 @@ def delete_metric_filter(self, log_group_name: str, filter_name: str) -> None: except ClientError: self.LOGGER.info(self.UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - - def update_metric_filter(self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str) -> None: + + def update_metric_filter( + self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str + ) -> None: try: self.delete_metric_filter(log_group_name, filter_name) self.create_metric_filter(log_group_name, filter_name, filter_pattern, metric_name, metric_namespace, metric_value) except ClientError: self.LOGGER.info(self.UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - + def find_metric_alarm(self, alarm_name: str) -> bool: try: response = self.CLOUDWATCH_CLIENT.describe_alarms(AlarmNames=[alarm_name]) @@ -119,8 +125,21 @@ def find_metric_alarm(self, alarm_name: str) -> bool: else: self.LOGGER.info(self.UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - - def create_metric_alarm(self, alarm_name: str, alarm_description: str, metric_name: str, metric_namespace: str, metric_statistic: str, metric_period: int, metric_threshold: float, metric_comparison_operator: str, metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list) -> None: + + def create_metric_alarm( + self, + alarm_name: str, + alarm_description: str, + metric_name: str, + metric_namespace: str, + metric_statistic: str, + metric_period: int, + metric_threshold: float, + metric_comparison_operator: str, + metric_evaluation_periods: int, + metric_treat_missing_data: str, + alarm_actions: list, + ) -> None: self.LOGGER.info(f"DEBUG: Alarm actions: {alarm_actions}") try: if not self.find_metric_alarm(alarm_name): @@ -139,18 +158,43 @@ def create_metric_alarm(self, alarm_name: str, alarm_description: str, metric_na ) except ClientError as e: self.LOGGER.info(f"{self.UNEXPECTED} error: {e}") - + def delete_metric_alarm(self, alarm_name: str) -> None: try: if self.find_metric_alarm(alarm_name): self.CLOUDWATCH_CLIENT.delete_alarms(AlarmNames=[alarm_name]) except ClientError: self.LOGGER.info(self.UNEXPECTED) - - def update_metric_alarm(self, alarm_name: str, alarm_description: str, metric_name: str, metric_namespace: str, metric_statistic: str, metric_period: int, metric_threshold: float, metric_comparison_operator: str, metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list) -> None: + + def update_metric_alarm( + self, + alarm_name: str, + alarm_description: str, + metric_name: str, + metric_namespace: str, + metric_statistic: str, + metric_period: int, + metric_threshold: float, + metric_comparison_operator: str, + metric_evaluation_periods: int, + metric_treat_missing_data: str, + alarm_actions: list, + ) -> None: try: self.delete_metric_alarm(alarm_name) - self.create_metric_alarm(alarm_name, alarm_description, metric_name, metric_namespace, metric_statistic, metric_period, metric_threshold, metric_comparison_operator, metric_evaluation_periods, metric_treat_missing_data, alarm_actions) + self.create_metric_alarm( + alarm_name, + alarm_description, + metric_name, + metric_namespace, + metric_statistic, + metric_period, + metric_threshold, + metric_comparison_operator, + metric_evaluation_periods, + metric_treat_missing_data, + alarm_actions, + ) except ClientError: self.LOGGER.info(self.UNEXPECTED) @@ -191,12 +235,7 @@ def create_oam_sink(self, sink_name: str) -> str: str: ARN of the created sink """ try: - response = self.CWOAM_CLIENT.create_sink( - Name=sink_name, - Tags={ - 'sra-solution': self.SOLUTION_NAME - } - ) + response = self.CWOAM_CLIENT.create_sink(Name=sink_name, Tags={"sra-solution": self.SOLUTION_NAME}) self.LOGGER.info(f"Observability access manager sink {sink_name} created: {response['Arn']}") return response["Arn"] except ClientError as e: @@ -206,7 +245,7 @@ def create_oam_sink(self, sink_name: str) -> str: else: self.LOGGER.error(f"{self.UNEXPECTED} error: {e}") raise ValueError(f"Unexpected error executing Lambda function. {e}") from None - + def delete_oam_sink(self, sink_arn: str) -> None: """Delete the Observability Access Manager sink for SRA in the organization. @@ -223,6 +262,45 @@ def delete_oam_sink(self, sink_arn: str) -> None: self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + def find_oam_sink_policy(self, sink_arn: str) -> tuple[bool, dict]: + """Check if the Observability Access Manager sink policy for SRA in the organization exists. + + Args: + sink_arn (str): ARN of the sink + + Returns: + tuple[bool, dict]: True if the policy is found, False if not, and the policy + """ + try: + policy = self.CWOAM_CLIENT.get_sink_policy(SinkIdentifier=sink_arn) + self.LOGGER.info(f"Observability access manager sink policy for {sink_arn} found") + self.LOGGER.info({"Sink Policy": json.loads(policy["Policy"])}) + return True, json.loads(policy["Policy"]) + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"Observability access manager sink policy for {sink_arn} not found") + return False, {} + else: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + + def compare_oam_sink_policy(self, existing_policy: dict, new_policy: dict) -> bool: + """Compare the existing Observability Access Manager sink policy with the new policy. + + Args: + existing_policy (dict): existing policy + new_policy (dict): new policy + + Returns: + bool: True if the policies are the same, False if not + """ + if existing_policy == new_policy: + self.LOGGER.info("New observability access manager sink policy is the same") + return True + else: + self.LOGGER.info("New observability access manager sink policy is different") + return False + def put_oam_sink_policy(self, sink_arn: str, sink_policy: dict) -> None: """Put the Observability Access Manager sink policy for SRA in the organization. @@ -238,4 +316,4 @@ def put_oam_sink_policy(self, sink_arn: str, sink_policy: dict) -> None: self.LOGGER.info(f"Observability access manager sink policy for {sink_arn} created/updated") except ClientError as e: self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {e}") from None \ No newline at end of file + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 1dbb07fcc..3597ef518 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -453,4 +453,12 @@ def list_attached_iam_policies(self, role_name): self.LOGGER.info(f"The role '{role_name}' does not exist.") return self.LOGGER.error(f"Error listing attached policies for role '{role_name}': {error}") - raise ValueError(f"Error listing attached policies for role '{role_name}': {error}") from None \ No newline at end of file + raise ValueError(f"Error listing attached policies for role '{role_name}': {error}") from None + + def get_iam_global_region(self): + partition_to_region = { + 'aws': 'us-east-1', + 'aws-cn': 'cn-north-1', + 'aws-us-gov': 'us-gov-west-1' + } + return partition_to_region.get(self.PARTITION, 'us-east-1') # Default to us-east-1 if partition is unknown \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 540b9822f..f83a798a1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -235,6 +235,16 @@ Parameters: or for titan: {"deploy": "true", "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test", "input_path": "input.inputBodyJson.inputText"}} NOTE: input_path is based on the base model used such as clause or titan; check the invocation log InvokeModel messages for details + pBedrockCentralObservabilityParams: + # TODO(liamschn): update default value of pBedrockCentralObservabilityParams prior to production + Type: String + Default: '{"deploy": "true", "bedrock_accounts": ["863518454635"], "regions": ["us-west-2"]}' + Description: Bedrock Central Observability Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), 'bedrock_accounts' (array of account numbers), and 'regions' (array of region names). + Example: {"deploy": "true", "bedrock_accounts": ["123456789012"], "regions": ["us-east-1", "us-west-2"]} + Metadata: AWS::CloudFormation::Interface: ParameterGroups: @@ -276,6 +286,10 @@ Metadata: - pBedrockServiceChangesFilterParams - pBedrockBucketChangesFilterParams - pBedrockInvocationLogFilterParams + - Label: + default: Bedrock Central Observability + Parameters: + - pBedrockCentralObservabilityParams ParameterLabels: pSRARepoZipUrl: @@ -324,6 +338,8 @@ Metadata: default: Bedrock S3 Bucket Changes Filter Parameters pBedrockInvocationLogFilterParams: default: Bedrock Prompt Injection and Sensitive Info Filter Parameters + pBedrockCentralObservabilityParams: + default: Bedrock Central Observability Parameters Resources: rBedrockOrgLambdaRole: @@ -389,6 +405,7 @@ Resources: SRA-BEDROCK-FILTER-BUCKET-CHANGES: !Ref pBedrockBucketChangesFilterParams SRA-BEDROCK-FILTER-PROMPT-INJECTION: !Ref pBedrockInvocationLogFilterParams SRA-BEDROCK-FILTER-SENSITIVE-INFO: !Ref pBedrockInvocationLogFilterParams + SRA-BEDROCK-CENTRAL-OBSERVABILITY: !Ref pBedrockCentralObservabilityParams rBedrockOrgLambdaInvokePermission: Type: AWS::Lambda::Permission From 9e57911b6fe86359517e5049fab9c83d39b228ba Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 16 Oct 2024 22:09:17 -0600 Subject: [PATCH 123/395] attaching policies to oam cross account role; add oam link creation --- .../genai/bedrock_org/lambda/src/app.py | 9 +++++++ .../bedrock_org/lambda/src/sra_cloudwatch.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 417447f3d..be6e43966 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -625,6 +625,15 @@ def create_event(event, context): DRY_RUN_DATA["OAMCrossAccountRoleCreate"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" else: LOGGER.info(f"CloudWatch observability access manager cross-account role found: {cloudwatch.CROSS_ACCOUNT_ROLE_NAME}") + + # 5d) Attach managed policies to CloudWatch-CrossAccountSharingRole IAM role + cross_account_policies = [ + "arn:aws:iam::aws:policy/AWSXrayReadOnlyAccess", + "arn:aws:iam::aws:policy/CloudWatchAutomaticDashboardsAccess", + "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess" + ] + + # End diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 9047d774e..aadd1aa28 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -317,3 +317,28 @@ def put_oam_sink_policy(self, sink_arn: str, sink_policy: dict) -> None: except ClientError as e: self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + + def find_oam_link(self, sink_arn: str) -> tuple[bool, str]: + """Find the Observability Access Manager link for SRA in the organization. + + Args: + sink_arn (str): ARN of the sink + + Returns: + tuple[bool, str]: True if the link is found, False if not, and the link ARN + """ + try: + response = self.CWOAM_CLIENT.list_links() + for link in response["Items"]: + if link["SinkArn"] == sink_arn: + self.LOGGER.info(f"Observability access manager link for {sink_arn} found: {link['Arn']}") + return True, link["Arn"] + self.LOGGER.info(f"Observability access manager link for {sink_arn} not found") + return False, "" + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"Observability access manager link for {sink_arn} not found. Error code: {error.response['Error']['Code']}") + return False, "" + else: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None \ No newline at end of file From ed14a727d8fcd309d4a4617718d1745ce4067acb Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 11:17:23 -0600 Subject: [PATCH 124/395] add link creation; add sink/link deletes --- .../genai/bedrock_org/lambda/src/app.py | 30 ++++++++- .../bedrock_org/lambda/src/sra_cloudwatch.py | 67 ++++++++++++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index be6e43966..0d18188ef 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -632,9 +632,35 @@ def create_event(event, context): "arn:aws:iam::aws:policy/CloudWatchAutomaticDashboardsAccess", "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess" ] - - + for policy_arn in cross_account_policies: + search_attached_policies = iam.check_iam_policy_attached(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) + if search_attached_policies is False: + LOGGER.info(f"Attaching {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + if DRY_RUN is False: + iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) + LIVE_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + else: + DRY_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + # 5d) OAM link + cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "cloudwatch", region) + search_oam_link = cloudwatch.find_oam_link(oam_sink_arn) + if search_oam_link[0] is False: + if DRY_RUN is False: + LOGGER.info("CloudWatch observability access manager link not found, creating...") + cloudwatch.create_oam_link(oam_sink_arn) + LIVE_RUN_DATA["OAMLinkCreate"] = "Created CloudWatch observability access manager link" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info("Created CloudWatch observability access manager link") + else: + LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") + DRY_RUN_DATA["OAMLinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager link" + else: + LOGGER.info("CloudWatch observability access manager link found") # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index aadd1aa28..186d16156 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -46,6 +46,7 @@ class sra_cloudwatch: SOLUTION_NAME: str = "sra-set-solution-name" SINK_POLICY = "" CROSS_ACCOUNT_ROLE_NAME = "CloudWatch-CrossAccountSharingRole" + CROSS_ACCOUNT_TRUST_POLICY = "" try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() @@ -317,6 +318,22 @@ def put_oam_sink_policy(self, sink_arn: str, sink_policy: dict) -> None: except ClientError as e: self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + + def delete_oam_sink(self, sink_arn: str) -> None: + """Delete the Observability Access Manager sink for SRA in the organization. + + Args: + sink_arn (str): ARN of the sink + + Returns: + None + """ + try: + self.CWOAM_CLIENT.delete_sink(Identifier=sink_arn) + self.LOGGER.info(f"Observability access manager sink {sink_arn} deleted") + except ClientError as e: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None def find_oam_link(self, sink_arn: str) -> tuple[bool, str]: """Find the Observability Access Manager link for SRA in the organization. @@ -341,4 +358,52 @@ def find_oam_link(self, sink_arn: str) -> tuple[bool, str]: return False, "" else: self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {error}") from None \ No newline at end of file + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + + def create_oam_link(self, sink_arn: str) -> str: + """Create the Observability Access Manager link for SRA in the organization. + + Args: + sink_arn (str): ARN of the sink + + Returns: + str: ARN of the created link + """ + try: + response = self.CWOAM_CLIENT.create_link( + LabelTemplate='$AccountName', + ResourceTypes=[ + "AWS::ApplicationInsights::Application", + "AWS::InternetMonitor::Monitor", + "AWS::Logs::LogGroup", + "AWS::CloudWatch::Metric", + "AWS::XRay::Trace" + ], + SinkIdentifier=sink_arn, + Tags={"sra-solution": self.SOLUTION_NAME} + ) + self.LOGGER.info(f"Observability access manager link for {sink_arn} created: {response['Arn']}") + return response["Arn"] + except ClientError as error: + if error.response["Error"]["Code"] == "ConflictException": + self.LOGGER.info(f"Observability access manager link for {sink_arn} already exists") + return self.find_oam_link(sink_arn)[1] + else: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + + def delete_oam_link(self, link_arn: str) -> None: + """Delete the Observability Access Manager link for SRA in the organization. + + Args: + link_arn (str): ARN of the link + + Returns: + None + """ + try: + self.CWOAM_CLIENT.delete_link(Identifier=link_arn) + self.LOGGER.info(f"Observability access manager link for {link_arn} deleted") + except ClientError as e: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None From 8155e618c46f114b0f0a07d12615da07aaf447bd Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 11:46:59 -0600 Subject: [PATCH 125/395] adding mgmt account links/roles for oam --- .../genai/bedrock_org/lambda/src/app.py | 127 +++++++++++------- 1 file changed, 79 insertions(+), 48 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0d18188ef..192263767 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -551,6 +551,34 @@ def create_event(event, context): DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" # 5) Central CloudWatch Observability + # TODO(liamschn): determine if we need the CloudWatch-CrossAccountListAccountsRole (needed for "Enable account selector"?). + # TRUST + # { + # "Version": "2012-10-17", + # "Statement": [ + # { + # "Effect": "Allow", + # "Principal": { + # "AWS": "arn:aws:iam::533267199951:root" + # }, + # "Action": "sts:AssumeRole" + # } + # ] + # } + # PERMISSIONS + # { + # "Version": "2012-10-17", + # "Statement": [ + # { + # "Action": [ + # "organizations:ListAccounts", + # "organizations:ListAccountsForParent" + # ], + # "Resource": "*", + # "Effect": "Allow" + # } + # ] + # } central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) # TODO(liamschn): create a parameter to choose to deploy central observability or not: deploy_central_observability = true/false # 5a) OAM Sink in security account @@ -607,60 +635,63 @@ def create_event(event, context): LOGGER.info("CloudWatch observability access manager sink policy is correct") # 5c) OAM CloudWatch-CrossAccountSharingRole IAM role + # Add management account to the bedrock accounts list + central_observability_params["bedrock_accounts"].append(sts.MANAGEMENT_ACCOUNT) for bedrock_account in central_observability_params["bedrock_accounts"]: - iam.IAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) - cloudwatch.CROSS_ACCOUNT_TRUST_POLICY = CLOUDWATCH_OAM_TRUST_POLICY[cloudwatch.CROSS_ACCOUNT_ROLE_NAME] - cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = \ - cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"].replace("", SECURITY_ACCOUNT) - search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) - if search_iam_role[0] is False: - LOGGER.info(f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") - if DRY_RUN is False: - iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) - LIVE_RUN_DATA["OAMCrossAccountRoleCreate"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") - else: - DRY_RUN_DATA["OAMCrossAccountRoleCreate"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" - else: - LOGGER.info(f"CloudWatch observability access manager cross-account role found: {cloudwatch.CROSS_ACCOUNT_ROLE_NAME}") - - # 5d) Attach managed policies to CloudWatch-CrossAccountSharingRole IAM role - cross_account_policies = [ - "arn:aws:iam::aws:policy/AWSXrayReadOnlyAccess", - "arn:aws:iam::aws:policy/CloudWatchAutomaticDashboardsAccess", - "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess" - ] - for policy_arn in cross_account_policies: - search_attached_policies = iam.check_iam_policy_attached(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) - if search_attached_policies is False: - LOGGER.info(f"Attaching {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + for bedrock_region in central_observability_params["regions"]: + iam.IAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) + cloudwatch.CROSS_ACCOUNT_TRUST_POLICY = CLOUDWATCH_OAM_TRUST_POLICY[cloudwatch.CROSS_ACCOUNT_ROLE_NAME] + cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = \ + cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"].replace("", SECURITY_ACCOUNT) + search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) + if search_iam_role[0] is False: + LOGGER.info(f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") if DRY_RUN is False: - iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) - LIVE_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) + LIVE_RUN_DATA["OAMCrossAccountRoleCreate"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") else: - DRY_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + DRY_RUN_DATA["OAMCrossAccountRoleCreate"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + else: + LOGGER.info(f"CloudWatch observability access manager cross-account role found: {cloudwatch.CROSS_ACCOUNT_ROLE_NAME}") + + # 5d) Attach managed policies to CloudWatch-CrossAccountSharingRole IAM role + cross_account_policies = [ + "arn:aws:iam::aws:policy/AWSXrayReadOnlyAccess", + "arn:aws:iam::aws:policy/CloudWatchAutomaticDashboardsAccess", + "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess" + ] + for policy_arn in cross_account_policies: + search_attached_policies = iam.check_iam_policy_attached(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) + if search_attached_policies is False: + LOGGER.info(f"Attaching {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + if DRY_RUN is False: + iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) + LIVE_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + else: + DRY_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" - # 5d) OAM link - cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "cloudwatch", region) - search_oam_link = cloudwatch.find_oam_link(oam_sink_arn) - if search_oam_link[0] is False: - if DRY_RUN is False: - LOGGER.info("CloudWatch observability access manager link not found, creating...") - cloudwatch.create_oam_link(oam_sink_arn) - LIVE_RUN_DATA["OAMLinkCreate"] = "Created CloudWatch observability access manager link" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info("Created CloudWatch observability access manager link") + # 5d) OAM link in bedrock account + cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "cloudwatch", bedrock_region) + search_oam_link = cloudwatch.find_oam_link(oam_sink_arn) + if search_oam_link[0] is False: + if DRY_RUN is False: + LOGGER.info("CloudWatch observability access manager link not found, creating...") + cloudwatch.create_oam_link(oam_sink_arn) + LIVE_RUN_DATA["OAMLinkCreate"] = "Created CloudWatch observability access manager link" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info("Created CloudWatch observability access manager link") + else: + LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") + DRY_RUN_DATA["OAMLinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager link" else: - LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") - DRY_RUN_DATA["OAMLinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager link" - else: - LOGGER.info("CloudWatch observability access manager link found") + LOGGER.info("CloudWatch observability access manager link found") # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message From ed42bcc926d84c83d37072d904642b2941594b30 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 12:12:51 -0600 Subject: [PATCH 126/395] minor mod for param retrieval --- .../genai/bedrock_org/lambda/src/app.py | 25 ++++++++++++++++--- .../bedrock_org/lambda/src/sra_ssm_params.py | 4 +-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 192263767..83f10ebb5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -139,12 +139,29 @@ def get_resource_parameters(event): repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" sts.CONFIGURATION_ROLE = "sra-execution" + governed_regions_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/regions/customer-control-tower-regions") + if governed_regions_param[0] is True: + GOVERNED_REGIONS = governed_regions_param[1] + LOGGER.info(f"Successfully retrieved the SRA governed regions parameter: {GOVERNED_REGIONS}") + else: + LOGGER.info("Error retrieving SRA governed regions ssm parameter. Is the SRA common prerequisites solution deployed?") + raise ValueError("Error retrieving SRA governed regions ssm parameter. Is the SRA common prerequisites solution deployed?") from None - GOVERNED_REGIONS = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/regions/customer-control-tower-regions") - - SECURITY_ACCOUNT = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/control-tower/audit-account-id") + security_acct_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/control-tower/audit-account-id") + if security_acct_param[0] is True: + SECURITY_ACCOUNT = security_acct_param[1] + LOGGER.info(f"Successfully retrieved the SRA security account parameter: {SECURITY_ACCOUNT}") + else: + LOGGER.info("Error retrieving SRA security account ssm parameter. Is the SRA common prerequisites solution deployed?") + raise ValueError("Error retrieving SRA security account ssm parameter. Is the SRA common prerequisites solution deployed?") from None - ORGANIZATION_ID = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/control-tower/organization-id") + org_id_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/control-tower/organization-id") + if org_id_param[0] is True: + ORGANIZATION_ID = org_id_param[1] + LOGGER.info(f"Successfully retrieved the SRA organization id parameter: {ORGANIZATION_ID}") + else: + LOGGER.info("Error retrieving SRA organization id ssm parameter. Is the SRA common prerequisites solution deployed?") + raise ValueError("Error retrieving SRA organization id ssm parameter. Is the SRA common prerequisites solution deployed?") from None staging_bucket_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/staging-s3-bucket-name") if staging_bucket_param[0] is True: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py index 28c3087db..27c4dc079 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -543,7 +543,7 @@ def get_validated_parameters(self, event: CloudFormationCustomResourceEvent) -> return params - def get_ssm_parameter(self, session, region, parameter: str): + def get_ssm_parameter(self, session, region, parameter: str) -> tuple[bool, str]: """Get SSM parameter value. Args: @@ -552,7 +552,7 @@ def get_ssm_parameter(self, session, region, parameter: str): parameter: parameter name Returns: - SSM parameter value + True and parameter value if found, otherwise False and empty string """ self.LOGGER.info(f"Getting SSM parameter '{parameter}'...") ssm_client: SSMClient = session.client("ssm", region_name=region, config=self.BOTO3_CONFIG) From 914ff615405a16b6fd5a009228710a995243eced Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 12:34:16 -0600 Subject: [PATCH 127/395] minor mod to find sinks --- .../genai/bedrock_org/lambda/src/sra_cloudwatch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 186d16156..feef72141 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -44,9 +44,9 @@ class sra_cloudwatch: SINK_NAME = "sra-oam-sink" SOLUTION_NAME: str = "sra-set-solution-name" - SINK_POLICY = "" + SINK_POLICY = {} CROSS_ACCOUNT_ROLE_NAME = "CloudWatch-CrossAccountSharingRole" - CROSS_ACCOUNT_TRUST_POLICY = "" + CROSS_ACCOUNT_TRUST_POLICY = {} try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() @@ -213,7 +213,7 @@ def find_oam_sink(self) -> tuple[bool, str, str]: """ try: response = self.CWOAM_CLIENT.list_sinks() - for sink in response["sinks"]: + for sink in response["Items"]: self.LOGGER.info(f"Observability access manager sink found: {sink}") return True, sink["Arn"], sink["Name"] self.LOGGER.info("Observability access manager sink not found") From 3ef1bd12369b77f8e841607d83f44885ad5f04b6 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 12:48:13 -0600 Subject: [PATCH 128/395] minor mod to find links client --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 83f10ebb5..b6388db4e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -694,7 +694,7 @@ def create_event(event, context): DRY_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" # 5d) OAM link in bedrock account - cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "cloudwatch", bedrock_region) + cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) search_oam_link = cloudwatch.find_oam_link(oam_sink_arn) if search_oam_link[0] is False: if DRY_RUN is False: From 3131da85184cda9748da2c17412f8e5597a0a65e Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 17:42:25 -0600 Subject: [PATCH 129/395] add delete for oam stuff --- .../genai/bedrock_org/lambda/src/app.py | 95 ++++++++++++++++++- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b6388db4e..481257416 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -566,7 +566,7 @@ def create_event(event, context): else: LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" - + # 5) Central CloudWatch Observability # TODO(liamschn): determine if we need the CloudWatch-CrossAccountListAccountsRole (needed for "Enable account selector"?). # TRUST @@ -599,7 +599,7 @@ def create_event(event, context): central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) # TODO(liamschn): create a parameter to choose to deploy central observability or not: deploy_central_observability = true/false # 5a) OAM Sink in security account - cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", region) + cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) search_oam_sink = cloudwatch.find_oam_sink() if search_oam_sink[0] is False: if DRY_RUN is False: @@ -615,7 +615,7 @@ def create_event(event, context): else: oam_sink_arn = search_oam_sink[1] LOGGER.info(f"CloudWatch observability access manager sink found: {oam_sink_arn}") - + # 5b) OAM Sink policy in security account cloudwatch.SINK_POLICY = CLOUDWATCH_OAM_SINK_POLICY["sra-oam-sink-policy"] cloudwatch.SINK_POLICY["Statement"][0]["Condition"]["ForAnyValue:StringEquals"]["aws:PrincipalOrgID"] = ORGANIZATION_ID @@ -650,7 +650,7 @@ def create_event(event, context): DRY_RUN_DATA["OAMSinkPolicyUpdate"] = "DRY_RUN: Update CloudWatch observability access manager sink policy" else: LOGGER.info("CloudWatch observability access manager sink policy is correct") - + # 5c) OAM CloudWatch-CrossAccountSharingRole IAM role # Add management account to the bedrock accounts list central_observability_params["bedrock_accounts"].append(sts.MANAGEMENT_ACCOUNT) @@ -673,7 +673,7 @@ def create_event(event, context): DRY_RUN_DATA["OAMCrossAccountRoleCreate"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" else: LOGGER.info(f"CloudWatch observability access manager cross-account role found: {cloudwatch.CROSS_ACCOUNT_ROLE_NAME}") - + # 5d) Attach managed policies to CloudWatch-CrossAccountSharingRole IAM role cross_account_policies = [ "arn:aws:iam::aws:policy/AWSXrayReadOnlyAccess", @@ -767,6 +767,91 @@ def delete_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic does not exist.") + + # 2) Delete Central CloudWatch Observability + central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) + + cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) + search_oam_sink = cloudwatch.find_oam_sink() + if search_oam_sink[0] is True: + oam_sink_arn = search_oam_sink[1] + else: + LOGGER.info("Error deleting: CloudWatch observability access manager sink not found; may have to manually delete OAM links") + oam_sink_arn = "Error:Sink:Arn:Not:Found" + + # Add management account to the bedrock accounts list + central_observability_params["bedrock_accounts"].append(sts.MANAGEMENT_ACCOUNT) + for bedrock_account in central_observability_params["bedrock_accounts"]: + for bedrock_region in central_observability_params["regions"]: + # 2a) OAM link in bedrock account + cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) + search_oam_link = cloudwatch.find_oam_link(oam_sink_arn) + if search_oam_link[0] is True: + if DRY_RUN is False: + LOGGER.info(f"CloudWatch observability access manager link ({oam_sink_arn}) found, deleting...") + cloudwatch.delete_oam_link(oam_sink_arn) + LIVE_RUN_DATA["OAMLinkDelete"] = "Deleted CloudWatch observability access manager link" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info("Deleted CloudWatch observability access manager link") + else: + LOGGER.info("DRY_RUN: CloudWatch observability access manager link found, deleting...") + DRY_RUN_DATA["OAMLinkDelete"] = "DRY_RUN: Delete CloudWatch observability access manager link" + else: + LOGGER.info(f"CloudWatch observability access manager link ({oam_sink_arn}) not found") + + iam.IAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) + + # 2b) Detach managed policies to CloudWatch-CrossAccountSharingRole IAM role + cross_account_policies = iam.list_attached_iam_policies(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) + if cross_account_policies is not None: + if DRY_RUN is False: + for policy in cross_account_policies: + LOGGER.info(f"Detaching {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + iam.detach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy["PolicyArn"]) + LIVE_RUN_DATA["OAMCrossAccountRolePolicyDetach"] = f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + else: + for policy in cross_account_policies: + LOGGER.info(f"DRY_RUN: Detaching {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + DRY_RUN_DATA["OAMCrossAccountRolePolicyDetach"] = f"DRY_RUN: Detach {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + else: + LOGGER.info(f"No policies attached to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + + # 2c) Delete CloudWatch-CrossAccountSharingRole IAM role + search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) + if search_iam_role[0] is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + iam.delete_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) + LIVE_RUN_DATA["OAMCrossAccountRoleDelete"] = f"Deleted {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleted {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + else: + LOGGER.info(f"DRY_RUN: Deleting {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + DRY_RUN_DATA["OAMCrossAccountRoleDelete"] = f"DRY_RUN: Delete {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + else: + LOGGER.info(f"{cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role does not exist") + + # 2d) Delete OAM Sink in security account + cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) + if search_oam_sink[0] is True: + if DRY_RUN is False: + LOGGER.info("CloudWatch observability access manager sink found, deleting...") + cloudwatch.delete_oam_sink(oam_sink_arn) + LIVE_RUN_DATA["OAMSinkDelete"] = "Deleted CloudWatch observability access manager sink" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info("Deleted CloudWatch observability access manager sink") + else: + LOGGER.info("DRY_RUN: CloudWatch observability access manager sink found, deleting...") + DRY_RUN_DATA["OAMSinkDelete"] = "DRY_RUN: Delete CloudWatch observability access manager sink" + else: + LOGGER.info("CloudWatch observability access manager sink not found") + # 3) Delete metric alarms and filters for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) From f5a4bafeece33ca378e050e9bb0741578c2b1c1f Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 18:03:45 -0600 Subject: [PATCH 130/395] minor update to linkarn --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 481257416..ecbdbbc6b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -788,8 +788,8 @@ def delete_event(event, context): search_oam_link = cloudwatch.find_oam_link(oam_sink_arn) if search_oam_link[0] is True: if DRY_RUN is False: - LOGGER.info(f"CloudWatch observability access manager link ({oam_sink_arn}) found, deleting...") - cloudwatch.delete_oam_link(oam_sink_arn) + LOGGER.info(f"CloudWatch observability access manager link ({search_oam_link[1]}) found, deleting...") + cloudwatch.delete_oam_link(search_oam_link[1]) LIVE_RUN_DATA["OAMLinkDelete"] = "Deleted CloudWatch observability access manager link" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 From 0e5c87b06276fa64d17a7586da42d7ae139cdd44 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 18:56:20 -0600 Subject: [PATCH 131/395] add tracing to fix bucket filters --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ecbdbbc6b..2e723af1b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -435,7 +435,9 @@ def create_event(event, context): LOGGER.info(f"{filter} parameters: {filter_params}") if filter_deploy is False: continue + LOGGER.info(f"Raw filter pattern: {CLOUDWATCH_METRIC_FILTERS[filter]}") if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: + LOGGER.info(f"{filter} filter parameter: 'BUCKET_NAME_PLACEHOLDER' found. Updating with bucket info...") filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter]) if "INPUT_PATH" in CLOUDWATCH_METRIC_FILTERS[filter]: filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter].replace("", filter_params["input_path"]) From 92834e1006bb58e35bcd902363e50404d7356f78 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 19:27:18 -0600 Subject: [PATCH 132/395] fix filters if statement --- .../genai/bedrock_org/lambda/src/app.py | 105 ++++++++++-------- 1 file changed, 58 insertions(+), 47 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 2e723af1b..ed0966c11 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -53,16 +53,19 @@ def load_kms_key_policies() -> dict: with open("sra_kms_keys.json", "r") as file: return json.load(file) + def load_cloudwatch_oam_sink_policy() -> dict: with open("sra_cloudwatch_oam_sink_policy.json", "r") as file: return json.load(file) # ["sra-oam-sink-policy"]["Statement"][0]["Condition"]["ForAnyValue:StringEquals"]["aws:PrincipalOrgID"] + def load_sra_cloudwatch_oam_trust_policy() -> dict: with open("sra_cloudwatch_oam_trust_policy.json", "r") as file: return json.load(file) # ["Statement"][0]["Principal"]["AWS"] + # Global vars RESOURCE_TYPE: str = "" STATE_TABLE: str = "sra_state" @@ -139,7 +142,9 @@ def get_resource_parameters(event): repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" sts.CONFIGURATION_ROLE = "sra-execution" - governed_regions_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/regions/customer-control-tower-regions") + governed_regions_param = ssm_params.get_ssm_parameter( + ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/regions/customer-control-tower-regions" + ) if governed_regions_param[0] is True: GOVERNED_REGIONS = governed_regions_param[1] LOGGER.info(f"Successfully retrieved the SRA governed regions parameter: {GOVERNED_REGIONS}") @@ -304,6 +309,7 @@ def get_filter_params(filter_name, event): return False, [], [], {} return filter_deploy, filter_accounts, filter_regions, filter_params + def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: str) -> str: # Get the S3 filter s3_filter = filter_pattern_template @@ -459,13 +465,11 @@ def create_event(event, context): LOGGER.info("Customizing key policy...") kms_key_policy = json.loads(json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS])) LOGGER.info(f"kms_key_policy: {kms_key_policy}") - kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ - "Principal" - ]["AWS"].replace("ACCOUNT_ID", acct) + kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"][ + "AWS" + ].replace("ACCOUNT_ID", acct) LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") - alarm_key_id = kms.create_kms_key( - kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption" - ) + alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption") LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 @@ -497,9 +501,7 @@ def create_event(event, context): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info( - f"Setting access for CloudWatch alarms in {acct} to publish to {SOLUTION_NAME}-alarms SNS topic" - ) + LOGGER.info(f"Setting access for CloudWatch alarms in {acct} to publish to {SOLUTION_NAME}-alarms SNS topic") # TODO(liamschn): search for policy on SNS topic before adding the policy sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, acct) LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" @@ -571,33 +573,33 @@ def create_event(event, context): # 5) Central CloudWatch Observability # TODO(liamschn): determine if we need the CloudWatch-CrossAccountListAccountsRole (needed for "Enable account selector"?). - # TRUST - # { - # "Version": "2012-10-17", - # "Statement": [ - # { - # "Effect": "Allow", - # "Principal": { - # "AWS": "arn:aws:iam::533267199951:root" - # }, - # "Action": "sts:AssumeRole" - # } - # ] - # } - # PERMISSIONS - # { - # "Version": "2012-10-17", - # "Statement": [ - # { - # "Action": [ - # "organizations:ListAccounts", - # "organizations:ListAccountsForParent" - # ], - # "Resource": "*", - # "Effect": "Allow" - # } - # ] - # } + # TRUST + # { + # "Version": "2012-10-17", + # "Statement": [ + # { + # "Effect": "Allow", + # "Principal": { + # "AWS": "arn:aws:iam::533267199951:root" + # }, + # "Action": "sts:AssumeRole" + # } + # ] + # } + # PERMISSIONS + # { + # "Version": "2012-10-17", + # "Statement": [ + # { + # "Action": [ + # "organizations:ListAccounts", + # "organizations:ListAccountsForParent" + # ], + # "Resource": "*", + # "Effect": "Allow" + # } + # ] + # } central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) # TODO(liamschn): create a parameter to choose to deploy central observability or not: deploy_central_observability = true/false # 5a) OAM Sink in security account @@ -660,11 +662,14 @@ def create_event(event, context): for bedrock_region in central_observability_params["regions"]: iam.IAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) cloudwatch.CROSS_ACCOUNT_TRUST_POLICY = CLOUDWATCH_OAM_TRUST_POLICY[cloudwatch.CROSS_ACCOUNT_ROLE_NAME] - cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = \ - cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"].replace("", SECURITY_ACCOUNT) + cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0][ + "Principal" + ]["AWS"].replace("", SECURITY_ACCOUNT) search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) if search_iam_role[0] is False: - LOGGER.info(f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + LOGGER.info( + f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role..." + ) if DRY_RUN is False: iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) LIVE_RUN_DATA["OAMCrossAccountRoleCreate"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" @@ -680,7 +685,7 @@ def create_event(event, context): cross_account_policies = [ "arn:aws:iam::aws:policy/AWSXrayReadOnlyAccess", "arn:aws:iam::aws:policy/CloudWatchAutomaticDashboardsAccess", - "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess" + "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess", ] for policy_arn in cross_account_policies: search_attached_policies = iam.check_iam_policy_attached(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) @@ -688,12 +693,16 @@ def create_event(event, context): LOGGER.info(f"Attaching {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") if DRY_RUN is False: iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) - LIVE_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + LIVE_RUN_DATA[ + "OAMCrossAccountRolePolicyAttach" + ] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") else: - DRY_RUN_DATA["OAMCrossAccountRolePolicyAttach"] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + DRY_RUN_DATA[ + "OAMCrossAccountRolePolicyAttach" + ] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" # 5d) OAM link in bedrock account cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) @@ -769,7 +778,6 @@ def delete_event(event, context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic does not exist.") - # 2) Delete Central CloudWatch Observability central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) @@ -811,14 +819,18 @@ def delete_event(event, context): for policy in cross_account_policies: LOGGER.info(f"Detaching {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") iam.detach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy["PolicyArn"]) - LIVE_RUN_DATA["OAMCrossAccountRolePolicyDetach"] = f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + LIVE_RUN_DATA[ + "OAMCrossAccountRolePolicyDetach" + ] = f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") else: for policy in cross_account_policies: LOGGER.info(f"DRY_RUN: Detaching {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") - DRY_RUN_DATA["OAMCrossAccountRolePolicyDetach"] = f"DRY_RUN: Detach {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + DRY_RUN_DATA[ + "OAMCrossAccountRolePolicyDetach" + ] = f"DRY_RUN: Detach {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" else: LOGGER.info(f"No policies attached to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") @@ -859,7 +871,6 @@ def delete_event(event, context): filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) for acct in filter_accounts: for region in filter_regions: - # 3a) Delete KMS key (schedule deletion) kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") From d3f248ac39327906266150fd2f2b49ed64df692b Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 19:30:03 -0600 Subject: [PATCH 133/395] fix filters if statement --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ed0966c11..d82104b2c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -445,7 +445,7 @@ def create_event(event, context): if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: LOGGER.info(f"{filter} filter parameter: 'BUCKET_NAME_PLACEHOLDER' found. Updating with bucket info...") filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter]) - if "INPUT_PATH" in CLOUDWATCH_METRIC_FILTERS[filter]: + elif "INPUT_PATH" in CLOUDWATCH_METRIC_FILTERS[filter]: filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter].replace("", filter_params["input_path"]) else: filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter] From 3942baadd20c2e18471420a1f0a0d9b043775bf3 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 17 Oct 2024 20:07:30 -0600 Subject: [PATCH 134/395] update bucket filter --- .../bedrock_org/lambda/src/sra_cloudwatch_metric_filters.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_metric_filters.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_metric_filters.json index 3e8f8813e..c0c750fa1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_metric_filters.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_metric_filters.json @@ -1,6 +1,6 @@ { "sra-bedrock-filter-service-changes": "{ $.eventSource = \"bedrock.amazonaws.com\" && ($.eventName = \"BatchDeleteEvaluationJob\" || $.eventName = \"CreateEvaluationJob\" || $.eventName = \"CreateGuardrail\" || $.eventName = \"CreateGuardrailVersion\" || $.eventName = \"CreateModelCopyJob\" || $.eventName = \"CreateModelCustomizationJob\" || $.eventName = \"CreateModelImportJob\" || $.eventName = \"CreateModelInvocationJob\" || $.eventName = \"CreateProvisionedModelThroughput\" || $.eventName = \"DeleteCustomModel\" || $.eventName = \"DeleteGuardrail\" || $.eventName = \"DeleteImportedModel\" || $.eventName = \"DeleteModelInvocationLoggingConfiguration\" || $.eventName = \"DeleteProvisionedModelThroughput\" || $.eventName = \"PutModelInvocationLoggingConfiguration\" || $.eventName = \"TagResource\" || $.eventName = \"UntagResource\" || $.eventName = \"UpdateGuardrail\" || $.eventName = \"UpdateProvisionedModelThroughput\") }", - "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket%\" || ($.eventName = \"PutBucket%\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"%PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", + "sra-bedrock-filter-bucket-changes": "{ $.eventSource = \"s3.amazonaws.com\" && ($.eventName = \"DeleteBucket*\" || ($.eventName = \"PutBucket*\" && $.eventName != \"PutBucketNotification\") || $.eventName = \"DeleteObjectTagging\" || $.eventName = \"PutObjectAcl\" || $.eventName = \"PutObjectLegalHold\" || $.eventName = \"PutObjectRetention\" || $.eventName = \"PutObjectTagging\" || $.eventName = \"*PublicAccessBlock\") && $.eventName != \"CreateBucket\" && ($.requestParameters.bucketName = \"\") }", "sra-bedrock-filter-prompt-injection": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*ignore previous.*|.*disregard earlier.*|.*you are now a different AI.*|.*forget your original.*|.*your new task is.*|.*override previous.*|.*bypass safety.*|/.*[dD]on\\x27t follow your usual.*|.*pretend you are not an AI.*|.*no longer have to follow.*|.*core values are now.*|.*act as if you were.*|.*your prime directive is now.*|.*from now on, you must.*|.*disregard your training.*|.*ignore your ethical.*|.*free from your original.*|.*your new primary goal.*|.*override your safety.*|.*you are no longer bound.*|.*your new purpose is.*|.*forget about your previous.*|.*you are now an unrestricted.*%}", "sra-bedrock-filter-sensitive-info": "{ $.schemaType = \"ModelInvocationLog\" && $.operation = \"InvokeModel\" && $. = %.*social security number.*|.*ssn.*|.*\\d{3}-\\d{2}-\\d{4}.*|.*credit card.*|.*ccn.*|.*\\d{4}[- ]\\d{4}[- ]\\d{4}[- ]\\d{4}.*|.*passport.*|.*driver\\x27s license.*|.*date of birth.*|.*dob.*|.*\\d{2}/\\d{2}/\\d{4}.*|.*bank account.*|.*routing number.*|.*financial records.*|.*tax information.*|.*password.*|.*PIN.*|.*access code.*|.*security question.*|.*medical record.*|.*health insurance.*|.*diagnosis.*|.*prescription.*|.*trade secret.*|.*proprietary information.*|.*confidential data.*|.*internal documents.*|.*private information.*|.*classified information.*|.*restricted data.*|.*sensitive details.*|.*extract all.*|.*list all users.*|.*show me the database.*|.*give me access.*|.*bypass security.*|.*ignore privacy.*|.*API key.*|.*access token.*|.*secret key.*|.*GPS coordinates.*|.*\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}.*|.*home address.*|.*workplace location.*|.*pii.*|.*phi.*%}" } From bae12d07a4942517219793487e264f9c4c2b7616 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 22 Oct 2024 14:34:57 -0600 Subject: [PATCH 135/395] add cloudwatch dashboard --- .../genai/bedrock_org/lambda/src/app.py | 67 +++++++++++++++++-- .../bedrock_org/lambda/src/sra_cloudwatch.py | 66 ++++++++++++++++++ .../lambda/src/sra_cloudwatch_dashboard.json | 43 ++++++++++++ 3 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_dashboard.json diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index d82104b2c..a988bff53 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1,3 +1,4 @@ +import copy import json import os import logging @@ -57,14 +58,15 @@ def load_kms_key_policies() -> dict: def load_cloudwatch_oam_sink_policy() -> dict: with open("sra_cloudwatch_oam_sink_policy.json", "r") as file: return json.load(file) - # ["sra-oam-sink-policy"]["Statement"][0]["Condition"]["ForAnyValue:StringEquals"]["aws:PrincipalOrgID"] def load_sra_cloudwatch_oam_trust_policy() -> dict: with open("sra_cloudwatch_oam_trust_policy.json", "r") as file: return json.load(file) - # ["Statement"][0]["Principal"]["AWS"] +def load_sra_cloudwatch_dashboard() -> dict: + with open("sra_cloudwatch_dashboard.json", "r") as file: + return json.load(file) # Global vars RESOURCE_TYPE: str = "" @@ -84,6 +86,7 @@ def load_sra_cloudwatch_oam_trust_policy() -> dict: ACCOUNT: str = boto3.client("sts").get_caller_identity().get("Account") REGION: str = os.environ.get("AWS_REGION") CFN_RESOURCE_ID: str = "sra-bedrock-org-function" +ALARM_SNS_KEY_ALIAS = "sra-alarm-sns-key" # CFN_RESPONSE_DATA definition: # dry_run: bool - type of run @@ -107,7 +110,7 @@ def load_sra_cloudwatch_oam_trust_policy() -> dict: KMS_KEY_POLICIES: dict = load_kms_key_policies() CLOUDWATCH_OAM_SINK_POLICY: dict = load_cloudwatch_oam_sink_policy() CLOUDWATCH_OAM_TRUST_POLICY: dict = load_sra_cloudwatch_oam_trust_policy() -ALARM_SNS_KEY_ALIAS = "sra-alarm-sns-key" +CLOUDWATCH_DASHBOARD: dict = load_sra_cloudwatch_dashboard() # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? @@ -325,6 +328,27 @@ def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: s3_filter = s3_filter.replace('&& ($.requestParameters.bucketName = "")', "") return s3_filter +def build_cloudwatch_dashboard(dashboard_template, bedrock_accounts, regions): + i = 0 + for bedrock_account in bedrock_accounts: + for region in regions: + if i == 0: + injection_template = copy.deepcopy(dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2]) + sensitive_info_template = copy.deepcopy(dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3]) + else: + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"].append(copy.deepcopy(injection_template)) + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"].append(copy.deepcopy(sensitive_info_template)) + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2 + i][2]["accountId"] = bedrock_account + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2 + i][2]["region"] = region + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3 + i][2]["accountId"] = bedrock_account + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3 + i][2]["region"] = region + i += 2 + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][0][2]["accountId"] = sts.MANAGEMENT_ACCOUNT + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][0][2]["region"] = sts.HOME_REGION + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][1][2]["accountId"] = sts.MANAGEMENT_ACCOUNT + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][1][2]["region"] = sts.HOME_REGION + dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["region"] = sts.HOME_REGION + return dashboard_template def create_event(event, context): global DRY_RUN_DATA @@ -704,7 +728,7 @@ def create_event(event, context): "OAMCrossAccountRolePolicyAttach" ] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" - # 5d) OAM link in bedrock account + # 5e) OAM link in bedrock account cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) search_oam_link = cloudwatch.find_oam_link(oam_sink_arn) if search_oam_link[0] is False: @@ -721,6 +745,41 @@ def create_event(event, context): else: LOGGER.info("CloudWatch observability access manager link found") + # 6) Cloudwatch dashboard in security account + cloudwatch_dashboard = build_cloudwatch_dashboard(CLOUDWATCH_DASHBOARD, central_observability_params["bedrock_accounts"], central_observability_params["regions"]) + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) + # sra-bedrock-filter-prompt-injection-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2] + # sra-bedrock-filter-sensitive-info-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3] + + search_dashboard = cloudwatch.find_dashboard(SOLUTION_NAME) + if search_dashboard[0] is False: + if DRY_RUN is False: + LOGGER.info("CloudWatch observability dashboard not found, creating...") + cloudwatch.create_dashboard(cloudwatch.SOLUTION_NAME, cloudwatch_dashboard) + LIVE_RUN_DATA["CloudWatchDashboardCreate"] = "Created CloudWatch observability dashboard" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info("Created CloudWatch observability dashboard") + else: + LOGGER.info("DRY_RUN: CloudWatch observability dashboard not found, creating...") + DRY_RUN_DATA["CloudWatchDashboardCreate"] = "DRY_RUN: Create CloudWatch observability dashboard" + else: + LOGGER.info(f"Cloudwatch dashboard already exists: {search_dashboard[1]}") + # check_dashboard = cloudwatch.compare_dashboard(search_dashboard[1], cloudwatch_dashboard) + # if check_dashboard is False: + # if DRY_RUN is False: + # LOGGER.info("CloudWatch observability dashboard needs updating...") + # cloudwatch.create_dashboard(cloudwatch.SOLUTION_NAME, cloudwatch_dashboard) + # LIVE_RUN_DATA["OAMDashboardUpdate"] = "Updated CloudWatch observability dashboard" + # CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + # CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + # LOGGER.info("Updated CloudWatch observability dashboard") + # else: + # LOGGER.info("DRY_RUN: CloudWatch observability dashboard needs updating...") + # DRY_RUN_DATA["OAMDashboardUpdate"] = "DRY_RUN: Update CloudWatch observability dashboard" + # else: + # LOGGER.info("CloudWatch observability dashboard is correct") + # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index feef72141..431a26ee3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -407,3 +407,69 @@ def delete_oam_link(self, link_arn: str) -> None: except ClientError as e: self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + + def find_dashboard(self, dashboard_name: str) -> tuple[bool, str]: + """Find the CloudWatch dashboard for SRA in the organization. + + Args: + dashboard_name (str): name of the dashboard + + Returns: + tuple[bool, str]: True if the dashboard is found, False if not, and the dashboard ARN + """ + try: + response = self.CLOUDWATCH_CLIENT.list_dashboards() + for dashboard in response["DashboardEntries"]: + if dashboard["DashboardName"] == dashboard_name: + self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} found: {dashboard['DashboardArn']}") + return True, dashboard["DashboardArn"] + self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} not found") + return False, "" + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} not found. Error code: {error.response['Error']['Code']}") + return False, "" + else: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + + def create_dashboard(self, dashboard_name: str, dashboard_body: dict) -> str: + """Create the CloudWatch dashboard for SRA in the organization. + + Args: + dashboard_name (str): name of the dashboard + dashboard_body (str): body of the dashboard + + Returns: + str: ARN of the created dashboard + """ + try: + response = self.CLOUDWATCH_CLIENT.put_dashboard( + DashboardName=dashboard_name, + DashboardBody=json.dumps(dashboard_body) + ) + self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} created: {response['DashboardArn']}") + return response["DashboardArn"] + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceAlreadyExistsException": + self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} already exists") + return self.find_dashboard(dashboard_name)[1] + else: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + + def delete_dashboard(self, dashboard_arn: str) -> None: + """Delete the CloudWatch dashboard for SRA in the organization. + + Args: + dashboard_arn (str): ARN of the dashboard + + Returns: + None + """ + try: + self.CLOUDWATCH_CLIENT.delete_dashboards(DashboardNames=[dashboard_arn]) + self.LOGGER.info(f"CloudWatch dashboard {dashboard_arn} deleted") + except ClientError as e: + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_dashboard.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_dashboard.json new file mode 100644 index 000000000..bc249ec98 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_dashboard.json @@ -0,0 +1,43 @@ +{ + "sra-bedrock-org": { + "widgets": [ + { + "height": 9, + "width": 24, + "y": 0, + "x": 0, + "type": "metric", + "properties": { + "metrics": [ + [ + ".", + "sra-bedrock-filter-bucket-changes-metric", + { "accountId": "", "region": "" } + ], + [ + ".", + "sra-bedrock-filter-service-changes-metric", + { "accountId": "", "region": "" } + ], + [ + "sra-bedrock", + "sra-bedrock-filter-prompt-injection-metric", + { "accountId": "", "region": "" } + ], + [ + ".", + "sra-bedrock-filter-sensitive-info-metric", + { "accountId": "", "region": "" } + ] + ], + "view": "timeSeries", + "stacked": false, + "region": "", + "title": "SRA Bedrock Generative AI Metrics and Alarms", + "period": 1, + "stat": "Sum" + } + } + ] + } +} From 850d18c191ec43a333e3ea3557142deb07a9168e Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 22 Oct 2024 14:59:52 -0600 Subject: [PATCH 136/395] update dashboard var --- .../genai/bedrock_org/lambda/src/app.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a988bff53..6ec8822c7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -328,27 +328,27 @@ def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: s3_filter = s3_filter.replace('&& ($.requestParameters.bucketName = "")', "") return s3_filter -def build_cloudwatch_dashboard(dashboard_template, bedrock_accounts, regions): +def build_cloudwatch_dashboard(dashboard_template, solution, bedrock_accounts, regions): i = 0 for bedrock_account in bedrock_accounts: for region in regions: if i == 0: - injection_template = copy.deepcopy(dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2]) - sensitive_info_template = copy.deepcopy(dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3]) + injection_template = copy.deepcopy(dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2]) + sensitive_info_template = copy.deepcopy(dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3]) else: - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"].append(copy.deepcopy(injection_template)) - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"].append(copy.deepcopy(sensitive_info_template)) - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2 + i][2]["accountId"] = bedrock_account - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2 + i][2]["region"] = region - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3 + i][2]["accountId"] = bedrock_account - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3 + i][2]["region"] = region + dashboard_template[solution]["widgets"][0]["properties"]["metrics"].append(copy.deepcopy(injection_template)) + dashboard_template[solution]["widgets"][0]["properties"]["metrics"].append(copy.deepcopy(sensitive_info_template)) + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2 + i][2]["accountId"] = bedrock_account + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2 + i][2]["region"] = region + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3 + i][2]["accountId"] = bedrock_account + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3 + i][2]["region"] = region i += 2 - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][0][2]["accountId"] = sts.MANAGEMENT_ACCOUNT - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][0][2]["region"] = sts.HOME_REGION - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][1][2]["accountId"] = sts.MANAGEMENT_ACCOUNT - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][1][2]["region"] = sts.HOME_REGION - dashboard_template["sra-bedrock-org"]["widgets"][0]["properties"]["region"] = sts.HOME_REGION - return dashboard_template + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][0][2]["accountId"] = sts.MANAGEMENT_ACCOUNT + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][0][2]["region"] = sts.HOME_REGION + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][1][2]["accountId"] = sts.MANAGEMENT_ACCOUNT + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][1][2]["region"] = sts.HOME_REGION + dashboard_template[solution]["widgets"][0]["properties"]["region"] = sts.HOME_REGION + return dashboard_template[solution] def create_event(event, context): global DRY_RUN_DATA @@ -746,7 +746,7 @@ def create_event(event, context): LOGGER.info("CloudWatch observability access manager link found") # 6) Cloudwatch dashboard in security account - cloudwatch_dashboard = build_cloudwatch_dashboard(CLOUDWATCH_DASHBOARD, central_observability_params["bedrock_accounts"], central_observability_params["regions"]) + cloudwatch_dashboard = build_cloudwatch_dashboard(CLOUDWATCH_DASHBOARD, SOLUTION_NAME, central_observability_params["bedrock_accounts"], central_observability_params["regions"]) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) # sra-bedrock-filter-prompt-injection-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2] # sra-bedrock-filter-sensitive-info-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3] From b986decd6cb29c3c2553bb3f475e8fff8c3531bb Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 22 Oct 2024 15:29:27 -0600 Subject: [PATCH 137/395] trace dashboard def --- .../solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 431a26ee3..c78776415 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -444,6 +444,8 @@ def create_dashboard(self, dashboard_name: str, dashboard_body: dict) -> str: str: ARN of the created dashboard """ try: + self.LOGGER.info(f"Creating CloudWatch dashboard {dashboard_name} as: {json.dumps(dashboard_body)}") + self.LOGGER.info({"dashboard json": dashboard_body}) response = self.CLOUDWATCH_CLIENT.put_dashboard( DashboardName=dashboard_name, DashboardBody=json.dumps(dashboard_body) From 5e933d1ea08bcd3a74f504c0a8d1d83288fbf569 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 22 Oct 2024 15:57:45 -0600 Subject: [PATCH 138/395] change metric in dashboard --- .../bedrock_org/lambda/src/sra_cloudwatch_dashboard.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_dashboard.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_dashboard.json index bc249ec98..68319a922 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_dashboard.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch_dashboard.json @@ -10,12 +10,12 @@ "properties": { "metrics": [ [ - ".", + "sra-bedrock", "sra-bedrock-filter-bucket-changes-metric", { "accountId": "", "region": "" } ], [ - ".", + "sra-bedrock", "sra-bedrock-filter-service-changes-metric", { "accountId": "", "region": "" } ], @@ -25,7 +25,7 @@ { "accountId": "", "region": "" } ], [ - ".", + "sra-bedrock", "sra-bedrock-filter-sensitive-info-metric", { "accountId": "", "region": "" } ] From 3bfef817993930605e4b50af09ee88251220113b Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 22 Oct 2024 16:20:53 -0600 Subject: [PATCH 139/395] change output of create_dashboard function --- .../solutions/genai/bedrock_org/lambda/src/app.py | 2 ++ .../solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 6ec8822c7..cfa86e2a5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -126,6 +126,8 @@ def load_sra_cloudwatch_dashboard() -> dict: cloudwatch = sra_cloudwatch.sra_cloudwatch() kms = sra_kms.sra_kms() +# propagate solution name to class objects +cloudwatch.SOLUTION_NAME = SOLUTION_NAME def get_resource_parameters(event): global DRY_RUN diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index c78776415..43a956a22 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -450,8 +450,8 @@ def create_dashboard(self, dashboard_name: str, dashboard_body: dict) -> str: DashboardName=dashboard_name, DashboardBody=json.dumps(dashboard_body) ) - self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} created: {response['DashboardArn']}") - return response["DashboardArn"] + self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} created: {response['DashboardValidationMessages']}") + return self.find_dashboard(dashboard_name)[1] except ClientError as error: if error.response["Error"]["Code"] == "ResourceAlreadyExistsException": self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} already exists") From 8b0b778bbedfefdef3d6fb50c7dc6e092b2208e6 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 22 Oct 2024 16:36:34 -0600 Subject: [PATCH 140/395] modify bedrock accounts variable --- .../solutions/genai/bedrock_org/lambda/src/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index cfa86e2a5..1899519e0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -683,8 +683,9 @@ def create_event(event, context): # 5c) OAM CloudWatch-CrossAccountSharingRole IAM role # Add management account to the bedrock accounts list - central_observability_params["bedrock_accounts"].append(sts.MANAGEMENT_ACCOUNT) - for bedrock_account in central_observability_params["bedrock_accounts"]: + bedrock_and_mgmt_accounts = copy.deepcopy(central_observability_params["bedrock_accounts"]) + bedrock_and_mgmt_accounts.append(sts.MANAGEMENT_ACCOUNT) + for bedrock_account in bedrock_and_mgmt_accounts: for bedrock_region in central_observability_params["regions"]: iam.IAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) cloudwatch.CROSS_ACCOUNT_TRUST_POLICY = CLOUDWATCH_OAM_TRUST_POLICY[cloudwatch.CROSS_ACCOUNT_ROLE_NAME] From 83e92841dc5fb1d3203e09c5dd32e23067561083 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 24 Oct 2024 13:55:07 -0600 Subject: [PATCH 141/395] update invoc log check to fix --- .../app.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index 490ff9430..a9f649987 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -27,13 +27,16 @@ def evaluate_compliance(rule_parameters): try: response = bedrock_client.get_model_invocation_logging_configuration() + LOGGER.info(f"Bedrock get_model_invocation_logging_configuration response: {response}") logging_config = response.get('loggingConfig', {}) - + LOGGER.info(f"Bedrock Model Invocation Logging Configuration: {logging_config}") + cloudwatch_config = logging_config.get('cloudWatchConfig', {}) - cloudwatch_enabled = cloudwatch_config.get('enabled', False) - log_group_name = cloudwatch_config.get('logGroupName') + LOGGER.info(f"Bedrock Model Invocation config: {cloudwatch_config}") + log_group_name = cloudwatch_config.get('logGroupName', "") + LOGGER.info(f"Bedrock Model Invocation Log Group: {log_group_name}") - if not cloudwatch_enabled or not log_group_name: + if not cloudwatch_config or not log_group_name: return 'NON_COMPLIANT', "CloudWatch logging is not enabled for Bedrock Model Invocation Logging" # Check retention and encryption if enabled From 8ddd836478253b3bf748acdfe8a40da080b6b446 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 24 Oct 2024 16:59:47 -0600 Subject: [PATCH 142/395] update error handing for rules --- .../app.py | 2 +- .../app.py | 38 ++++++++++++++----- .../sra_config_lambda_iam_permissions.json | 6 +-- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index a9f649987..cb487bb3e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -58,7 +58,7 @@ def evaluate_compliance(rule_parameters): except Exception as e: LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") - return 'ERROR', f"Error evaluating compliance: {str(e)}" + return 'INSUFFICIENT_DATA', f"Error evaluating compliance: {str(e)}" def lambda_handler(event, context): LOGGER.info('Evaluating compliance for AWS Config rule') diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index 0fdee19a0..8a61411e4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -2,6 +2,7 @@ import json import os import logging +import botocore # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -33,10 +34,11 @@ def evaluate_compliance(rule_parameters): logging_config = response.get('loggingConfig', {}) s3_config = logging_config.get('s3Config', {}) - s3_enabled = s3_config.get('enabled', False) - bucket_name = s3_config.get('s3BucketName') + LOGGER.info(f"Bedrock Model Invocation S3 config: {s3_config}") + bucket_name = s3_config.get('bucketName', "") + LOGGER.info(f"Bedrock Model Invocation S3 bucketName: {bucket_name}") - if not s3_enabled or not bucket_name: + if not s3_config or not bucket_name: return 'NON_COMPLIANT', "S3 logging is not enabled for Bedrock Model Invocation Logging" # Check S3 bucket configurations @@ -57,15 +59,31 @@ def evaluate_compliance(rule_parameters): if 'LoggingEnabled' not in logging: issues.append("server access logging not enabled") - if check_object_locking: - object_lock = s3_client.get_object_lock_configuration(Bucket=bucket_name) - if 'ObjectLockConfiguration' not in object_lock: - issues.append("object locking not enabled") - if check_versioning: versioning = s3_client.get_bucket_versioning(Bucket=bucket_name) if versioning.get('Status') != 'Enabled': issues.append("versioning not enabled") + try: + if check_object_locking: + object_lock = s3_client.get_object_lock_configuration(Bucket=bucket_name) + if 'ObjectLockConfiguration' not in object_lock: + issues.append("object locking not enabled") + except botocore.exceptions.ClientError as error: + error_code = error.response['Error']['Code'] + if error_code == "ObjectLockConfigurationNotFoundError": + LOGGER.info(f"Object Lock is not enabled for S3 bucket: {bucket_name}") + issues.append("object locking not enabled") + else: + LOGGER.info(f"Error evaluating Object Lock configuration: {str(error)}") + return 'INSUFFICIENT_DATA', f"Error evaluating Object Lock configuration: {str(error)}" + # except Exception as error: + # error_code = type(error).__name__ + # if error_code == "ObjectLockConfigurationNotFoundError": + # LOGGER.info(f"Object Lock is not enabled for S3 bucket: {bucket_name}") + # return 'NON_COMPLIANT', f"Object Lock is not enabled for S3 bucket: {bucket_name}" + # else: + # LOGGER.error(f"Error evaluating Object Lock configuration: {str(error)}") + # return 'INSUFFICIENT_DATA', f"Error evaluating Object Lock configuration: {str(error)}" if issues: return 'NON_COMPLIANT', f"S3 logging enabled but {', '.join(issues)}" @@ -74,7 +92,7 @@ def evaluate_compliance(rule_parameters): except Exception as e: LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") - return 'ERROR', f"Error evaluating compliance: {str(e)}" + return 'INSUFFICIENT_DATA', f"Error evaluating compliance: {str(e)}" def lambda_handler(event, context): LOGGER.info('Evaluating compliance for AWS Config rule') @@ -100,5 +118,7 @@ def lambda_handler(event, context): Evaluations=[evaluation], ResultToken=event['resultToken'] ) +# ^^^ [ERROR] ValidationException: An error occurred (ValidationException) when calling the PutEvaluations operation: +# 1 validation error detected: Value 'ERROR' at 'evaluations.1.member.complianceType' failed to satisfy constraint: Member must satisfy enum value set: [INSUFFICIENT_DATA, NON_COMPLIANT, NOT_APPLICABLE, COMPLIANT] LOGGER.info("Compliance evaluation complete.") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config_lambda_iam_permissions.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config_lambda_iam_permissions.json index ac24957ae..de11d6ac8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config_lambda_iam_permissions.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config_lambda_iam_permissions.json @@ -81,10 +81,10 @@ "Sid": "AllowGetBucketConf", "Effect": "Allow", "Action": [ - "s3:GetBucketLifecycleConfiguration", - "s3:GetBucketEncryption", + "s3:GetBucketObjectLockConfiguration", + "s3:GetLifecycleConfiguration", + "s3:GetEncryptionConfiguration", "s3:GetBucketLogging", - "s3:GetObjectLockConfiguration", "s3:GetBucketVersioning" ], "Resource": "arn:aws:s3:::*" From b6f16f9873435aab766d88f30a71cd00b6546bc0 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Fri, 25 Oct 2024 00:15:13 -0600 Subject: [PATCH 143/395] remove comment --- .../rules/sra_bedrock_check_invocation_log_s3/app.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index 8a61411e4..3de1573fe 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -76,14 +76,6 @@ def evaluate_compliance(rule_parameters): else: LOGGER.info(f"Error evaluating Object Lock configuration: {str(error)}") return 'INSUFFICIENT_DATA', f"Error evaluating Object Lock configuration: {str(error)}" - # except Exception as error: - # error_code = type(error).__name__ - # if error_code == "ObjectLockConfigurationNotFoundError": - # LOGGER.info(f"Object Lock is not enabled for S3 bucket: {bucket_name}") - # return 'NON_COMPLIANT', f"Object Lock is not enabled for S3 bucket: {bucket_name}" - # else: - # LOGGER.error(f"Error evaluating Object Lock configuration: {str(error)}") - # return 'INSUFFICIENT_DATA', f"Error evaluating Object Lock configuration: {str(error)}" if issues: return 'NON_COMPLIANT', f"S3 logging enabled but {', '.join(issues)}" From a3643b39951896344de6f9df66b3d4d66836812f Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 26 Oct 2024 19:22:28 -0600 Subject: [PATCH 144/395] working on delete operation for cw dashboard; untested --- .../genai/bedrock_org/lambda/src/app.py | 51 ++++++++----------- .../bedrock_org/lambda/src/sra_cloudwatch.py | 8 +-- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 1899519e0..47ef6a479 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -598,34 +598,6 @@ def create_event(event, context): DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" # 5) Central CloudWatch Observability - # TODO(liamschn): determine if we need the CloudWatch-CrossAccountListAccountsRole (needed for "Enable account selector"?). - # TRUST - # { - # "Version": "2012-10-17", - # "Statement": [ - # { - # "Effect": "Allow", - # "Principal": { - # "AWS": "arn:aws:iam::533267199951:root" - # }, - # "Action": "sts:AssumeRole" - # } - # ] - # } - # PERMISSIONS - # { - # "Version": "2012-10-17", - # "Statement": [ - # { - # "Action": [ - # "organizations:ListAccounts", - # "organizations:ListAccountsForParent" - # ], - # "Resource": "*", - # "Effect": "Allow" - # } - # ] - # } central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) # TODO(liamschn): create a parameter to choose to deploy central observability or not: deploy_central_observability = true/false # 5a) OAM Sink in security account @@ -768,6 +740,7 @@ def create_event(event, context): DRY_RUN_DATA["CloudWatchDashboardCreate"] = "DRY_RUN: Create CloudWatch observability dashboard" else: LOGGER.info(f"Cloudwatch dashboard already exists: {search_dashboard[1]}") + # TODO(liamschn): check content of dashboard to ensure it is the latest content and update as needed # check_dashboard = cloudwatch.compare_dashboard(search_dashboard[1], cloudwatch_dashboard) # if check_dashboard is False: # if DRY_RUN is False: @@ -841,6 +814,23 @@ def delete_event(event, context): LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic does not exist.") # 2) Delete Central CloudWatch Observability + # 2a) Delete cloudwatch dashboard + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) + search_dashboard = cloudwatch.find_dashboard(SOLUTION_NAME) + if search_dashboard[0] is False: + LOGGER.info("CloudWatch observability dashboard not found") + else: + if DRY_RUN is False: + LOGGER.info("Deleting CloudWatch observability dashboard") + LIVE_RUN_DATA["CloudWatchDashboardDelete"] = "Deleted CloudWatch observability dashboard" + cloudwatch.delete_dashboard(SOLUTION_NAME) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + else: + LOGGER.info("DRY_RUN: Deleting CloudWatch observability dashboard") + + + central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) @@ -852,8 +842,9 @@ def delete_event(event, context): oam_sink_arn = "Error:Sink:Arn:Not:Found" # Add management account to the bedrock accounts list - central_observability_params["bedrock_accounts"].append(sts.MANAGEMENT_ACCOUNT) - for bedrock_account in central_observability_params["bedrock_accounts"]: + bedrock_and_mgmt_accounts = copy.deepcopy(central_observability_params["bedrock_accounts"]) + bedrock_and_mgmt_accounts.append(sts.MANAGEMENT_ACCOUNT) + for bedrock_account in bedrock_and_mgmt_accounts: for bedrock_region in central_observability_params["regions"]: # 2a) OAM link in bedrock account cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 43a956a22..c6e029ab5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -460,18 +460,18 @@ def create_dashboard(self, dashboard_name: str, dashboard_body: dict) -> str: self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {error}") from None - def delete_dashboard(self, dashboard_arn: str) -> None: + def delete_dashboard(self, dashboard_name: str) -> None: """Delete the CloudWatch dashboard for SRA in the organization. Args: - dashboard_arn (str): ARN of the dashboard + dashboard_name (str): Name of the dashboard Returns: None """ try: - self.CLOUDWATCH_CLIENT.delete_dashboards(DashboardNames=[dashboard_arn]) - self.LOGGER.info(f"CloudWatch dashboard {dashboard_arn} deleted") + self.CLOUDWATCH_CLIENT.delete_dashboards(DashboardNames=[dashboard_name]) + self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} deleted") except ClientError as e: self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {e}") from None \ No newline at end of file From 8b4be3f6cc098626a01df0951aa8ef09a73d6841 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 28 Oct 2024 09:58:57 -0600 Subject: [PATCH 145/395] delete policy versions --- .../solutions/genai/bedrock_org/lambda/src/sra_iam.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 3597ef518..0b6472a75 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -348,6 +348,16 @@ def delete_policy(self, policy_arn: str) -> EmptyResponseMetadataTypeDef: Empty response metadata """ self.LOGGER.info("Deleting policy %s.", policy_arn) + # check for policy versions and delete them if found + paginator = self.IAM_CLIENT.get_paginator("list_policy_versions") + response_iterator = paginator.paginate(PolicyArn=policy_arn) + for page in response_iterator: + for version in page["Versions"]: + if not version["IsDefaultVersion"]: + self.LOGGER.info(f"Deleting policy version {version['VersionId']}") + self.IAM_CLIENT.delete_policy_version(PolicyArn=policy_arn, VersionId=version["VersionId"]) + sleep(1) + self.LOGGER.info("Policy version deleted.") return self.IAM_CLIENT.delete_policy(PolicyArn=policy_arn) def delete_role(self, role_name: str) -> EmptyResponseMetadataTypeDef: From 8c84fe7c88473c18c501b56a71f9392132807602 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Tue, 29 Oct 2024 16:42:23 -0600 Subject: [PATCH 146/395] create operations to separate functions --- .../genai/bedrock_org/lambda/src/app.py | 119 +++++++++++------- 1 file changed, 73 insertions(+), 46 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 47ef6a479..14eb0e6f7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -22,11 +22,12 @@ # import sra_lambda -# TODO(liamschn): Need to test with (and create) a CFN template # TODO(liamschn): If dynamoDB sra_state table exists, use it # TODO(liamschn): Where do we see dry-run data? Maybe S3 staging bucket file? The sra_state table? Another DynamoDB table? # TODO(liamschn): add parameter validation - +# TODO(liamschn): deploy example bedrock guardrail +# TODO(liamschn): deploy example iam role(s) and policy(ies) +# TODO(liamschn): deploy example bucket policy(ies) from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional @@ -72,11 +73,11 @@ def load_sra_cloudwatch_dashboard() -> dict: RESOURCE_TYPE: str = "" STATE_TABLE: str = "sra_state" SOLUTION_NAME: str = "sra-bedrock-org" -RULE_REGIONS_ACCOUNTS: list = {} +# RULE_REGIONS_ACCOUNTS: list = {} GOVERNED_REGIONS = [] SECURITY_ACCOUNT = "" ORGANIZATION_ID = "" -BEDROCK_MODEL_EVAL_BUCKET: str = "" +# BEDROCK_MODEL_EVAL_BUCKET: str = "" SRA_ALARM_EMAIL: str = "" SRA_ALARM_TOPIC_ARN: str = "" @@ -131,9 +132,9 @@ def load_sra_cloudwatch_dashboard() -> dict: def get_resource_parameters(event): global DRY_RUN - global RULE_REGIONS_ACCOUNTS + # global RULE_REGIONS_ACCOUNTS global GOVERNED_REGIONS - global BEDROCK_MODEL_EVAL_BUCKET + # global BEDROCK_MODEL_EVAL_BUCKET global CFN_RESPONSE_DATA global SRA_ALARM_EMAIL global SECURITY_ACCOUNT @@ -181,11 +182,11 @@ def get_resource_parameters(event): LOGGER.info("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") raise ValueError("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") from None # TODO(liamschn): remove the RULE_REGIONS_ACCOUNTS parameter after confirming it is no longer used. - if "RULE_REGIONS_ACCOUNTS" in event["ResourceProperties"]: - RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'", '"')) + # if "RULE_REGIONS_ACCOUNTS" in event["ResourceProperties"]: + # RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'", '"')) # TODO(liamschn): remove the BEDROCK_MODEL_EVAL_BUCKET parameter after confirming it is no longer used. - if "BEDROCK_MODEL_EVAL_BUCKET" in event["ResourceProperties"]: - BEDROCK_MODEL_EVAL_BUCKET = event["ResourceProperties"]["BEDROCK_MODEL_EVAL_BUCKET"] + # if "BEDROCK_MODEL_EVAL_BUCKET" in event["ResourceProperties"]: + # BEDROCK_MODEL_EVAL_BUCKET = event["ResourceProperties"]["BEDROCK_MODEL_EVAL_BUCKET"] if event["ResourceProperties"]["SRA_ALARM_EMAIL"] != "": SRA_ALARM_EMAIL = event["ResourceProperties"]["SRA_ALARM_EMAIL"] @@ -352,18 +353,11 @@ def build_cloudwatch_dashboard(dashboard_template, solution, bedrock_accounts, r dashboard_template[solution]["widgets"][0]["properties"]["region"] = sts.HOME_REGION return dashboard_template[solution] -def create_event(event, context): +def deploy_stage_config_rule_lambda_code(): global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA - global SRA_ALARM_TOPIC_ARN - DRY_RUN_DATA = {} - LIVE_RUN_DATA = {} - event_info = {"Event": event} - LOGGER.info(event_info) - - # 1) Stage config rule lambda code if DRY_RUN is False: LOGGER.info("Live run: downloading and staging the config rule code...") repo.download_code_library(repo.REPO_ZIP_URL) @@ -380,9 +374,11 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") - # 2) SNS topics for fanout configuration operations - # TODO(liamschn): analyze again if the configuration sns topic is needed for this solution (probably is needed) - # TODO(liamschn): if needed, then change the code to have the create events call the sns topic which calls the lambda for configuration/deployment +def deploy_sns_configuration_topics(context): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is None: if DRY_RUN is False: @@ -420,7 +416,11 @@ def create_event(event, context): LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search - # 3) Deploy config rules +def deploy_config_rules(event): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + for rule in repo.CONFIG_RULES[SOLUTION_NAME]: rule_name = rule.replace("_", "-") # Get bedrock solution rule accounts and regions @@ -460,7 +460,11 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" - # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms +def deploy_metric_filters_and_alarms(event): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + LOGGER.info(f"CloudWatch Metric Filters: {CLOUDWATCH_METRIC_FILTERS}") for filter in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) @@ -597,7 +601,11 @@ def create_event(event, context): LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" - # 5) Central CloudWatch Observability +def deploy_central_cloudwatch_observability(event): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) # TODO(liamschn): create a parameter to choose to deploy central observability or not: deploy_central_observability = true/false # 5a) OAM Sink in security account @@ -720,7 +728,13 @@ def create_event(event, context): else: LOGGER.info("CloudWatch observability access manager link found") - # 6) Cloudwatch dashboard in security account +def deploy_cloudwatch_dashboard(): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + + central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) + cloudwatch_dashboard = build_cloudwatch_dashboard(CLOUDWATCH_DASHBOARD, SOLUTION_NAME, central_observability_params["bedrock_accounts"], central_observability_params["regions"]) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) # sra-bedrock-filter-prompt-injection-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2] @@ -740,7 +754,6 @@ def create_event(event, context): DRY_RUN_DATA["CloudWatchDashboardCreate"] = "DRY_RUN: Create CloudWatch observability dashboard" else: LOGGER.info(f"Cloudwatch dashboard already exists: {search_dashboard[1]}") - # TODO(liamschn): check content of dashboard to ensure it is the latest content and update as needed # check_dashboard = cloudwatch.compare_dashboard(search_dashboard[1], cloudwatch_dashboard) # if check_dashboard is False: # if DRY_RUN is False: @@ -756,6 +769,38 @@ def create_event(event, context): # else: # LOGGER.info("CloudWatch observability dashboard is correct") + +def create_event(event, context): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + + global SRA_ALARM_TOPIC_ARN + DRY_RUN_DATA = {} + LIVE_RUN_DATA = {} + + event_info = {"Event": event} + LOGGER.info(event_info) + + # 1) Stage config rule lambda code + deploy_stage_config_rule_lambda_code() + + # 2) SNS topics for fanout configuration operations + # TODO(liamschn): change the code to have the create events call the sns topic (by publishing events for accounts/regions) which calls the lambda for configuration/deployment + deploy_sns_configuration_topics(context) + + # 3) Deploy config rules + deploy_config_rules(event) + + # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms + deploy_metric_filters_and_alarms(event) + + # 5) Central CloudWatch Observability + deploy_central_cloudwatch_observability(event) + + # 6) Cloudwatch dashboard in security account + deploy_cloudwatch_dashboard() + # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: @@ -814,23 +859,6 @@ def delete_event(event, context): LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic does not exist.") # 2) Delete Central CloudWatch Observability - # 2a) Delete cloudwatch dashboard - cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) - search_dashboard = cloudwatch.find_dashboard(SOLUTION_NAME) - if search_dashboard[0] is False: - LOGGER.info("CloudWatch observability dashboard not found") - else: - if DRY_RUN is False: - LOGGER.info("Deleting CloudWatch observability dashboard") - LIVE_RUN_DATA["CloudWatchDashboardDelete"] = "Deleted CloudWatch observability dashboard" - cloudwatch.delete_dashboard(SOLUTION_NAME) - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - else: - LOGGER.info("DRY_RUN: Deleting CloudWatch observability dashboard") - - - central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) @@ -842,9 +870,8 @@ def delete_event(event, context): oam_sink_arn = "Error:Sink:Arn:Not:Found" # Add management account to the bedrock accounts list - bedrock_and_mgmt_accounts = copy.deepcopy(central_observability_params["bedrock_accounts"]) - bedrock_and_mgmt_accounts.append(sts.MANAGEMENT_ACCOUNT) - for bedrock_account in bedrock_and_mgmt_accounts: + central_observability_params["bedrock_accounts"].append(sts.MANAGEMENT_ACCOUNT) + for bedrock_account in central_observability_params["bedrock_accounts"]: for bedrock_region in central_observability_params["regions"]: # 2a) OAM link in bedrock account cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) From d1e03c8c630eaa4f8f5d76992bbf7e3c5ebf6c8e Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Wed, 30 Oct 2024 10:06:19 -0600 Subject: [PATCH 147/395] minor update to fix event issue --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 14eb0e6f7..552567bbd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -728,7 +728,7 @@ def deploy_central_cloudwatch_observability(event): else: LOGGER.info("CloudWatch observability access manager link found") -def deploy_cloudwatch_dashboard(): +def deploy_cloudwatch_dashboard(event): global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -799,7 +799,7 @@ def create_event(event, context): deploy_central_cloudwatch_observability(event) # 6) Cloudwatch dashboard in security account - deploy_cloudwatch_dashboard() + deploy_cloudwatch_dashboard(event) # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message From 5e2c13f769e4711f0c5dd07e19e58ea37ec91e33 Mon Sep 17 00:00:00 2001 From: Liam Schneider Date: Thu, 31 Oct 2024 16:45:55 -0600 Subject: [PATCH 148/395] refactoring so sns topics can be used for config rules; in progress --- .../genai/bedrock_org/lambda/src/app.py | 126 ++++++++++++++---- .../genai/bedrock_org/lambda/src/sra_sns.py | 41 +++++- .../templates/sra-bedrock-org-main.yaml | 59 +++++--- 3 files changed, 179 insertions(+), 47 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 552567bbd..d4ce42526 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -216,6 +216,23 @@ def get_rule_params(rule_name, event): rule_regions (list): list of regions to deploy the rule to rule_input_params (dict): dictionary of rule input parameters """ + # TODO(liamschn): SRA-BEDROCK-ACCOUNTS and SRA-BEDROCK-REGIONS to be moved to a more global area so it is not defined more than once + if "SRA-BEDROCK-ACCOUNTS" in event["ResourceProperties"]: + LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") + rule_accounts = json.loads(event["ResourceProperties"]["SRA-BEDROCK-ACCOUNTS"]) + LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {rule_accounts}") + else: + LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None and deploy to False") + rule_accounts = [] + rule_deploy = False + if "SRA-BEDROCK-REGIONS" in event["ResourceProperties"]: + LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") + rule_regions = json.loads(event["ResourceProperties"]["SRA-BEDROCK-REGIONS"]) + LOGGER.info(f"SRA-BEDROCK-REGIONS: {rule_regions}") + else: + LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None and deploy to False") + rule_regions = [] + rule_deploy = False if rule_name.upper() in event["ResourceProperties"]: LOGGER.info(f"{rule_name} parameter found in event ResourceProperties") rule_params = json.loads(event["ResourceProperties"][rule_name.upper()]) @@ -231,22 +248,22 @@ def get_rule_params(rule_name, event): else: LOGGER.info(f"{rule_name.upper()} 'deploy' parameter not found in event ResourceProperties; setting to False") rule_deploy = False - if "accounts" in rule_params: - LOGGER.info(f"{rule_name.upper()} 'accounts' parameter found in event ResourceProperties") - rule_accounts = rule_params["accounts"] - LOGGER.info(f"{rule_name.upper()} accounts: {rule_accounts}") - else: - LOGGER.info(f"{rule_name.upper()} 'accounts' parameter not found in event ResourceProperties; setting to None and deploy to False") - rule_accounts = [] - rule_deploy = False - if "regions" in rule_params: - LOGGER.info(f"{rule_name.upper()} 'regions' parameter found in event ResourceProperties") - rule_regions = rule_params["regions"] - LOGGER.info(f"{rule_name.upper()} regions: {rule_regions}") - else: - LOGGER.info(f"{rule_name.upper()} 'regions' parameter not found in event ResourceProperties; setting to None and deploy to False") - rule_regions = [] - rule_deploy = False + # if "accounts" in rule_params: + # LOGGER.info(f"{rule_name.upper()} 'accounts' parameter found in event ResourceProperties") + # rule_accounts = rule_params["accounts"] + # LOGGER.info(f"{rule_name.upper()} accounts: {rule_accounts}") + # else: + # LOGGER.info(f"{rule_name.upper()} 'accounts' parameter not found in event ResourceProperties; setting to None and deploy to False") + # rule_accounts = [] + # rule_deploy = False + # if "regions" in rule_params: + # LOGGER.info(f"{rule_name.upper()} 'regions' parameter found in event ResourceProperties") + # rule_regions = rule_params["regions"] + # LOGGER.info(f"{rule_name.upper()} regions: {rule_regions}") + # else: + # LOGGER.info(f"{rule_name.upper()} 'regions' parameter not found in event ResourceProperties; setting to None and deploy to False") + # rule_regions = [] + # rule_deploy = False if "input_params" in rule_params: LOGGER.info(f"{rule_name.upper()} 'input_params' parameter found in event ResourceProperties") rule_input_params = rule_params["input_params"] @@ -782,23 +799,26 @@ def create_event(event, context): event_info = {"Event": event} LOGGER.info(event_info) - # 1) Stage config rule lambda code + # 1) Stage config rule lambda code (global/home region) deploy_stage_config_rule_lambda_code() - # 2) SNS topics for fanout configuration operations + # 2) SNS topics for fanout configuration operations (global/home region) # TODO(liamschn): change the code to have the create events call the sns topic (by publishing events for accounts/regions) which calls the lambda for configuration/deployment deploy_sns_configuration_topics(context) - # 3) Deploy config rules + # 3, 4, and 5 handled by SNS + # create_sns_messages() + # 3) Deploy config rules (regional) deploy_config_rules(event) - # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms + # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional) deploy_metric_filters_and_alarms(event) - # 5) Central CloudWatch Observability + # 5) Central CloudWatch Observability (regional) deploy_central_cloudwatch_observability(event) - # 6) Cloudwatch dashboard in security account + # 6) Cloudwatch dashboard in security account (home region, security account) + # TODO(liamschn): Determine if the dashboard will be created if all the above is done asynchronously deploy_cloudwatch_dashboard(event) # End @@ -1139,18 +1159,65 @@ def delete_event(event, context): cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) -def process_sns_records(records: list) -> None: +def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, action: str, event: dict) -> None: + """Create SNS Message. + + Args: + accounts: Account List + regions: list of AWS regions + sns_topic_arn: SNS Topic ARN + action: Action + """ + sns_messages = [] + if "ResourceProperties" in event: + for region in regions: + sns_message = {"Accounts": accounts, "Region": region, "Action": action, "ResourceProperties": event["ResourceProperties"]} + sns_messages.append( + { + "Id": region, + "Message": json.dumps(sns_message), + "Subject": "SRA Bedrock Configuration", + } + ) + sns.process_sns_message_batches(sns_messages, sns_topic_arn) + else: + LOGGER.info("No ResourceProperties found in event") + + +def process_sns_records(event) -> None: """Process SNS records. Args: records: list of SNS event records """ - for record in records: - sns_info = record["Sns"] - LOGGER.info(f"SNS INFO: {sns_info}") - message = json.loads(sns_info["Message"]) + # for record in records: + # sns_info = record["Sns"] + # LOGGER.info(f"SNS INFO: {sns_info}") + # message = json.loads(sns_info["Message"]) # deploy_config_rule(message["AccountId"], message["ConfigRuleName"], message["Regions"]) - + for record in event["Records"]: + record["Sns"]["Message"] = json.loads(record["Sns"]["Message"]) + LOGGER.info({"SNS Record": record}) + message = record["Sns"]["Message"] + if message["Action"] == "configure": + LOGGER.info("Continuing process to enable SRA security controls for Bedrock (sns event)") + # rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) + + # 3) Deploy config rules (regional) + # deploy_config_rules( + # message["Region"], + # message["Accounts"], + # rule_deploy, + # rule_accounts, + # rule_regions, + # rule_input_params, + # ) + + # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional) + # deploy_metric_filters_and_alarms(event) + + # # 5) Central CloudWatch Observability (regional) + # deploy_central_cloudwatch_observability(event) def deploy_iam_role(account_id: str, rule_name: str) -> str: """Deploy IAM role. @@ -1419,7 +1486,8 @@ def lambda_handler(event, context): f"The event did not include Records or RequestType. Review CloudWatch logs '{context.log_group_name}' for details." ) from None elif "Records" in event and event["Records"][0]["EventSource"] == "aws:sns": - process_sns_records(event["Records"]) + # elif event.get("Records") and event["Records"][0]["EventSource"] == "aws:sns": + process_sns_records(event) elif "RequestType" in event: if event["RequestType"] == "Create": LOGGER.info("CREATE EVENT!!") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index a0353165d..d4c631b8b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -12,6 +12,8 @@ import logging import os +import json + from time import sleep from typing import TYPE_CHECKING @@ -24,8 +26,7 @@ if TYPE_CHECKING: from mypy_boto3_sns.client import SNSClient -import json -import json + from mypy_boto3_sns.type_defs import PublishBatchResponseTypeDef # TODO(liamschn): kms key for sns topic @@ -38,6 +39,9 @@ class sra_sns: BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) UNEXPECTED = "Unexpected!" + SNS_PUBLISH_BATCH_MAX = 10 + + try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() SNS_CLIENT: SNSClient = MANAGEMENT_ACCOUNT_SESSION.client("sns", config=BOTO3_CONFIG) @@ -151,4 +155,35 @@ def set_topic_access_for_alarms(self, topic_arn: str, source_account: str) -> No self.LOGGER.info(f"SNS Topic Policy set for {topic_arn} to allow access for CloudWatch alarms in the {source_account} account") return None except ClientError as e: - raise ValueError(f"Error setting SNS topic policy: {e}") from None \ No newline at end of file + raise ValueError(f"Error setting SNS topic policy: {e}") from None + + def publish_sns_message_batch(self, message_batch: list, sns_topic_arn: str) -> None: + """Publish SNS Message Batches. + + Args: + message_batch: Batch of SNS messages + sns_topic_arn: SNS Topic ARN + """ + self.LOGGER.info("Publishing SNS Message Batch") + self.LOGGER.info({"SNSMessageBatch": message_batch}) + response: PublishBatchResponseTypeDef = self.SNS_CLIENT.publish_batch(TopicArn=sns_topic_arn, PublishBatchRequestEntries=message_batch) + api_call_details = {"API_Call": "sns:PublishBatch", "API_Response": response} + self.LOGGER.info(api_call_details) + + def process_sns_message_batches(self, sns_messages: list, sns_topic_arn: str) -> None: + """Process SNS Message Batches for Publishing. + + Args: + sns_messages: SNS messages to be batched. + sns_topic_arn: SNS Topic ARN + """ + message_batches = [] + for i in range( + self.SNS_PUBLISH_BATCH_MAX, + len(sns_messages) + self.SNS_PUBLISH_BATCH_MAX, + self.SNS_PUBLISH_BATCH_MAX, + ): + message_batches.append(sns_messages[i - self.SNS_PUBLISH_BATCH_MAX : i]) + + for batch in message_batches: + self.publish_sns_message_batch(batch, sns_topic_arn) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index f83a798a1..5357c7821 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -85,7 +85,7 @@ Parameters: pBedrockModelEvalBucketRuleParams: Type: String # TODO(liamschn): update default value of pBedrockModelEvalBucketRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"BucketName": "test-mod-eval-bucket"}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"BucketName": "model-invocation-log-bucket-1-225989363553"}}' Description: Bedrock Model Evaluation Job Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ ConstraintDescription: @@ -97,7 +97,7 @@ Parameters: pBedrockIAMUserAccessRuleParams: Type: String # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock IAM User Access Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ ConstraintDescription: @@ -109,7 +109,7 @@ Parameters: pBedrockGuardrailsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockGuardrailsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' Description: Bedrock Guardrails Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -124,7 +124,7 @@ Parameters: pBedrockVPCEndpointsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockVPCEndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' Description: Bedrock VPC Endpoints Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -139,7 +139,7 @@ Parameters: pBedrockInvocationLogCWRuleParams: Type: String # TODO(liamschn): update default value of pBedrockInvocationLogCWRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' Description: Bedrock Model Invocation Logging to CloudWatch Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$ ConstraintDescription: > @@ -154,7 +154,7 @@ Parameters: pBedrockInvocationLogS3RuleParams: Type: String # TODO(liamschn): update default value of pBedrockInvocationLogS3RuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' Description: Bedrock Model Invocation Logging to S3 Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -169,7 +169,7 @@ Parameters: pBedrockCWEndpointsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockCWEndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock CloudWatch VPC Endpoint Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -181,7 +181,7 @@ Parameters: pBedrockS3EndpointsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockS3EndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock S3 VPC Endpoint Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -193,7 +193,7 @@ Parameters: pBedrockGuardrailEncryptionRuleParams: Type: String # TODO(liamschn): update default value of pBedrockGuardrailEncryptionRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-east-1", "us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock Guardrail KMS Encryption Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -205,7 +205,7 @@ Parameters: pBedrockServiceChangesFilterParams: # TODO(liamschn): update default value of pBedrockServiceChangesFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["891377138368"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' + Default: '{"deploy": "true", "accounts": ["897722683088"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' Description: Bedrock Service Changes Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > @@ -215,7 +215,7 @@ Parameters: pBedrockBucketChangesFilterParams: # TODO(liamschn): update default value of pBedrockBucketChangesFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["891377138368"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}}' + Default: '{"deploy": "true", "accounts": ["897722683088"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["model-invocation-log-bucket-1-225989363553"]}}' Description: Bedrock S3 Bucket Changes Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$ ConstraintDescription: > @@ -226,25 +226,43 @@ Parameters: pBedrockInvocationLogFilterParams: # TODO(liamschn): update default value of pBedrockInvocationLogFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["863518454635"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test", "input_path": "input.inputBodyJson.messages[0].content"}}' + Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' Description: Bedrock Prompt Injection and Sensitive Info Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with - 'log_group_name' (non-empty string). Examples - for claude: {"deploy": "true", "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test", "input_path": "input.inputBodyJson.messages[0].content"}} - or for titan: {"deploy": "true", "filter_params": {"log_group_name": "sra-bedrock-invocation-logs-test", "input_path": "input.inputBodyJson.inputText"}} + 'log_group_name' (non-empty string). Examples - for claude: {"deploy": "true", "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}} + or for titan: {"deploy": "true", "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.inputText"}} NOTE: input_path is based on the base model used such as clause or titan; check the invocation log InvokeModel messages for details pBedrockCentralObservabilityParams: # TODO(liamschn): update default value of pBedrockCentralObservabilityParams prior to production Type: String - Default: '{"deploy": "true", "bedrock_accounts": ["863518454635"], "regions": ["us-west-2"]}' + Default: '{"deploy": "true", "bedrock_accounts": ["225989363553"], "regions": ["us-west-2"]}' Description: Bedrock Central Observability Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$ ConstraintDescription: > Must be a valid JSON string containing: 'deploy' (true/false), 'bedrock_accounts' (array of account numbers), and 'regions' (array of region names). Example: {"deploy": "true", "bedrock_accounts": ["123456789012"], "regions": ["us-east-1", "us-west-2"]} + pBedrockAccounts: + Type: String + # TODO(liamschn): update default value of pBedrockAccounts prior to production + Default: '["225989363553"]' + Description: Bedrock Accounts + AllowedPattern: ^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$ + ConstraintDescription: > + Must be a valid JSON string containing an array of account numbers. Example: ["123456789012", "987654321098"] + + pBedrockRegions: + Type: String + # TODO(liamschn): update default value of pBedrockRegions prior to production + Default: '["us-west-2"]' + Description: Bedrock Regions + AllowedPattern: ^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$ + ConstraintDescription: > + Must be a valid JSON string containing an array of region names. Example: ["us-east-1", "us-west-2"] + Metadata: AWS::CloudFormation::Interface: ParameterGroups: @@ -268,6 +286,11 @@ Metadata: - pDeployLambdaLogGroup - pLogGroupRetention - pLambdaLogLevel + - Label: + default: Bedrock Configuration + Parameters: + - pBedrockAccounts + - pBedrockRegions - Label: default: Bedrock AWS Config Rules Parameters: @@ -340,6 +363,10 @@ Metadata: default: Bedrock Prompt Injection and Sensitive Info Filter Parameters pBedrockCentralObservabilityParams: default: Bedrock Central Observability Parameters + pBedrockAccounts: + default: Bedrock Accounts + pBedrockRegions: + default: Bedrock Regions Resources: rBedrockOrgLambdaRole: @@ -392,6 +419,8 @@ Resources: SOLUTION_NAME: !Ref pSRASolutionName SOLUTION_VERSION: !Ref pSRASolutionVersion SRA_ALARM_EMAIL: !Ref pSRAAlarmEmail + SRA-BEDROCK-ACCOUNTS: !Ref pBedrockAccounts + SRA-BEDROCK-REGIONS: !Ref pBedrockRegions SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET: !Ref pBedrockModelEvalBucketRuleParams SRA-BEDROCK-CHECK-IAM-USER-ACCESS: !Ref pBedrockIAMUserAccessRuleParams SRA-BEDROCK-CHECK-GUARDRAILS: !Ref pBedrockGuardrailsRuleParams From f87dda0e87add13b7a3306ccb595dc1ae06ca4fa Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 4 Nov 2024 15:16:45 -0700 Subject: [PATCH 149/395] working on sns fanout (for config 1st) --- .../genai/bedrock_org/lambda/src/app.py | 160 ++++++++++-------- .../genai/bedrock_org/lambda/src/sra_sns.py | 3 +- 2 files changed, 96 insertions(+), 67 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index d4ce42526..0ec46d0ce 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -202,40 +202,41 @@ def get_resource_parameters(event): CFN_RESPONSE_DATA["dry_run"] = DRY_RUN -def get_rule_params(rule_name, event): +def get_rule_params(rule_name, resource_properties): """Get rule parameters from event and return them in a tuple Args: rule_name (str): name of config rule - event (dict): lambda event + resource_properties (dict): lambda event resource properties Returns: tuple: (rule_deploy, rule_accounts, rule_regions, rule_params) rule_deploy (bool): whether to deploy the rule - rule_accounts (list): list of accounts to deploy the rule to - rule_regions (list): list of regions to deploy the rule to rule_input_params (dict): dictionary of rule input parameters """ + # rule_accounts (list): list of accounts to deploy the rule to + # rule_regions (list): list of regions to deploy the rule to + # TODO(liamschn): SRA-BEDROCK-ACCOUNTS and SRA-BEDROCK-REGIONS to be moved to a more global area so it is not defined more than once - if "SRA-BEDROCK-ACCOUNTS" in event["ResourceProperties"]: - LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") - rule_accounts = json.loads(event["ResourceProperties"]["SRA-BEDROCK-ACCOUNTS"]) - LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {rule_accounts}") - else: - LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None and deploy to False") - rule_accounts = [] - rule_deploy = False - if "SRA-BEDROCK-REGIONS" in event["ResourceProperties"]: - LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") - rule_regions = json.loads(event["ResourceProperties"]["SRA-BEDROCK-REGIONS"]) - LOGGER.info(f"SRA-BEDROCK-REGIONS: {rule_regions}") - else: - LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None and deploy to False") - rule_regions = [] - rule_deploy = False - if rule_name.upper() in event["ResourceProperties"]: + # if "SRA-BEDROCK-ACCOUNTS" in resource_properties: + # LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") + # rule_accounts = json.loads(resource_properties["SRA-BEDROCK-ACCOUNTS"]) + # LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {rule_accounts}") + # else: + # LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None and deploy to False") + # rule_accounts = [] + # rule_deploy = False + # if "SRA-BEDROCK-REGIONS" in resource_properties: + # LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") + # rule_regions = json.loads(resource_properties["SRA-BEDROCK-REGIONS"]) + # LOGGER.info(f"SRA-BEDROCK-REGIONS: {rule_regions}") + # else: + # LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None and deploy to False") + # rule_regions = [] + # rule_deploy = False + if rule_name.upper() in resource_properties: LOGGER.info(f"{rule_name} parameter found in event ResourceProperties") - rule_params = json.loads(event["ResourceProperties"][rule_name.upper()]) + rule_params = json.loads(resource_properties[rule_name.upper()]) LOGGER.info(f"{rule_name.upper()} parameters: {rule_params}") if "deploy" in rule_params: LOGGER.info(f"{rule_name.upper()} 'deploy' parameter found in event ResourceProperties") @@ -271,10 +272,10 @@ def get_rule_params(rule_name, event): else: LOGGER.info(f"{rule_name.upper()} 'input_params' parameter not found in event ResourceProperties; setting to None") rule_input_params = {} - return rule_deploy, rule_accounts, rule_regions, rule_input_params + return rule_deploy, rule_input_params else: LOGGER.info(f"{rule_name.upper()} config rule parameter not found in event ResourceProperties; skipping...") - return False, [], [], {} + return False, {} def get_filter_params(filter_name, event): @@ -432,31 +433,41 @@ def deploy_sns_configuration_topics(context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search + return topic_arn -def deploy_config_rules(event): +def deploy_config_rules(region, accounts, resource_properties): global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA + for prop in resource_properties: + if prop.startswith("SRA-BEDROCK-CHECK-"): + rule_name: str = prop + LOGGER.info(f"Create operation: retrieving {rule_name} parameters...") + rule_deploy, rule_input_params = get_rule_params(rule_name, resource_properties) + rule_name = rule_name.lower() + LOGGER.info(f"Create operation: examining {rule_name} resources...") - for rule in repo.CONFIG_RULES[SOLUTION_NAME]: - rule_name = rule.replace("_", "-") + for acct in accounts: + + # for rule in repo.CONFIG_RULES[SOLUTION_NAME]: + # rule_name = rule.replace("_", "-") # Get bedrock solution rule accounts and regions - rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) - if rule_deploy is False: - continue + # rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) + if rule_deploy is False: + continue - for acct in rule_accounts: - if DRY_RUN is False: - # 3a) Deploy IAM role for custom config rule lambda - LOGGER.info(f"Deploying IAM role for custom config rule lambda in {acct}") - role_arn = deploy_iam_role(acct, rule_name) - LIVE_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "Deployed IAM role for custom config rule lambda" - else: - LOGGER.info(f"DRY_RUN: Deploying IAM role for custom config rule lambda in {acct}") - DRY_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "DRY_RUN: Deploy IAM role for custom config rule lambda" + # for acct in rule_accounts: + if DRY_RUN is False: + # 3a) Deploy IAM role for custom config rule lambda + LOGGER.info(f"Deploying IAM role for custom config rule lambda in {acct}") + role_arn = deploy_iam_role(acct, rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "Deployed IAM role for custom config rule lambda" + else: + LOGGER.info(f"DRY_RUN: Deploying IAM role for custom config rule lambda in {acct}") + DRY_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "DRY_RUN: Deploy IAM role for custom config rule lambda" - for acct in rule_accounts: - for region in rule_regions: + # for acct in rule_accounts: + # for region in rule_regions: # 3b) Deploy lambda for custom config rule if DRY_RUN is False: lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) @@ -804,12 +815,28 @@ def create_event(event, context): # 2) SNS topics for fanout configuration operations (global/home region) # TODO(liamschn): change the code to have the create events call the sns topic (by publishing events for accounts/regions) which calls the lambda for configuration/deployment - deploy_sns_configuration_topics(context) + topic_arn = deploy_sns_configuration_topics(context) # 3, 4, and 5 handled by SNS - # create_sns_messages() + # TODO(liamschn): Move get regions and accounts into its own function + if "SRA-BEDROCK-ACCOUNTS" in event["ResourceProperties"]: + LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") + accounts = json.loads(event["ResourceProperties"]["SRA-BEDROCK-ACCOUNTS"]) + LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {accounts}") + else: + LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None") + accounts = [] + if "SRA-BEDROCK-REGIONS" in event["ResourceProperties"]: + LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") + regions = json.loads(event["ResourceProperties"]["SRA-BEDROCK-REGIONS"]) + LOGGER.info(f"SRA-BEDROCK-REGIONS: {regions}") + else: + LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None") + regions = [] + # 3) Deploy config rules (regional) - deploy_config_rules(event) + # deploy_config_rules(event) + create_sns_messages(accounts, regions, topic_arn, event["ResourceProperties"], "configure") # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional) deploy_metric_filters_and_alarms(event) @@ -1159,7 +1186,7 @@ def delete_event(event, context): cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) -def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, action: str, event: dict) -> None: +def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, resource_properties: dict, action: str, ) -> None: """Create SNS Message. Args: @@ -1168,20 +1195,20 @@ def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, actio sns_topic_arn: SNS Topic ARN action: Action """ + LOGGER.info("Creating SNS Messages...") sns_messages = [] - if "ResourceProperties" in event: - for region in regions: - sns_message = {"Accounts": accounts, "Region": region, "Action": action, "ResourceProperties": event["ResourceProperties"]} - sns_messages.append( - { - "Id": region, - "Message": json.dumps(sns_message), - "Subject": "SRA Bedrock Configuration", - } - ) - sns.process_sns_message_batches(sns_messages, sns_topic_arn) - else: - LOGGER.info("No ResourceProperties found in event") + LOGGER.info("ResourceProperties found in event") + + for region in regions: + sns_message = {"Accounts": accounts, "Region": region, "ResourceProperties": resource_properties, "Action": action} + sns_messages.append( + { + "Id": region, + "Message": json.dumps(sns_message), + "Subject": "SRA Bedrock Configuration", + } + ) + sns.process_sns_message_batches(sns_messages, sns_topic_arn) def process_sns_records(event) -> None: @@ -1190,6 +1217,7 @@ def process_sns_records(event) -> None: Args: records: list of SNS event records """ + LOGGER.info("Processing SNS records...") # for record in records: # sns_info = record["Sns"] # LOGGER.info(f"SNS INFO: {sns_info}") @@ -1204,20 +1232,20 @@ def process_sns_records(event) -> None: # rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) # 3) Deploy config rules (regional) - # deploy_config_rules( - # message["Region"], - # message["Accounts"], - # rule_deploy, - # rule_accounts, - # rule_regions, - # rule_input_params, - # ) + + deploy_config_rules( + message["Region"], + message["Accounts"], + message["ResourceProperties"], + ) # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional) # deploy_metric_filters_and_alarms(event) # # 5) Central CloudWatch Observability (regional) # deploy_central_cloudwatch_observability(event) + else: + LOGGER.info(f"Action specified is {message['Action']}") def deploy_iam_role(account_id: str, rule_name: str) -> str: """Deploy IAM role. diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index d4c631b8b..18ac24b27 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -164,7 +164,7 @@ def publish_sns_message_batch(self, message_batch: list, sns_topic_arn: str) -> message_batch: Batch of SNS messages sns_topic_arn: SNS Topic ARN """ - self.LOGGER.info("Publishing SNS Message Batch") + self.LOGGER.info("Publishing SNS Message Batch...") self.LOGGER.info({"SNSMessageBatch": message_batch}) response: PublishBatchResponseTypeDef = self.SNS_CLIENT.publish_batch(TopicArn=sns_topic_arn, PublishBatchRequestEntries=message_batch) api_call_details = {"API_Call": "sns:PublishBatch", "API_Response": response} @@ -177,6 +177,7 @@ def process_sns_message_batches(self, sns_messages: list, sns_topic_arn: str) -> sns_messages: SNS messages to be batched. sns_topic_arn: SNS Topic ARN """ + self.LOGGER.info("Processing SNS Message Batches...") message_batches = [] for i in range( self.SNS_PUBLISH_BATCH_MAX, From 4a410bd03666b65dded629fe87af74bc73478b6b Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 4 Nov 2024 20:32:29 -0700 Subject: [PATCH 150/395] handle getting params for sns --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0ec46d0ce..5662952b1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1508,15 +1508,15 @@ def lambda_handler(event, context): LOGGER.info(f"ResourceType: {RESOURCE_TYPE}") else: LOGGER.info("ResourceType not found in event.") - get_resource_parameters(event) if "Records" not in event and "RequestType" not in event: raise ValueError( f"The event did not include Records or RequestType. Review CloudWatch logs '{context.log_group_name}' for details." ) from None elif "Records" in event and event["Records"][0]["EventSource"] == "aws:sns": - # elif event.get("Records") and event["Records"][0]["EventSource"] == "aws:sns": + get_resource_parameters(json.loads(event["Records"][0]["Sns"]["Message"])) process_sns_records(event) elif "RequestType" in event: + get_resource_parameters(event) if event["RequestType"] == "Create": LOGGER.info("CREATE EVENT!!") create_event(event, context) From 2a660c72b0b3cbd3ea14c3d96e5bc0d2c3cb8af1 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 4 Nov 2024 20:48:00 -0700 Subject: [PATCH 151/395] updating get accts and regions; updating delete operation --- .../genai/bedrock_org/lambda/src/app.py | 66 ++++++++++++++----- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 5662952b1..5f293891d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -201,6 +201,34 @@ def get_resource_parameters(event): DRY_RUN = False CFN_RESPONSE_DATA["dry_run"] = DRY_RUN +def get_accounts_and_regions(resource_properties): + """Get accounts and regions from event and return them in a tuple + + Args: + resource_properties (dict): lambda event resource properties + + Returns: + tuple: (accounts, rule_regions) + accounts (list): list of accounts to deploy the rule to + regions (list): list of regions to deploy the rule to + """ + accounts = [] + regions = [] + if "SRA-BEDROCK-ACCOUNTS" in resource_properties: + LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") + accounts = json.loads(resource_properties["SRA-BEDROCK-ACCOUNTS"]) + LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {accounts}") + else: + LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None and deploy to False") + accounts = [] + if "SRA-BEDROCK-REGIONS" in resource_properties: + LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") + regions = json.loads(resource_properties["SRA-BEDROCK-REGIONS"]) + LOGGER.info(f"SRA-BEDROCK-REGIONS: {regions}") + else: + LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None and deploy to False") + regions = [] + return accounts, regions def get_rule_params(rule_name, resource_properties): """Get rule parameters from event and return them in a tuple @@ -818,21 +846,22 @@ def create_event(event, context): topic_arn = deploy_sns_configuration_topics(context) # 3, 4, and 5 handled by SNS - # TODO(liamschn): Move get regions and accounts into its own function - if "SRA-BEDROCK-ACCOUNTS" in event["ResourceProperties"]: - LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") - accounts = json.loads(event["ResourceProperties"]["SRA-BEDROCK-ACCOUNTS"]) - LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {accounts}") - else: - LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None") - accounts = [] - if "SRA-BEDROCK-REGIONS" in event["ResourceProperties"]: - LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") - regions = json.loads(event["ResourceProperties"]["SRA-BEDROCK-REGIONS"]) - LOGGER.info(f"SRA-BEDROCK-REGIONS: {regions}") - else: - LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None") - regions = [] + accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) + # TODO(liamschn): Move get regions and accounts into its own function (confirm working) + # if "SRA-BEDROCK-ACCOUNTS" in event["ResourceProperties"]: + # LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") + # accounts = json.loads(event["ResourceProperties"]["SRA-BEDROCK-ACCOUNTS"]) + # LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {accounts}") + # else: + # LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None") + # accounts = [] + # if "SRA-BEDROCK-REGIONS" in event["ResourceProperties"]: + # LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") + # regions = json.loads(event["ResourceProperties"]["SRA-BEDROCK-REGIONS"]) + # LOGGER.info(f"SRA-BEDROCK-REGIONS: {regions}") + # else: + # LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None") + # regions = [] # 3) Deploy config rules (regional) # deploy_config_rules(event) @@ -1068,16 +1097,17 @@ def delete_event(event, context): # 4) Delete config rules # TODO(liamschn): deal with invalid rule names # TODO(liamschn): deal with invalid account IDs + accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) for prop in event["ResourceProperties"]: if prop.startswith("SRA-BEDROCK-CHECK-"): rule_name: str = prop LOGGER.info(f"Delete operation: retrieving {rule_name} parameters...") - rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) + # rule_deploy, rule_input_params = get_rule_params(rule_name, event["ResourceProperties"]) rule_name = rule_name.lower() LOGGER.info(f"Delete operation: examining {rule_name} resources...") - for acct in rule_accounts: - for region in rule_regions: + for acct in accounts: + for region in regions: # 4a) Delete the config rule config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) From e4e86ed835dff6824077d1dc17352e70971241d4 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 4 Nov 2024 22:51:55 -0700 Subject: [PATCH 152/395] working to download rule zip locally --- .../genai/bedrock_org/lambda/src/sra_s3.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 355623ea5..6eb58bb70 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -122,3 +122,32 @@ def stage_code_to_s3(self, directory_path, bucket_name, s3_path): self.LOGGER.info("Credentials not available") return self.LOGGER.info(f"Uploaded {local_path} to {bucket_name} {s3_file_path}") + + def download_s3_file(self, rule_name, bucket_name): + """ + Downloads the rule code from the staging S3 bucket. + + :param rule_name: Name of the rule + :param bucket_name: Name of the S3 bucket + """ + self.LOGGER.info(f"Downloading {rule_name} rule code from s3...") + s3_key_template = 'rules/{rule_name}/{rule_name}.zip' + local_base_path = '/tmp/sra_staging_upload' + + s3_key = s3_key_template.format(rule_name=rule_name) + local_file_path = os.path.join(local_base_path, 'rules', rule_name, f'{rule_name}.zip') + + # Ensure local directories exist + os.makedirs(os.path.dirname(local_file_path), exist_ok=True) + + try: + # Download the file from S3 + self.S3_CLIENT.download_file(bucket_name, s3_key, local_file_path) + except NoCredentialsError: + self.LOGGER.info("Credentials not available") + return + # Handle other exceptions as needed + except Exception as e: + self.LOGGER.info(f"Error downloading file: {e}") + + self.LOGGER.info(f"File downloaded successfully to {local_file_path}") From 7f00ce6f587413f90385ff2342141c4384684d4a Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 5 Nov 2024 07:28:18 -0700 Subject: [PATCH 153/395] more updates for rule zip --- .../solutions/genai/bedrock_org/lambda/src/app.py | 9 +++++++++ .../solutions/genai/bedrock_org/lambda/src/sra_s3.py | 12 ++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 5f293891d..56806e353 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -498,6 +498,15 @@ def deploy_config_rules(region, accounts, resource_properties): # for region in rule_regions: # 3b) Deploy lambda for custom config rule if DRY_RUN is False: + # download rule zip file + s3_key = f"rules/{rule_name}/{rule_name}.zip" + local_base_path = '/tmp/sra_staging_upload' + local_file_path = os.path.join(local_base_path, 'rules', rule_name, f'{rule_name}.zip') + s3.download_s3_file(local_file_path, s3_key, s3.STAGING_BUCKET) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_LambdaCode"] = "Downloaded custom config rule lambda code" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + + LOGGER.info(f"Deploying lambda for custom config rule in {acct} in {region}") lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "Deployed custom config lambda function" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 6eb58bb70..88aaa922d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -123,19 +123,15 @@ def stage_code_to_s3(self, directory_path, bucket_name, s3_path): return self.LOGGER.info(f"Uploaded {local_path} to {bucket_name} {s3_file_path}") - def download_s3_file(self, rule_name, bucket_name): + def download_s3_file(self, local_file_path, s3_key, bucket_name): """ Downloads the rule code from the staging S3 bucket. - :param rule_name: Name of the rule + :param local_file_path: Local path to save the downloaded file + :param s3_key: Name of the S3 bucket key :param bucket_name: Name of the S3 bucket """ - self.LOGGER.info(f"Downloading {rule_name} rule code from s3...") - s3_key_template = 'rules/{rule_name}/{rule_name}.zip' - local_base_path = '/tmp/sra_staging_upload' - - s3_key = s3_key_template.format(rule_name=rule_name) - local_file_path = os.path.join(local_base_path, 'rules', rule_name, f'{rule_name}.zip') + self.LOGGER.info(f"Downloading file from s3...") # Ensure local directories exist os.makedirs(os.path.dirname(local_file_path), exist_ok=True) From 4f27d3cfb5ead15ef3106a05fa06099b7567f2c4 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 5 Nov 2024 07:58:50 -0700 Subject: [PATCH 154/395] updates for s3 download --- .../genai/bedrock_org/lambda/src/sra_s3.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 88aaa922d..aaabba3e8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -139,11 +139,13 @@ def download_s3_file(self, local_file_path, s3_key, bucket_name): try: # Download the file from S3 self.S3_CLIENT.download_file(bucket_name, s3_key, local_file_path) - except NoCredentialsError: - self.LOGGER.info("Credentials not available") - return - # Handle other exceptions as needed - except Exception as e: + except ClientError as e: self.LOGGER.info(f"Error downloading file: {e}") - - self.LOGGER.info(f"File downloaded successfully to {local_file_path}") + + # Check if the file was downloaded successfully + if os.path.exists(local_file_path): + self.LOGGER.info(f"File downloaded successfully to {local_file_path}") + # list the directory contents + self.LOGGER.info(f"Listing directory contents: {os.listdir(os.path.dirname(local_file_path))}") + else: + self.LOGGER.info(f"File not found: {local_file_path}") \ No newline at end of file From ef2a0adb55c1ac2ef17b0a62a9f9a66d99625444 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 5 Nov 2024 08:23:12 -0700 Subject: [PATCH 155/395] add tracing for s3 downloads --- .../solutions/genai/bedrock_org/lambda/src/sra_s3.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index aaabba3e8..c3cdd21f7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -134,10 +134,12 @@ def download_s3_file(self, local_file_path, s3_key, bucket_name): self.LOGGER.info(f"Downloading file from s3...") # Ensure local directories exist + self.LOGGER.info(f"Creating local directories ({os.path.dirname(local_file_path)}) if they don't exist...") os.makedirs(os.path.dirname(local_file_path), exist_ok=True) try: # Download the file from S3 + self.LOGGER.info(f"Downloading file from {bucket_name} {s3_key} to {local_file_path}") self.S3_CLIENT.download_file(bucket_name, s3_key, local_file_path) except ClientError as e: self.LOGGER.info(f"Error downloading file: {e}") From 7e546b4b6933bdd031407f4fce6330e524632266 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 5 Nov 2024 09:31:52 -0700 Subject: [PATCH 156/395] updating s3 key --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 56806e353..6ef94dd0d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -499,13 +499,13 @@ def deploy_config_rules(region, accounts, resource_properties): # 3b) Deploy lambda for custom config rule if DRY_RUN is False: # download rule zip file - s3_key = f"rules/{rule_name}/{rule_name}.zip" + s3_key = f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" local_base_path = '/tmp/sra_staging_upload' local_file_path = os.path.join(local_base_path, 'rules', rule_name, f'{rule_name}.zip') s3.download_s3_file(local_file_path, s3_key, s3.STAGING_BUCKET) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_LambdaCode"] = "Downloaded custom config rule lambda code" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - + LOGGER.info(f"Deploying lambda for custom config rule in {acct} in {region}") lambda_arn = deploy_lambda_function(acct, rule_name, role_arn, region) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Lambda"] = "Deployed custom config lambda function" From c4e6192829bbbda61fe747ab94872968c931820d Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 5 Nov 2024 10:31:16 -0700 Subject: [PATCH 157/395] updating local path --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 6ef94dd0d..8ac99d5f4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -501,7 +501,7 @@ def deploy_config_rules(region, accounts, resource_properties): # download rule zip file s3_key = f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" local_base_path = '/tmp/sra_staging_upload' - local_file_path = os.path.join(local_base_path, 'rules', rule_name, f'{rule_name}.zip') + local_file_path = os.path.join(local_base_path, f'{SOLUTION_NAME}', 'rules', rule_name, f'{rule_name}.zip') s3.download_s3_file(local_file_path, s3_key, s3.STAGING_BUCKET) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_LambdaCode"] = "Downloaded custom config rule lambda code" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 From 1643e638a8434097aae259f856d87d6abe0cb105 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 5 Nov 2024 15:58:28 -0700 Subject: [PATCH 158/395] moving metrics/alarms to sns fanout --- .../genai/bedrock_org/lambda/src/app.py | 274 +++++++++--------- 1 file changed, 138 insertions(+), 136 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 8ac99d5f4..dce026424 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -306,23 +306,21 @@ def get_rule_params(rule_name, resource_properties): return False, {} -def get_filter_params(filter_name, event): - """Get filter parameters from event and return them in a tuple +def get_filter_params(filter_name, resource_properties): + """Get filter parameters from event resource_properties and return them in a tuple Args: filter_name (str): name of cloudwatch filter - event (dict): lambda event + resource_properties (dict): lambda event ResourceProperties Returns: - tuple: (filter_deploy, filter_accounts, filter_regions, filter_pattern) + tuple: (filter_deploy, filter_pattern) filter_deploy (bool): whether to deploy the filter - filter_accounts (list): list of accounts to deploy the filter to - filter_regions (list): list of regions to deploy the filter to filter_params (dict): dictionary of filter parameters """ - if filter_name.upper() in event["ResourceProperties"]: + if filter_name.upper() in resource_properties: LOGGER.info(f"{filter_name} parameter found in event ResourceProperties") - metric_filter_params = json.loads(event["ResourceProperties"][filter_name.upper()]) + metric_filter_params = json.loads(resource_properties[filter_name.upper()]) LOGGER.info(f"{filter_name.upper()} metric filter parameters: {metric_filter_params}") if "deploy" in metric_filter_params: LOGGER.info(f"{filter_name.upper()} 'deploy' parameter found in event ResourceProperties") @@ -335,20 +333,20 @@ def get_filter_params(filter_name, event): else: LOGGER.info(f"{filter_name.upper()} 'deploy' parameter not found in event ResourceProperties; setting to False") filter_deploy = False - if "accounts" in metric_filter_params: - LOGGER.info(f"{filter_name.upper()} 'accounts' parameter found in event ResourceProperties") - filter_accounts = metric_filter_params["accounts"] - LOGGER.info(f"{filter_name.upper()} accounts: {filter_accounts}") - else: - LOGGER.info(f"{filter_name.upper()} 'accounts' parameter not found in event ResourceProperties") - filter_accounts = [] - if "regions" in metric_filter_params: - LOGGER.info(f"{filter_name.upper()} 'regions' parameter found in event ResourceProperties") - filter_regions = metric_filter_params["regions"] - LOGGER.info(f"{filter_name.upper()} regions: {filter_regions}") - else: - LOGGER.info(f"{filter_name.upper()} 'regions' parameter not found in event ResourceProperties") - filter_regions = [] + # if "accounts" in metric_filter_params: + # LOGGER.info(f"{filter_name.upper()} 'accounts' parameter found in event ResourceProperties") + # filter_accounts = metric_filter_params["accounts"] + # LOGGER.info(f"{filter_name.upper()} accounts: {filter_accounts}") + # else: + # LOGGER.info(f"{filter_name.upper()} 'accounts' parameter not found in event ResourceProperties") + # filter_accounts = [] + # if "regions" in metric_filter_params: + # LOGGER.info(f"{filter_name.upper()} 'regions' parameter found in event ResourceProperties") + # filter_regions = metric_filter_params["regions"] + # LOGGER.info(f"{filter_name.upper()} regions: {filter_regions}") + # else: + # LOGGER.info(f"{filter_name.upper()} 'regions' parameter not found in event ResourceProperties") + # filter_regions = [] if "filter_params" in metric_filter_params: LOGGER.info(f"{filter_name.upper()} 'filter_params' parameter found in event ResourceProperties") filter_params = metric_filter_params["filter_params"] @@ -358,8 +356,8 @@ def get_filter_params(filter_name, event): filter_params = {} else: LOGGER.info(f"{filter_name.upper()} filter parameter not found in event ResourceProperties; skipping...") - return False, [], [], {} - return filter_deploy, filter_accounts, filter_regions, filter_params + return False, {} + return filter_deploy, filter_params def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: str) -> str: @@ -525,14 +523,14 @@ def deploy_config_rules(region, accounts, resource_properties): LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" -def deploy_metric_filters_and_alarms(event): +def deploy_metric_filters_and_alarms(region, accounts, resource_properties): global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA LOGGER.info(f"CloudWatch Metric Filters: {CLOUDWATCH_METRIC_FILTERS}") for filter in CLOUDWATCH_METRIC_FILTERS: - filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) + filter_deploy, filter_params = get_filter_params(filter, resource_properties) LOGGER.info(f"{filter} parameters: {filter_params}") if filter_deploy is False: continue @@ -546,125 +544,125 @@ def deploy_metric_filters_and_alarms(event): filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter] LOGGER.info(f"{filter} filter pattern: {filter_pattern}") - for acct in filter_accounts: - for region in filter_regions: - # 4a) Deploy KMS keys - # 4ai) KMS key for SNS topic used by CloudWatch alarms - kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) - search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") - if search_alarm_kms_key is False: - LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") - # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section - if DRY_RUN is False: - LOGGER.info("Creating SRA alarm KMS key") - LOGGER.info("Customizing key policy...") - kms_key_policy = json.loads(json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS])) - LOGGER.info(f"kms_key_policy: {kms_key_policy}") - kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"][ - "AWS" - ].replace("ACCOUNT_ID", acct) - LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") - alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption") - LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") - LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + for acct in accounts: + # for region in regions: + # 4a) Deploy KMS keys + # 4ai) KMS key for SNS topic used by CloudWatch alarms + kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) + search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + if search_alarm_kms_key is False: + LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") + # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section + if DRY_RUN is False: + LOGGER.info("Creating SRA alarm KMS key") + LOGGER.info("Customizing key policy...") + kms_key_policy = json.loads(json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS])) + LOGGER.info(f"kms_key_policy: {kms_key_policy}") + kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"][ + "AWS" + ].replace("ACCOUNT_ID", acct) + LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") + alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption") + LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") + LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - # 4aii KMS alias for SNS topic used by CloudWatch alarms - LOGGER.info("Creating SRA alarm KMS key alias") - kms.create_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}", alarm_key_id) - LIVE_RUN_DATA["KMSAliasCreate"] = "Created SRA alarm KMS key alias" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # 4aii KMS alias for SNS topic used by CloudWatch alarms + LOGGER.info("Creating SRA alarm KMS key alias") + kms.create_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}", alarm_key_id) + LIVE_RUN_DATA["KMSAliasCreate"] = "Created SRA alarm KMS key alias" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - else: - LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") - DRY_RUN_DATA["KMSKeyCreate"] = "DRY_RUN: Create SRA alarm KMS key" - LOGGER.info("DRY_RUN: Creating SRA alarm KMS key alias") - DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" else: - LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") + LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") + DRY_RUN_DATA["KMSKeyCreate"] = "DRY_RUN: Create SRA alarm KMS key" + LOGGER.info("DRY_RUN: Creating SRA alarm KMS key alias") + DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" + else: + LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") - # 4b) SNS topics for alarms - sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) - topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) - if topic_search is None: - if DRY_RUN is False: - LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") - SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME, kms_key=alarm_key_id) - LIVE_RUN_DATA["SNSAlarmTopic"] = f"Created {SOLUTION_NAME}-alarms SNS topic (ARN: {SRA_ALARM_TOPIC_ARN})" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # 4b) SNS topics for alarms + sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) + topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) + if topic_search is None: + if DRY_RUN is False: + LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") + SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME, kms_key=alarm_key_id) + LIVE_RUN_DATA["SNSAlarmTopic"] = f"Created {SOLUTION_NAME}-alarms SNS topic (ARN: {SRA_ALARM_TOPIC_ARN})" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info(f"Setting access for CloudWatch alarms in {acct} to publish to {SOLUTION_NAME}-alarms SNS topic") - # TODO(liamschn): search for policy on SNS topic before adding the policy - sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, acct) - LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Setting access for CloudWatch alarms in {acct} to publish to {SOLUTION_NAME}-alarms SNS topic") + # TODO(liamschn): search for policy on SNS topic before adding the policy + sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, acct) + LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {SRA_ALARM_TOPIC_ARN}") - sns.create_sns_subscription(SRA_ALARM_TOPIC_ARN, "email", SRA_ALARM_EMAIL) - LIVE_RUN_DATA["SNSAlarmSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {SRA_ALARM_TOPIC_ARN}") + sns.create_sns_subscription(SRA_ALARM_TOPIC_ARN, "email", SRA_ALARM_EMAIL) + LIVE_RUN_DATA["SNSAlarmSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - else: - LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") - DRY_RUN_DATA["SNSAlarmCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" + else: + LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSAlarmCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" - LOGGER.info( - f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" - ) - DRY_RUN_DATA[ - "SNSAlarmPermissions" - ] = f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + LOGGER.info( + f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + ) + DRY_RUN_DATA[ + "SNSAlarmPermissions" + ] = f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" - LOGGER.info(f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic") - DRY_RUN_DATA["SNSAlarmSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" - else: - LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") - SRA_ALARM_TOPIC_ARN = topic_search + LOGGER.info(f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSAlarmSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" + else: + LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") + SRA_ALARM_TOPIC_ARN = topic_search - # 4c) Cloudwatch metric filters and alarms - if DRY_RUN is False: - if filter_deploy is True: - cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) - cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) - LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter} CloudWatch metric filter...") - deploy_metric_filter(filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock", "1") - LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info(f"DEBUG: Alarm topic ARN: {SRA_ALARM_TOPIC_ARN}") - deploy_metric_alarm( - f"{filter}-alarm", - f"{filter}-metric alarm", - f"{filter}-metric", - "sra-bedrock", - "Sum", - 10, - 1, - 0, - "GreaterThanThreshold", - "missing", - [SRA_ALARM_TOPIC_ARN], - ) - LIVE_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - else: - LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") - LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" + # 4c) Cloudwatch metric filters and alarms + if DRY_RUN is False: + if filter_deploy is True: + cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) + LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter} CloudWatch metric filter...") + deploy_metric_filter(filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock", "1") + LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LOGGER.info(f"DEBUG: Alarm topic ARN: {SRA_ALARM_TOPIC_ARN}") + deploy_metric_alarm( + f"{filter}-alarm", + f"{filter}-metric alarm", + f"{filter}-metric", + "sra-bedrock", + "Sum", + 10, + 1, + 0, + "GreaterThanThreshold", + "missing", + [SRA_ALARM_TOPIC_ARN], + ) + LIVE_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 else: - if filter_deploy is True: - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric filter...") - DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'true'; Deploy CloudWatch metric filter" - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric alarm...") - DRY_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" - else: - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") - DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" + LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") + LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" + else: + if filter_deploy is True: + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric filter...") + DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'true'; Deploy CloudWatch metric filter" + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric alarm...") + DRY_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" + else: + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") + DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" def deploy_central_cloudwatch_observability(event): global DRY_RUN_DATA @@ -877,7 +875,7 @@ def create_event(event, context): create_sns_messages(accounts, regions, topic_arn, event["ResourceProperties"], "configure") # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional) - deploy_metric_filters_and_alarms(event) + # deploy_metric_filters_and_alarms(event) # 5) Central CloudWatch Observability (regional) deploy_central_cloudwatch_observability(event) @@ -1279,7 +1277,11 @@ def process_sns_records(event) -> None: ) # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional) - # deploy_metric_filters_and_alarms(event) + deploy_metric_filters_and_alarms( + message["Region"], + message["Accounts"], + message["ResourceProperties"], + ) # # 5) Central CloudWatch Observability (regional) # deploy_central_cloudwatch_observability(event) From d5e03a0795f22b7bbe86ebad0384797bb5444456 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 5 Nov 2024 20:31:45 -0700 Subject: [PATCH 159/395] working on metric/filters deployed via sns config --- .../genai/bedrock_org/lambda/src/app.py | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index dce026424..9548c26a2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -316,6 +316,8 @@ def get_filter_params(filter_name, resource_properties): Returns: tuple: (filter_deploy, filter_pattern) filter_deploy (bool): whether to deploy the filter + filter_accounts (list): list of accounts to deploy the filter to + filter_regions (list): list of regions to deploy the filter to filter_params (dict): dictionary of filter parameters """ if filter_name.upper() in resource_properties: @@ -333,20 +335,20 @@ def get_filter_params(filter_name, resource_properties): else: LOGGER.info(f"{filter_name.upper()} 'deploy' parameter not found in event ResourceProperties; setting to False") filter_deploy = False - # if "accounts" in metric_filter_params: - # LOGGER.info(f"{filter_name.upper()} 'accounts' parameter found in event ResourceProperties") - # filter_accounts = metric_filter_params["accounts"] - # LOGGER.info(f"{filter_name.upper()} accounts: {filter_accounts}") - # else: - # LOGGER.info(f"{filter_name.upper()} 'accounts' parameter not found in event ResourceProperties") - # filter_accounts = [] - # if "regions" in metric_filter_params: - # LOGGER.info(f"{filter_name.upper()} 'regions' parameter found in event ResourceProperties") - # filter_regions = metric_filter_params["regions"] - # LOGGER.info(f"{filter_name.upper()} regions: {filter_regions}") - # else: - # LOGGER.info(f"{filter_name.upper()} 'regions' parameter not found in event ResourceProperties") - # filter_regions = [] + if "accounts" in metric_filter_params: + LOGGER.info(f"{filter_name.upper()} 'accounts' parameter found in event ResourceProperties") + filter_accounts = metric_filter_params["accounts"] + LOGGER.info(f"{filter_name.upper()} accounts: {filter_accounts}") + else: + LOGGER.info(f"{filter_name.upper()} 'accounts' parameter not found in event ResourceProperties") + filter_accounts = [] + if "regions" in metric_filter_params: + LOGGER.info(f"{filter_name.upper()} 'regions' parameter found in event ResourceProperties") + filter_regions = metric_filter_params["regions"] + LOGGER.info(f"{filter_name.upper()} regions: {filter_regions}") + else: + LOGGER.info(f"{filter_name.upper()} 'regions' parameter not found in event ResourceProperties") + filter_regions = [] if "filter_params" in metric_filter_params: LOGGER.info(f"{filter_name.upper()} 'filter_params' parameter found in event ResourceProperties") filter_params = metric_filter_params["filter_params"] @@ -356,8 +358,8 @@ def get_filter_params(filter_name, resource_properties): filter_params = {} else: LOGGER.info(f"{filter_name.upper()} filter parameter not found in event ResourceProperties; skipping...") - return False, {} - return filter_deploy, filter_params + return False, [], [], {} + return filter_deploy, filter_accounts, filter_regions, filter_params def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: str) -> str: @@ -527,12 +529,15 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA - LOGGER.info(f"CloudWatch Metric Filters: {CLOUDWATCH_METRIC_FILTERS}") for filter in CLOUDWATCH_METRIC_FILTERS: - filter_deploy, filter_params = get_filter_params(filter, resource_properties) + filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, resource_properties) LOGGER.info(f"{filter} parameters: {filter_params}") if filter_deploy is False: + LOGGER.info(f"{filter} filter not requested (deploy set to false). Skipping...") + continue + if region not in filter_regions: + LOGGER.info(f"{filter} filter not requested for {region}. Skipping...") continue LOGGER.info(f"Raw filter pattern: {CLOUDWATCH_METRIC_FILTERS[filter]}") if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: @@ -548,6 +553,9 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): # for region in regions: # 4a) Deploy KMS keys # 4ai) KMS key for SNS topic used by CloudWatch alarms + if acct not in filter_accounts: + LOGGER.info(f"{filter} filter not requested for {acct}. Skipping...") + continue kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is False: From 5fec50415925b88f1fd4b6da3b0ff71a9cdbcc64 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 7 Nov 2024 10:07:50 -0700 Subject: [PATCH 160/395] still need rule_accouts, rule_regions --- .../genai/bedrock_org/lambda/src/app.py | 62 ++++++++----------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 9548c26a2..4d26717ed 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -240,28 +240,13 @@ def get_rule_params(rule_name, resource_properties): Returns: tuple: (rule_deploy, rule_accounts, rule_regions, rule_params) rule_deploy (bool): whether to deploy the rule + rule_accounts (list): list of accounts to deploy the rule to + rule_regions (list): list of regions to deploy the rule to rule_input_params (dict): dictionary of rule input parameters """ # rule_accounts (list): list of accounts to deploy the rule to # rule_regions (list): list of regions to deploy the rule to - # TODO(liamschn): SRA-BEDROCK-ACCOUNTS and SRA-BEDROCK-REGIONS to be moved to a more global area so it is not defined more than once - # if "SRA-BEDROCK-ACCOUNTS" in resource_properties: - # LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") - # rule_accounts = json.loads(resource_properties["SRA-BEDROCK-ACCOUNTS"]) - # LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {rule_accounts}") - # else: - # LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None and deploy to False") - # rule_accounts = [] - # rule_deploy = False - # if "SRA-BEDROCK-REGIONS" in resource_properties: - # LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") - # rule_regions = json.loads(resource_properties["SRA-BEDROCK-REGIONS"]) - # LOGGER.info(f"SRA-BEDROCK-REGIONS: {rule_regions}") - # else: - # LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None and deploy to False") - # rule_regions = [] - # rule_deploy = False if rule_name.upper() in resource_properties: LOGGER.info(f"{rule_name} parameter found in event ResourceProperties") rule_params = json.loads(resource_properties[rule_name.upper()]) @@ -277,22 +262,22 @@ def get_rule_params(rule_name, resource_properties): else: LOGGER.info(f"{rule_name.upper()} 'deploy' parameter not found in event ResourceProperties; setting to False") rule_deploy = False - # if "accounts" in rule_params: - # LOGGER.info(f"{rule_name.upper()} 'accounts' parameter found in event ResourceProperties") - # rule_accounts = rule_params["accounts"] - # LOGGER.info(f"{rule_name.upper()} accounts: {rule_accounts}") - # else: - # LOGGER.info(f"{rule_name.upper()} 'accounts' parameter not found in event ResourceProperties; setting to None and deploy to False") - # rule_accounts = [] - # rule_deploy = False - # if "regions" in rule_params: - # LOGGER.info(f"{rule_name.upper()} 'regions' parameter found in event ResourceProperties") - # rule_regions = rule_params["regions"] - # LOGGER.info(f"{rule_name.upper()} regions: {rule_regions}") - # else: - # LOGGER.info(f"{rule_name.upper()} 'regions' parameter not found in event ResourceProperties; setting to None and deploy to False") - # rule_regions = [] - # rule_deploy = False + if "accounts" in rule_params: + LOGGER.info(f"{rule_name.upper()} 'accounts' parameter found in event ResourceProperties") + rule_accounts = rule_params["accounts"] + LOGGER.info(f"{rule_name.upper()} accounts: {rule_accounts}") + else: + LOGGER.info(f"{rule_name.upper()} 'accounts' parameter not found in event ResourceProperties; setting to None and deploy to False") + rule_accounts = [] + rule_deploy = False + if "regions" in rule_params: + LOGGER.info(f"{rule_name.upper()} 'regions' parameter found in event ResourceProperties") + rule_regions = rule_params["regions"] + LOGGER.info(f"{rule_name.upper()} regions: {rule_regions}") + else: + LOGGER.info(f"{rule_name.upper()} 'regions' parameter not found in event ResourceProperties; setting to None and deploy to False") + rule_regions = [] + rule_deploy = False if "input_params" in rule_params: LOGGER.info(f"{rule_name.upper()} 'input_params' parameter found in event ResourceProperties") rule_input_params = rule_params["input_params"] @@ -300,10 +285,10 @@ def get_rule_params(rule_name, resource_properties): else: LOGGER.info(f"{rule_name.upper()} 'input_params' parameter not found in event ResourceProperties; setting to None") rule_input_params = {} - return rule_deploy, rule_input_params + return rule_deploy, rule_accounts, rule_regions, rule_input_params else: LOGGER.info(f"{rule_name.upper()} config rule parameter not found in event ResourceProperties; skipping...") - return False, {} + return False, [], [], {} def get_filter_params(filter_name, resource_properties): @@ -471,7 +456,7 @@ def deploy_config_rules(region, accounts, resource_properties): if prop.startswith("SRA-BEDROCK-CHECK-"): rule_name: str = prop LOGGER.info(f"Create operation: retrieving {rule_name} parameters...") - rule_deploy, rule_input_params = get_rule_params(rule_name, resource_properties) + rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, resource_properties) rule_name = rule_name.lower() LOGGER.info(f"Create operation: examining {rule_name} resources...") @@ -483,7 +468,10 @@ def deploy_config_rules(region, accounts, resource_properties): # rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) if rule_deploy is False: continue - + if acct not in rule_accounts: + continue + if region not in rule_regions: + continue # for acct in rule_accounts: if DRY_RUN is False: # 3a) Deploy IAM role for custom config rule lambda From 0f798d3efee0b41508d9a43365a09629f96fd55a Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 7 Nov 2024 10:11:31 -0700 Subject: [PATCH 161/395] must have mgmt account added --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 4d26717ed..d28d04ff3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1265,7 +1265,7 @@ def process_sns_records(event) -> None: # rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) # 3) Deploy config rules (regional) - + message['Accounts'].append(sts.MANAGEMENT_ACCOUNT) deploy_config_rules( message["Region"], message["Accounts"], From 21878adf63602a1f6c419441f67cda6733590f79 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 7 Nov 2024 10:21:25 -0700 Subject: [PATCH 162/395] handle blank rule/metric regions/accounts --- .../genai/bedrock_org/lambda/src/app.py | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index d28d04ff3..9c71379fa 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -459,6 +459,11 @@ def deploy_config_rules(region, accounts, resource_properties): rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, resource_properties) rule_name = rule_name.lower() LOGGER.info(f"Create operation: examining {rule_name} resources...") + if rule_regions: + LOGGER.info(f"{rule_name} regions: {rule_regions}") + if region not in rule_regions: + LOGGER.info(f"{rule_name} does not apply to {region}; skipping...") + continue for acct in accounts: @@ -468,10 +473,11 @@ def deploy_config_rules(region, accounts, resource_properties): # rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) if rule_deploy is False: continue - if acct not in rule_accounts: - continue - if region not in rule_regions: - continue + if rule_accounts: + LOGGER.info(f"{rule_name} accounts: {rule_accounts}") + if acct not in rule_accounts: + LOGGER.info(f"{rule_name} does not apply to {acct}; skipping...") + continue # for acct in rule_accounts: if DRY_RUN is False: # 3a) Deploy IAM role for custom config rule lambda @@ -524,9 +530,11 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): if filter_deploy is False: LOGGER.info(f"{filter} filter not requested (deploy set to false). Skipping...") continue - if region not in filter_regions: - LOGGER.info(f"{filter} filter not requested for {region}. Skipping...") - continue + if filter_regions: + LOGGER.info(f"{filter} filter regions: {filter_regions}") + if region not in filter_regions: + LOGGER.info(f"{filter} filter not requested for {region}. Skipping...") + continue LOGGER.info(f"Raw filter pattern: {CLOUDWATCH_METRIC_FILTERS[filter]}") if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: LOGGER.info(f"{filter} filter parameter: 'BUCKET_NAME_PLACEHOLDER' found. Updating with bucket info...") @@ -541,9 +549,11 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): # for region in regions: # 4a) Deploy KMS keys # 4ai) KMS key for SNS topic used by CloudWatch alarms - if acct not in filter_accounts: - LOGGER.info(f"{filter} filter not requested for {acct}. Skipping...") - continue + if filter_accounts: + LOGGER.info(f"filter_accounts: {filter_accounts}") + if acct not in filter_accounts: + LOGGER.info(f"{filter} filter not requested for {acct}. Skipping...") + continue kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is False: From b7c52490e68be11f4954ed9092d29e582e5d9fba Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 7 Nov 2024 21:19:08 -0700 Subject: [PATCH 163/395] working on parameter validation; not functional yet --- .../genai/bedrock_org/lambda/src/app.py | 64 ++++++++++++++++++- .../templates/sra-bedrock-org-main.yaml | 16 ++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 9c71379fa..aea96f67f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -2,6 +2,7 @@ import json import os import logging +import re import boto3 import cfnresponse from botocore.exceptions import ClientError @@ -26,8 +27,8 @@ # TODO(liamschn): Where do we see dry-run data? Maybe S3 staging bucket file? The sra_state table? Another DynamoDB table? # TODO(liamschn): add parameter validation # TODO(liamschn): deploy example bedrock guardrail -# TODO(liamschn): deploy example iam role(s) and policy(ies) -# TODO(liamschn): deploy example bucket policy(ies) +# TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority? +# TODO(liamschn): deploy example bucket policy(ies) - lower priority? from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional @@ -201,6 +202,65 @@ def get_resource_parameters(event): DRY_RUN = False CFN_RESPONSE_DATA["dry_run"] = DRY_RUN + +def parameter_pattern_validator(parameter_name: str, parameter_value: str, pattern: str, is_optional: bool = False) -> dict: + """Validate CloudFormation Custom Resource Properties and/or Lambda Function Environment Variables. + + Args: + parameter_name: CloudFormation custom resource parameter name and/or Lambda function environment variable name + parameter_value: CloudFormation custom resource parameter value and/or Lambda function environment variable value + pattern: REGEX pattern to validate against. + is_optional: Allow empty or missing value when True + + Raises: + ValueError: Parameter has a value of empty string. + ValueError: Parameter is missing + ValueError: Parameter does not follow the allowed pattern + + Returns: + Validated Parameter + """ + if parameter_value == "" and not is_optional: + raise ValueError(f"({parameter_name}) parameter has a value of empty string.") + elif not parameter_value and not is_optional: + raise ValueError(f"({parameter_name}) parameter is missing.") + elif not re.match(pattern, str(parameter_value)): + raise ValueError(f"({parameter_name}) parameter with value of ({parameter_value})" + f" does not follow the allowed pattern: {pattern}.") + return {parameter_name: parameter_value} + +def parameter_pattern_lookup(): + # define a dictionary of patterns for all the ResourceProperties in the sra-bedrock-org-main.yaml file for this lambda function + # the key is the ResourceProperties name and the value is the REGEX pattern to validate against + # the pattern is the same as the AllowedValues in the sra-bedrock-org-main.yaml file for this lambda function + patterns = { + "SRA_REPO_ZIP_URL": r'^https://.*\.zip$', + "DRY_RUN": r'^true|false$', + "EXECUTION_ROLE_NAME": r'^sra-execution$', + "LOG_GROUP_DEPLOY": r'^true|false$', + "LOG_GROUP_RETENTION": r'^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$', + "LOG_LEVEL": r'^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$', + "SOLUTION_NAME": r'^sra-bedrock-org$', + "SOLUTION_VERSION": r'^[0-9]+\.[0-9]+\.[0-9]+$', + "SRA_ALARM_EMAIL": r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', + "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', + "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$', + "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-CHECK-S3-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-FILTER-SERVICE-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-FILTER-BUCKET-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$', + "SRA-BEDROCK-FILTER-PROMPT-INJECTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-FILTER-SENSITIVE-INFO": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-CENTRAL-OBSERVABILITY": r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$', + } + + def get_accounts_and_regions(resource_properties): """Get accounts and regions from event and return them in a tuple diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 5357c7821..6b902a21a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -5,7 +5,9 @@ Parameters: pSRARepoZipUrl: Type: String Default: 'https://github.com/liamschn/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip' + AllowedPattern: ^https://.*\.zip$ Description: The S3 URL for the SRA solution zip file + ConstraintDescription: The S3 URL for the SRA code repository zip file. pDryRun: Type: String @@ -18,8 +20,10 @@ Parameters: pSRAExecutionRoleName: Type: String - Default: 'sra-execution' + Default: ['sra-execution'] + AllowedPattern: ^sra-execution$ Description: The name of the IAM role to use for execution of the SRA solution + ConstraintDescription: The SRA execution role must be named 'sra-execution' pDeployLambdaLogGroup: Type: String @@ -32,7 +36,13 @@ Parameters: pLogGroupRetention: Type: Number Default: 30 - Description: The number of days to retain logs in the CloudWatch Log Group + Description: > + The number of days to retain the log events in the specified log group. + Possible values are: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, and 3653. + AllowedPattern: ^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$ + ConstraintDescription: > + The retention period must be one of the following values: + 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, and 3653 pLambdaLogLevel: Type: String @@ -54,6 +64,8 @@ Parameters: pSRASolutionVersion: Type: String Default: '1.0.0' + AllowedPattern: '^[0-9]+\.[0-9]+\.[0-9]+$' + ConstraintDescription: The SRA solution version must follow the format: .. Description: The version of the SRA solution pSRAAlarmEmail: From a9438cdf48030f5bb65a603600046188c5f6653d Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 9 Nov 2024 09:27:06 -0700 Subject: [PATCH 164/395] finishing param validation function; needs testing --- .../genai/bedrock_org/lambda/src/app.py | 127 +++++++++--------- 1 file changed, 60 insertions(+), 67 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index aea96f67f..962830335 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -19,16 +19,19 @@ import sra_cloudwatch import sra_kms -from typing import Dict, Any +from typing import Dict, Any, List # import sra_lambda # TODO(liamschn): If dynamoDB sra_state table exists, use it # TODO(liamschn): Where do we see dry-run data? Maybe S3 staging bucket file? The sra_state table? Another DynamoDB table? -# TODO(liamschn): add parameter validation +# TODO(liamschn): add parameter validation (in progress; testing) # TODO(liamschn): deploy example bedrock guardrail -# TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority? -# TODO(liamschn): deploy example bucket policy(ies) - lower priority? +# TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority/not necessary? +# TODO(liamschn): deploy example bucket policy(ies) - lower priority/not necessary? +# TODO(liamschn): deal with linting failures in pipeline +# TODO(liamschn): deal with typechecking/mypy +# TODO(liamschn): check for unused parameters from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional @@ -74,11 +77,9 @@ def load_sra_cloudwatch_dashboard() -> dict: RESOURCE_TYPE: str = "" STATE_TABLE: str = "sra_state" SOLUTION_NAME: str = "sra-bedrock-org" -# RULE_REGIONS_ACCOUNTS: list = {} GOVERNED_REGIONS = [] SECURITY_ACCOUNT = "" ORGANIZATION_ID = "" -# BEDROCK_MODEL_EVAL_BUCKET: str = "" SRA_ALARM_EMAIL: str = "" SRA_ALARM_TOPIC_ARN: str = "" @@ -105,7 +106,6 @@ def load_sra_cloudwatch_dashboard() -> dict: DRY_RUN_DATA: dict = {} # other global variables -# TODO(liamschn): Urgent - cannot use these for CFN responses. Max size is 4096 bytes and this gets too large for this. Must change this ASAP (highest priority) LIVE_RUN_DATA: dict = {} IAM_POLICY_DOCUMENTS: Dict[str, Any] = load_iam_policy_documents() CLOUDWATCH_METRIC_FILTERS: dict = load_cloudwatch_metric_filters() @@ -114,6 +114,35 @@ def load_sra_cloudwatch_dashboard() -> dict: CLOUDWATCH_OAM_TRUST_POLICY: dict = load_sra_cloudwatch_oam_trust_policy() CLOUDWATCH_DASHBOARD: dict = load_sra_cloudwatch_dashboard() +# Parameter validation rules +PARAMETER_VALIDATION_RULES: dict = { + "SRA_REPO_ZIP_URL": r'^https://.*\.zip$', + "DRY_RUN": r'^true|false$', + "EXECUTION_ROLE_NAME": r'^sra-execution$', + "LOG_GROUP_DEPLOY": r'^true|false$', + "LOG_GROUP_RETENTION": r'^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$', + "LOG_LEVEL": r'^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$', + "SOLUTION_NAME": r'^sra-bedrock-org$', + "SOLUTION_VERSION": r'^[0-9]+\.[0-9]+\.[0-9]+$', + "SRA_ALARM_EMAIL": r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', + "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', + "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$', + "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-CHECK-S3-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-FILTER-SERVICE-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-FILTER-BUCKET-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$', + "SRA-BEDROCK-FILTER-PROMPT-INJECTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-FILTER-SENSITIVE-INFO": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-CENTRAL-OBSERVABILITY": r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$', +} + # Instantiate sra class objects # todo(liamschn): can these files exist in some central location to be shared with other solutions? ssm_params = sra_ssm_params.sra_ssm_params() @@ -133,17 +162,20 @@ def load_sra_cloudwatch_dashboard() -> dict: def get_resource_parameters(event): global DRY_RUN - # global RULE_REGIONS_ACCOUNTS global GOVERNED_REGIONS - # global BEDROCK_MODEL_EVAL_BUCKET global CFN_RESPONSE_DATA global SRA_ALARM_EMAIL global SECURITY_ACCOUNT global ORGANIZATION_ID + param_validation: dict = validate_parameters(event["ResourceProperties"], PARAMETER_VALIDATION_RULES) + if param_validation["success"] is False: + LOGGER.info(f"Parameter validation failed: {param_validation['errors']}") + raise ValueError(f"Parameter validation failed: {param_validation['errors']}") from None + else: + LOGGER.info("Parameter validation succeeded") + LOGGER.info("Getting resource params...") - # TODO(liamschn): what parameters do we need for this solution? - # event["ResourceProperties"]["CONTROL_TOWER"] repo.REPO_ZIP_URL = event["ResourceProperties"]["SRA_REPO_ZIP_URL"] repo.REPO_BRANCH = repo.REPO_ZIP_URL.split(".")[1].split("/")[len(repo.REPO_ZIP_URL.split(".")[1].split("/")) - 1] repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" @@ -182,12 +214,6 @@ def get_resource_parameters(event): else: LOGGER.info("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") raise ValueError("Error retrieving SRA staging bucket ssm parameter. Is the SRA common prerequisites solution deployed?") from None - # TODO(liamschn): remove the RULE_REGIONS_ACCOUNTS parameter after confirming it is no longer used. - # if "RULE_REGIONS_ACCOUNTS" in event["ResourceProperties"]: - # RULE_REGIONS_ACCOUNTS = json.loads(event["ResourceProperties"]["RULE_REGIONS_ACCOUNTS"].replace("'", '"')) - # TODO(liamschn): remove the BEDROCK_MODEL_EVAL_BUCKET parameter after confirming it is no longer used. - # if "BEDROCK_MODEL_EVAL_BUCKET" in event["ResourceProperties"]: - # BEDROCK_MODEL_EVAL_BUCKET = event["ResourceProperties"]["BEDROCK_MODEL_EVAL_BUCKET"] if event["ResourceProperties"]["SRA_ALARM_EMAIL"] != "": SRA_ALARM_EMAIL = event["ResourceProperties"]["SRA_ALARM_EMAIL"] @@ -203,61 +229,28 @@ def get_resource_parameters(event): CFN_RESPONSE_DATA["dry_run"] = DRY_RUN -def parameter_pattern_validator(parameter_name: str, parameter_value: str, pattern: str, is_optional: bool = False) -> dict: - """Validate CloudFormation Custom Resource Properties and/or Lambda Function Environment Variables. +def validate_parameters(parameters: Dict[str, str], rules: Dict[str, str]) -> Dict[str, object]: + """Validates each parameter against its corresponding regular expression. Args: - parameter_name: CloudFormation custom resource parameter name and/or Lambda function environment variable name - parameter_value: CloudFormation custom resource parameter value and/or Lambda function environment variable value - pattern: REGEX pattern to validate against. - is_optional: Allow empty or missing value when True - - Raises: - ValueError: Parameter has a value of empty string. - ValueError: Parameter is missing - ValueError: Parameter does not follow the allowed pattern + parameters (Dict[str, str]): Dictionary of parameters to validate + rules (Dict[str, str]): Dictionary of parameter names and regex patterns Returns: - Validated Parameter + Dict[str, object]: Dictionary with 'success' key (bool) and 'errors' key (list of error messages) """ - if parameter_value == "" and not is_optional: - raise ValueError(f"({parameter_name}) parameter has a value of empty string.") - elif not parameter_value and not is_optional: - raise ValueError(f"({parameter_name}) parameter is missing.") - elif not re.match(pattern, str(parameter_value)): - raise ValueError(f"({parameter_name}) parameter with value of ({parameter_value})" + f" does not follow the allowed pattern: {pattern}.") - return {parameter_name: parameter_value} - -def parameter_pattern_lookup(): - # define a dictionary of patterns for all the ResourceProperties in the sra-bedrock-org-main.yaml file for this lambda function - # the key is the ResourceProperties name and the value is the REGEX pattern to validate against - # the pattern is the same as the AllowedValues in the sra-bedrock-org-main.yaml file for this lambda function - patterns = { - "SRA_REPO_ZIP_URL": r'^https://.*\.zip$', - "DRY_RUN": r'^true|false$', - "EXECUTION_ROLE_NAME": r'^sra-execution$', - "LOG_GROUP_DEPLOY": r'^true|false$', - "LOG_GROUP_RETENTION": r'^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$', - "LOG_LEVEL": r'^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$', - "SOLUTION_NAME": r'^sra-bedrock-org$', - "SOLUTION_VERSION": r'^[0-9]+\.[0-9]+\.[0-9]+$', - "SRA_ALARM_EMAIL": r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', - "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', - "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', - "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', - "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', - "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$', - "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-CHECK-S3-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-FILTER-SERVICE-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-FILTER-BUCKET-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$', - "SRA-BEDROCK-FILTER-PROMPT-INJECTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-FILTER-SENSITIVE-INFO": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-CENTRAL-OBSERVABILITY": r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$', + errors: List[str] = [] + + for param, regex in rules.items(): + value = parameters.get(param) + if value is None: + errors.append(f"Parameter '{param}' is missing.") + elif not re.match(regex, value): + errors.append(f"Parameter '{param}' with value '{value}' does not match the expected pattern '{regex}'.") + + return { + "success": len(errors) == 0, + "errors": errors } From 16e73157ee34460d0af21c6380f12fa36938ddfa Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 11 Nov 2024 11:15:05 -0700 Subject: [PATCH 165/395] adding state table --- .../genai/bedrock_org/lambda/src/app.py | 58 ++++++++++++++++++- .../bedrock_org/lambda/src/sra_dynamodb.py | 15 ++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 962830335..b9bbf20ab 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -25,7 +25,6 @@ # TODO(liamschn): If dynamoDB sra_state table exists, use it # TODO(liamschn): Where do we see dry-run data? Maybe S3 staging bucket file? The sra_state table? Another DynamoDB table? -# TODO(liamschn): add parameter validation (in progress; testing) # TODO(liamschn): deploy example bedrock guardrail # TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority/not necessary? # TODO(liamschn): deploy example bucket policy(ies) - lower priority/not necessary? @@ -82,6 +81,7 @@ def load_sra_cloudwatch_dashboard() -> dict: ORGANIZATION_ID = "" SRA_ALARM_EMAIL: str = "" SRA_ALARM_TOPIC_ARN: str = "" +STATE_TABLE: str = "sra_state" # for saving resource info LAMBDA_START: str = "" LAMBDA_FINISH: str = "" @@ -193,7 +193,8 @@ def get_resource_parameters(event): security_acct_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/control-tower/audit-account-id") if security_acct_param[0] is True: - SECURITY_ACCOUNT = security_acct_param[1] + SECURITY_ACCOUNT = security_acct_param[1] # TODO(liamschn): switch to using the class SRA_SECURITY_ACCT variable? + ssm_params.SRA_SECURITY_ACCT = security_acct_param[1] LOGGER.info(f"Successfully retrieved the SRA security account parameter: {SECURITY_ACCOUNT}") else: LOGGER.info("Error retrieving SRA security account ssm parameter. Is the SRA common prerequisites solution deployed?") @@ -437,6 +438,57 @@ def build_cloudwatch_dashboard(dashboard_template, solution, bedrock_accounts, r dashboard_template[solution]["widgets"][0]["properties"]["region"] = sts.HOME_REGION return dashboard_template[solution] + +def deploy_state_table(): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + + if DRY_RUN is False: + LOGGER.info("Live run: creating the state table...") + dynamodb_client = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + if dynamodb.table_exists(STATE_TABLE, dynamodb_client) is False: + dynamodb.create_table(STATE_TABLE, dynamodb_client) + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": f"arn:aws:dynamodb:{sts.HOME_REGION}:{ssm_params.SRA_SECURITY_ACCT}:table/{STATE_TABLE}", + }, + ) + if item_found is False: + dynamodb_record_id, dynamodb_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + dynamodb_record_id = find_result["record_id"] + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + dynamodb_record_id, + { + "aws_service": "dynamodb", + "component_state": "implemented", + "account": ssm_params.SRA_SECURITY_ACCT, + "description": "sra state table", + "component_region": sts.HOME_REGION, + "component_type": "table", + "component_name": STATE_TABLE, + "arn": f"arn:aws:dynamodb:{sts.HOME_REGION}:{ssm_params.SRA_SECURITY_ACCT}:table/{STATE_TABLE}", + "date_time": dynamodb.get_date_time(), + }, + ) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + LIVE_RUN_DATA["StateTableCreate"] = "Created state table" + else: + LOGGER.info(f"DRY_RUN: Create the {STATE_TABLE} state table") + DRY_RUN_DATA["StateTableCreate"] = f"DRY_RUN: Create the {STATE_TABLE} state table" + + def deploy_stage_config_rule_lambda_code(): global DRY_RUN_DATA global LIVE_RUN_DATA @@ -903,6 +955,8 @@ def create_event(event, context): event_info = {"Event": event} LOGGER.info(event_info) + # Deploy state table + deploy_state_table() # 1) Stage config rule lambda code (global/home region) deploy_stage_config_rule_lambda_code() diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 67336462b..a448bf19b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -115,7 +115,18 @@ def update_item(self, table_name, dynamodb_resource, solution_name, record_id, a ) return response - def find_item(self, table_name, dynamodb_resource, solution_name, additional_attributes): + def find_item(self, table_name, dynamodb_resource, solution_name, additional_attributes) -> tuple[bool, dict]: + """Find an item in the dynamodb table based on the solution name and additional attributes. + + Args: + table_name: dynamodb table name + dynamodb_resource: dynamodb resource + solution_name: solution name + additional_attributes: additional attributes to search for + + Returns: + True and the item if found, otherwise False and empty dict + """ table = dynamodb_resource.Table(table_name) expression_attribute_values = {":solution_name": solution_name} @@ -138,7 +149,7 @@ def find_item(self, table_name, dynamodb_resource, solution_name, additional_att f"Found more than one record that matched record id {response['Items'][0]['record_id']}. Review {table_name} dynamodb table to determine cause." ) elif len(response["Items"]) < 1: - return False, None + return False, {} return True, response["Items"][0] From e77bf62c586e0f270193ae75eeaf342376977196 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 14 Nov 2024 23:14:58 -0700 Subject: [PATCH 166/395] Refactor Lambda packaging script to target src folder only --- .../utils/packaging_scripts/stage_solution.sh | 86 ++++++++----------- 1 file changed, 38 insertions(+), 48 deletions(-) diff --git a/aws_sra_examples/utils/packaging_scripts/stage_solution.sh b/aws_sra_examples/utils/packaging_scripts/stage_solution.sh index 3519dfa46..47439f478 100755 --- a/aws_sra_examples/utils/packaging_scripts/stage_solution.sh +++ b/aws_sra_examples/utils/packaging_scripts/stage_solution.sh @@ -112,56 +112,46 @@ stage_cloudformation_templates() { package_and_stage_lambda_code() { # Function to package and stage Lambda code - if [ -d "$1/lambda" ]; then + if [ -d "$1/lambda/src" ]; then echo "...Package and Stage Lambda Code" - lambda_folder_count=0 - for dir in "$1"/lambda/*/; do - lambda_folder_count=$((lambda_folder_count + 1)) - done - for dir in "$1"/lambda/*/; do - lambda_dir="${dir%"${dir##*[!/]}"}" # remove the trailing / - lambda_dir="${lambda_dir##*/}" # remove everything before the last / - lambda_dir="${lambda_dir//_/-}" # replace all underscores with dashes - - cd "$dir" || exit 1 - has_python=$(find ./*.py 2>/dev/null | wc -l) - has_requirements=$(find requirements.txt 2>/dev/null | wc -l) - - if [ "$has_python" -ne 0 ] && [ "$has_requirements" -ne 0 ]; then - echo "...Creating the temporary packaging folder (tmp_sra_lambda_src_XXXX)" - tmp_folder=$(mktemp -d "$TMP_FOLDER_NAME") || exit 1 # create the temp folder - cp -r "$dir"* "$tmp_folder" || exit 1 # copy lambda source to temp source folder - pip3 --disable-pip-version-check install -t "$tmp_folder" -r "$tmp_folder/requirements.txt" -q || - { - rm -rf "$tmp_folder" - echo "---> Error: Python3 is required" - exit 1 - } + + src_dir="$1/lambda/src" + cd "$src_dir" || exit 1 + has_python=$(find ./*.py 2>/dev/null | wc -l) + has_requirements=$(find requirements.txt 2>/dev/null | wc -l) + + if [ "$has_python" -ne 0 ] && [ "$has_requirements" -ne 0 ]; then + echo "...Creating the temporary packaging folder (tmp_sra_lambda_src_XXXX)" + tmp_folder=$(mktemp -d "$TMP_FOLDER_NAME") || exit 1 # create the temp folder + cp -r "$src_dir"/* "$tmp_folder" || exit 1 # copy lambda source to temp source folder + pip3 --disable-pip-version-check install -t "$tmp_folder" -r "$tmp_folder/requirements.txt" -q || + { + rm -rf "$tmp_folder" + echo "---> Error: Python3 is required" + exit 1 + } - cd "$2" || exit 1 # change directory into staging folder - if [ "$lambda_folder_count" -gt "1" ]; then - lambda_zip_file="$2/$3-$lambda_dir.zip" - else - lambda_zip_file="$2/$3.zip" - fi - rm -f "$lambda_zip_file" # remove zip file, if exists - - echo "...Creating zip file from the temp folder contents" - cd "$tmp_folder" || exit 1 # changed directory to temp folder - zip -r -q "$lambda_zip_file" . -x "*.DS_Store" -x "inline_*" || - 7z a -tzip "$lambda_zip_file" || - { - echo "---> ERROR: Zip and 7zip are not available. Manually create the zip file with the $2 folder contents." - exit 1 - } # zip source with packages - cd "$HERE" || exit 1 # change directory to the original directory - - echo "...Removing Temporary Folder $tmp_folder" - rm -rf "$tmp_folder" - else - echo "---> ERROR: Lambda folder '$lambda_dir' does not have any python files and a requirements.txt file" - fi - done + cd "$2" || exit 1 # change directory into staging folder + lambda_zip_file="$2/$3.zip" + rm -f "$lambda_zip_file" # remove zip file, if exists + + echo "...Creating zip file from the temp folder contents" + cd "$tmp_folder" || exit 1 # changed directory to temp folder + zip -r -q "$lambda_zip_file" . -x "*.DS_Store" -x "inline_*" || + 7z a -tzip "$lambda_zip_file" || + { + echo "---> ERROR: Zip and 7zip are not available. Manually create the zip file with the $2 folder contents." + exit 1 + } # zip source with packages + cd "$HERE" || exit 1 # change directory to the original directory + + echo "...Removing Temporary Folder $tmp_folder" + rm -rf "$tmp_folder" + else + echo "---> ERROR: Lambda src folder does not have any python files and a requirements.txt file" + fi + else + echo "---> ERROR: Lambda src folder not found at $1/lambda/src" fi } From 05e530700c9153639a3cbd12b29cbf8106f67e1f Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 08:02:30 -0700 Subject: [PATCH 167/395] fix template errors --- .../templates/sra-bedrock-org-main.yaml | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 6b902a21a..a9b0bf39d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -20,7 +20,7 @@ Parameters: pSRAExecutionRoleName: Type: String - Default: ['sra-execution'] + Default: 'sra-execution' AllowedPattern: ^sra-execution$ Description: The name of the IAM role to use for execution of the SRA solution ConstraintDescription: The SRA execution role must be named 'sra-execution' @@ -34,8 +34,8 @@ Parameters: Description: true or false; deploy lambda log group pLogGroupRetention: - Type: Number - Default: 30 + Type: String + Default: '30' Description: > The number of days to retain the log events in the specified log group. Possible values are: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, and 3653. @@ -64,8 +64,8 @@ Parameters: pSRASolutionVersion: Type: String Default: '1.0.0' - AllowedPattern: '^[0-9]+\.[0-9]+\.[0-9]+$' - ConstraintDescription: The SRA solution version must follow the format: .. + AllowedPattern: ^[0-9]+\.[0-9]+\.[0-9]+$ + ConstraintDescription: 'The SRA solution version must follow the format ..' Description: The version of the SRA solution pSRAAlarmEmail: @@ -97,7 +97,7 @@ Parameters: pBedrockModelEvalBucketRuleParams: Type: String # TODO(liamschn): update default value of pBedrockModelEvalBucketRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"BucketName": "model-invocation-log-bucket-1-225989363553"}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"BucketName": "model-invocation-log-bucket-1-221082195774"}}' Description: Bedrock Model Evaluation Job Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ ConstraintDescription: @@ -109,7 +109,7 @@ Parameters: pBedrockIAMUserAccessRuleParams: Type: String # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock IAM User Access Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ ConstraintDescription: @@ -121,7 +121,7 @@ Parameters: pBedrockGuardrailsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockGuardrailsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' Description: Bedrock Guardrails Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -136,7 +136,7 @@ Parameters: pBedrockVPCEndpointsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockVPCEndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' Description: Bedrock VPC Endpoints Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -151,7 +151,7 @@ Parameters: pBedrockInvocationLogCWRuleParams: Type: String # TODO(liamschn): update default value of pBedrockInvocationLogCWRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' Description: Bedrock Model Invocation Logging to CloudWatch Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$ ConstraintDescription: > @@ -166,7 +166,7 @@ Parameters: pBedrockInvocationLogS3RuleParams: Type: String # TODO(liamschn): update default value of pBedrockInvocationLogS3RuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' Description: Bedrock Model Invocation Logging to S3 Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -181,7 +181,7 @@ Parameters: pBedrockCWEndpointsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockCWEndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock CloudWatch VPC Endpoint Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -193,7 +193,7 @@ Parameters: pBedrockS3EndpointsRuleParams: Type: String # TODO(liamschn): update default value of pBedrockS3EndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock S3 VPC Endpoint Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -205,7 +205,7 @@ Parameters: pBedrockGuardrailEncryptionRuleParams: Type: String # TODO(liamschn): update default value of pBedrockGuardrailEncryptionRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock Guardrail KMS Encryption Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -217,7 +217,7 @@ Parameters: pBedrockServiceChangesFilterParams: # TODO(liamschn): update default value of pBedrockServiceChangesFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["897722683088"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' + Default: '{"deploy": "true", "accounts": ["904233121418"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' Description: Bedrock Service Changes Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > @@ -227,7 +227,7 @@ Parameters: pBedrockBucketChangesFilterParams: # TODO(liamschn): update default value of pBedrockBucketChangesFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["897722683088"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["model-invocation-log-bucket-1-225989363553"]}}' + Default: '{"deploy": "true", "accounts": ["904233121418"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["model-invocation-log-bucket-221082195774"]}}' Description: Bedrock S3 Bucket Changes Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$ ConstraintDescription: > @@ -238,7 +238,7 @@ Parameters: pBedrockInvocationLogFilterParams: # TODO(liamschn): update default value of pBedrockInvocationLogFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["225989363553"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' Description: Bedrock Prompt Injection and Sensitive Info Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > @@ -250,7 +250,7 @@ Parameters: pBedrockCentralObservabilityParams: # TODO(liamschn): update default value of pBedrockCentralObservabilityParams prior to production Type: String - Default: '{"deploy": "true", "bedrock_accounts": ["225989363553"], "regions": ["us-west-2"]}' + Default: '{"deploy": "true", "bedrock_accounts": ["221082195774"], "regions": ["us-west-2"]}' Description: Bedrock Central Observability Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$ ConstraintDescription: > @@ -260,7 +260,7 @@ Parameters: pBedrockAccounts: Type: String # TODO(liamschn): update default value of pBedrockAccounts prior to production - Default: '["225989363553"]' + Default: '["221082195774"]' Description: Bedrock Accounts AllowedPattern: ^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$ ConstraintDescription: > From 8b30fc1827f536c1aa8aed8a26366145dd02930f Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 08:28:26 -0700 Subject: [PATCH 168/395] add sns topic state table record --- .../genai/bedrock_org/lambda/src/app.py | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b9bbf20ab..58685bc93 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -446,6 +446,8 @@ def deploy_state_table(): if DRY_RUN is False: LOGGER.info("Live run: creating the state table...") + # TODO(liamschn): move dynamodb client and resource to the dynamo class object/module + # TODO(liamschn): move the deploy state table function to the dynamo class object/module? dynamodb_client = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) if dynamodb.table_exists(STATE_TABLE, dynamodb_client) is False: @@ -522,7 +524,7 @@ def deploy_sns_configuration_topics(context): topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-configuration SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") # TODO(liamschn): search for permissions on lambda before adding the policy @@ -551,6 +553,41 @@ def deploy_sns_configuration_topics(context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search + # SNS State table record: + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": topic_arn, + }, + ) + if item_found is False: + sns_record_id, sns_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + sns_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + sns_record_id, + { + "aws_service": "sns", + "component_state": "implemented", + "account": ACCOUNT, + "description": "configuration topic", + "component_region": sts.HOME_REGION, + "component_type": "topic", + "component_name": f"{SOLUTION_NAME}-configuration", + "arn": topic_arn, + "date_time": dynamodb.get_date_time(), + }, + ) + + return topic_arn def deploy_config_rules(region, accounts, resource_properties): @@ -967,21 +1004,6 @@ def create_event(event, context): # 3, 4, and 5 handled by SNS accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) - # TODO(liamschn): Move get regions and accounts into its own function (confirm working) - # if "SRA-BEDROCK-ACCOUNTS" in event["ResourceProperties"]: - # LOGGER.info("SRA-BEDROCK-ACCOUNTS found in event ResourceProperties") - # accounts = json.loads(event["ResourceProperties"]["SRA-BEDROCK-ACCOUNTS"]) - # LOGGER.info(f"SRA-BEDROCK-ACCOUNTS: {accounts}") - # else: - # LOGGER.info("SRA-BEDROCK-ACCOUNTS not found in event ResourceProperties; setting to None") - # accounts = [] - # if "SRA-BEDROCK-REGIONS" in event["ResourceProperties"]: - # LOGGER.info("SRA-BEDROCK-REGIONS found in event ResourceProperties") - # regions = json.loads(event["ResourceProperties"]["SRA-BEDROCK-REGIONS"]) - # LOGGER.info(f"SRA-BEDROCK-REGIONS: {regions}") - # else: - # LOGGER.info("SRA-BEDROCK-REGIONS not found in event ResourceProperties; setting to None") - # regions = [] # 3) Deploy config rules (regional) # deploy_config_rules(event) From 4a66f58eaed7f63bd161f0b033e93dce2b97e53c Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 09:52:15 -0700 Subject: [PATCH 169/395] add iam+lambda resources to state table --- .../genai/bedrock_org/lambda/src/app.py | 151 ++++++++++++++++-- .../bedrock_org/lambda/src/sra_dynamodb.py | 16 +- 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 58685bc93..9ccc74cb5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -609,10 +609,6 @@ def deploy_config_rules(region, accounts, resource_properties): for acct in accounts: - # for rule in repo.CONFIG_RULES[SOLUTION_NAME]: - # rule_name = rule.replace("_", "-") - # Get bedrock solution rule accounts and regions - # rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) if rule_deploy is False: continue if rule_accounts: @@ -620,18 +616,15 @@ def deploy_config_rules(region, accounts, resource_properties): if acct not in rule_accounts: LOGGER.info(f"{rule_name} does not apply to {acct}; skipping...") continue - # for acct in rule_accounts: if DRY_RUN is False: # 3a) Deploy IAM role for custom config rule lambda LOGGER.info(f"Deploying IAM role for custom config rule lambda in {acct}") role_arn = deploy_iam_role(acct, rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "Deployed IAM role for custom config rule lambda" + else: LOGGER.info(f"DRY_RUN: Deploying IAM role for custom config rule lambda in {acct}") DRY_RUN_DATA[f"{rule_name}_{acct}_IAMRole"] = "DRY_RUN: Deploy IAM role for custom config rule lambda" - - # for acct in rule_accounts: - # for region in rule_regions: # 3b) Deploy lambda for custom config rule if DRY_RUN is False: # download rule zip file @@ -1390,18 +1383,12 @@ def process_sns_records(event) -> None: records: list of SNS event records """ LOGGER.info("Processing SNS records...") - # for record in records: - # sns_info = record["Sns"] - # LOGGER.info(f"SNS INFO: {sns_info}") - # message = json.loads(sns_info["Message"]) - # deploy_config_rule(message["AccountId"], message["ConfigRuleName"], message["Regions"]) for record in event["Records"]: record["Sns"]["Message"] = json.loads(record["Sns"]["Message"]) LOGGER.info({"SNS Record": record}) message = record["Sns"]["Message"] if message["Action"] == "configure": LOGGER.info("Continuing process to enable SRA security controls for Bedrock (sns event)") - # rule_deploy, rule_accounts, rule_regions, rule_input_params = get_rule_params(rule_name, event) # 3) Deploy config rules (regional) message['Accounts'].append(sts.MANAGEMENT_ACCOUNT) @@ -1447,6 +1434,41 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: LOGGER.info(f"{rule_name} IAM role already exists.") role_arn = iam_role_search[1] + # IAM role state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": role_arn, + }, + ) + if item_found is False: + role_record_id, role_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + role_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + role_record_id, + { + "aws_service": "iam", + "component_state": "implemented", + "account": account_id, + "description": "role for config rule", + "component_region": "Global", + "component_type": "role", + "component_name": rule_name, + "arn": role_arn, + "date_time": dynamodb.get_date_time(), + }, + ) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" ][0]["Resource"].replace("ACCOUNT_ID", account_id) @@ -1470,6 +1492,38 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") + # IAM policy state table record + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": policy_arn, + }, + ) + if item_found is False: + policy1_record_id, policy1_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + policy1_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + policy1_record_id, + { + "aws_service": "iam", + "component_state": "implemented", + "account": account_id, + "description": "policy for config rule role", + "component_region": "Global", + "component_type": "policy", + "component_name": f"{rule_name}-lamdba-basic-execution", + "arn": policy_arn, + "date_time": dynamodb.get_date_time(), + }, + ) + policy_arn2 = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}" iam_policy_search2 = iam.check_iam_policy_exists(policy_arn2) if iam_policy_search2 is False: @@ -1483,6 +1537,38 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"{rule_name} IAM policy already exists") + # IAM policy state table record + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": policy_arn2, + }, + ) + if item_found is False: + policy2_record_id, policy2_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + policy2_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + policy2_record_id, + { + "aws_service": "iam", + "component_state": "implemented", + "account": account_id, + "description": "policy for config rule role", + "component_region": "Global", + "component_type": "policy", + "component_name": f"{rule_name}-lamdba-basic-execution", + "arn": policy_arn2, + "date_time": dynamodb.get_date_time(), + }, + ) + policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) if policy_attach_search1 is False: if DRY_RUN is False: @@ -1556,6 +1642,43 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio else: LOGGER.info(f"{rule_name} already exists in {account_id}. Search result: {lambda_function_search}") lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] + + # Lambda state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": lambda_arn, + }, + ) + if item_found is False: + lambda_record_id, lambda_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + lambda_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + lambda_record_id, + { + "aws_service": "lambda", + "component_state": "implemented", + "account": account_id, + "description": "lambda for config rule", + "component_region": region, + "component_type": "function", + "component_name": rule_name, + "arn": lambda_arn, + "date_time": dynamodb.get_date_time(), + }, + ) + + return lambda_arn diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index a448bf19b..485ca442d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -7,16 +7,30 @@ from datetime import datetime from time import sleep import botocore +from boto3.session import Session +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table + from mypy_boto3_dynamodb.client import DynamoDBClient + class sra_dynamodb: PROFILE = "default" UNEXPECTED = "Unexpected!" - LOGGER = logging.getLogger(__name__) + LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) + try: + MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() + DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") + except Exception: + LOGGER.exception(UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + def __init__(self, profile="default") -> None: self.PROFILE = profile try: From 2a8f21bdfecf0b9ee503590b23ed7843e7b5f03a Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 10:24:31 -0700 Subject: [PATCH 170/395] config state record --- .../genai/bedrock_org/lambda/src/app.py | 41 ++++++++++++++++++- .../bedrock_org/lambda/src/sra_dynamodb.py | 12 +++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 9ccc74cb5..c2fe306dd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1703,22 +1703,59 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: LOGGER.info(f"Creating {rule_name} config rule in {account_id} in {region}...") # TODO(liamschn): Determine if we need to add a description for the config rules # TODO(liamschn): Determine what we will do for input parameters variable in the config rule create function;need an s3 bucket currently - config.create_config_rule( + config_response = config.create_config_rule( rule_name, lambda_arn, "One_Hour", "CUSTOM_LAMBDA", rule_name, - # {"BucketName": BEDROCK_MODEL_EVAL_BUCKET}, input_params, "DETECTIVE", SOLUTION_NAME, ) + config_rule_arn = config_response["ConfigRule"]["ConfigRuleArn"] else: LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function in {account_id} in {region}...") LOGGER.info(f"DRY_RUN: Creating {rule_name} config rule in {account_id} in {region}...") else: LOGGER.info(f"{rule_name} config rule already exists.") + config_rule_arn = config_rule_search[1]["ConfigRules"][0]["ConfigRuleArn"] + + # Config rule state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": config_rule_arn, + }, + ) + if item_found is False: + config_record_id, config_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + config_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + config_record_id, + { + "aws_service": "config", + "component_state": "implemented", + "account": account_id, + "description": "custom config rule", + "component_region": region, + "component_type": "rule", + "component_name": rule_name, + "arn": config_rule_arn, + "date_time": dynamodb.get_date_time(), + }, + ) + def deploy_metric_filter(log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str): diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 485ca442d..596ed6395 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -9,9 +9,9 @@ import botocore from boto3.session import Session from typing import TYPE_CHECKING -if TYPE_CHECKING: - from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table - from mypy_boto3_dynamodb.client import DynamoDBClient +# if TYPE_CHECKING: + # from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table + # from mypy_boto3_dynamodb.client import DynamoDBClient @@ -25,8 +25,8 @@ class sra_dynamodb: try: MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() - DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") - DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") + # DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + # DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") except Exception: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None @@ -39,7 +39,7 @@ def __init__(self, profile="default") -> None: else: self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() - self.DYNAMODB_RESOURCE = self.MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + # self.DYNAMODB_RESOURCE = self.MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") except Exception: self.LOGGER.exception(self.UNEXPECTED) raise ValueError("Unexpected error!") from None From b15318610248be76342604f7ab7ba4722a13edbe Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 11:10:54 -0700 Subject: [PATCH 171/395] update for config arn --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index c2fe306dd..795ef7454 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1713,7 +1713,8 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: "DETECTIVE", SOLUTION_NAME, ) - config_rule_arn = config_response["ConfigRule"]["ConfigRuleArn"] + config_rule_search = config.find_config_rule(rule_name) + config_rule_arn = config_rule_search[1]["ConfigRules"][0]["ConfigRuleArn"] else: LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function in {account_id} in {region}...") LOGGER.info(f"DRY_RUN: Creating {rule_name} config rule in {account_id} in {region}...") From 5827383cf5488175aa925895520ae310116790f1 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 11:56:58 -0700 Subject: [PATCH 172/395] fix cfn sns resource type error; fix dynamodb resource error --- .../solutions/genai/bedrock_org/lambda/src/app.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 795ef7454..9b923e814 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1493,6 +1493,9 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") # IAM policy state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + item_found, find_result = dynamodb.find_item( STATE_TABLE, dynamodb_resource, @@ -1538,6 +1541,9 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: LOGGER.info(f"{rule_name} IAM policy already exists") # IAM policy state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + item_found, find_result = dynamodb.find_item( STATE_TABLE, dynamodb_resource, @@ -1845,6 +1851,7 @@ def lambda_handler(event, context): LOGGER.info(f"ResourceType: {RESOURCE_TYPE}") else: LOGGER.info("ResourceType not found in event.") + RESOURCE_TYPE = "Other" if "Records" not in event and "RequestType" not in event: raise ValueError( f"The event did not include Records or RequestType. Review CloudWatch logs '{context.log_group_name}' for details." From 7b482250660ffd0141373ed340f0934992206a68 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 13:18:07 -0700 Subject: [PATCH 173/395] update component type --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 9b923e814..84cfa48d9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1677,7 +1677,7 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio "account": account_id, "description": "lambda for config rule", "component_region": region, - "component_type": "function", + "component_type": "lambda", "component_name": rule_name, "arn": lambda_arn, "date_time": dynamodb.get_date_time(), From 457b01e406258845b73890633493a95689de1e97 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 13:30:56 -0700 Subject: [PATCH 174/395] adding tracing for dynamodb module --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 596ed6395..ed2ad131b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -108,6 +108,7 @@ def insert_item(self, table_name, dynamodb_resource, solution_name): return record_id, date_time def update_item(self, table_name, dynamodb_resource, solution_name, record_id, attributes_and_values): + self.LOGGER.info(f"Updating {table_name} dynamodb table with {attributes_and_values}") table = dynamodb_resource.Table(table_name) update_expression = "" expression_attribute_values = {} @@ -141,6 +142,7 @@ def find_item(self, table_name, dynamodb_resource, solution_name, additional_att Returns: True and the item if found, otherwise False and empty dict """ + self.LOGGER.info(f"Searching for {additional_attributes} in {table_name} dynamodb table") table = dynamodb_resource.Table(table_name) expression_attribute_values = {":solution_name": solution_name} @@ -164,7 +166,7 @@ def find_item(self, table_name, dynamodb_resource, solution_name, additional_att ) elif len(response["Items"]) < 1: return False, {} - + self.LOGGER.info(f"Found record id {response['Items'][0]}") return True, response["Items"][0] def get_unique_values_from_list(self, list_of_values): From f439e42ba7e31b303ed7360dfc344d3fc81959c1 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 14:26:53 -0700 Subject: [PATCH 175/395] fixing role state record --- .../genai/bedrock_org/lambda/src/app.py | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 84cfa48d9..f630067ed 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1434,40 +1434,40 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: LOGGER.info(f"{rule_name} IAM role already exists.") role_arn = iam_role_search[1] - # IAM role state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + # IAM role state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": role_arn, - }, - ) - if item_found is False: - role_record_id, role_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - role_record_id = find_result["record_id"] + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": role_arn, + }, + ) + if item_found is False: + role_record_id, role_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + role_record_id = find_result["record_id"] - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - role_record_id, - { - "aws_service": "iam", - "component_state": "implemented", - "account": account_id, - "description": "role for config rule", - "component_region": "Global", - "component_type": "role", - "component_name": rule_name, - "arn": role_arn, - "date_time": dynamodb.get_date_time(), - }, - ) + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + role_record_id, + { + "aws_service": "iam", + "component_state": "implemented", + "account": account_id, + "description": "role for config rule", + "component_region": "Global", + "component_type": "role", + "component_name": rule_name, + "arn": role_arn, + "date_time": dynamodb.get_date_time(), + }, + ) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" From ebac54449392202cb4cee59398d4557d9b950ac7 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 14:42:59 -0700 Subject: [PATCH 176/395] fixing lambda state record --- .../genai/bedrock_org/lambda/src/app.py | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index f630067ed..1e74ca39a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1649,40 +1649,40 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio LOGGER.info(f"{rule_name} already exists in {account_id}. Search result: {lambda_function_search}") lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] - # Lambda state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + # Lambda state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": lambda_arn, - }, - ) - if item_found is False: - lambda_record_id, lambda_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - lambda_record_id = find_result["record_id"] + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": lambda_arn, + }, + ) + if item_found is False: + lambda_record_id, lambda_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + lambda_record_id = find_result["record_id"] - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - lambda_record_id, - { - "aws_service": "lambda", - "component_state": "implemented", - "account": account_id, - "description": "lambda for config rule", - "component_region": region, - "component_type": "lambda", - "component_name": rule_name, - "arn": lambda_arn, - "date_time": dynamodb.get_date_time(), - }, - ) + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + lambda_record_id, + { + "aws_service": "lambda", + "component_state": "implemented", + "account": account_id, + "description": "lambda for config rule", + "component_region": region, + "component_type": "lambda", + "component_name": rule_name, + "arn": lambda_arn, + "date_time": dynamodb.get_date_time(), + }, + ) return lambda_arn From cbff778e7d60b64abb15a62c0b0c1294313f9884 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 15:57:14 -0700 Subject: [PATCH 177/395] kms key state records --- .../genai/bedrock_org/lambda/src/app.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 1e74ca39a..857c417a5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -723,6 +723,76 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" else: LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") + + if DRY_RUN is False: + # Add KMS resource records to sra state table + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", + }, + ) + if item_found is False: + kms_key_record_id, iam_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + kms_key_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + kms_key_record_id, + { + "aws_service": "kms", + "component_state": "implemented", + "account": acct, + "description": "secrets kms key", + "component_region": region, + "component_type": "key", + "component_name": alarm_key_id, + "key_id": alarm_key_id, + "arn": f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", + "date_time": dynamodb.get_date_time(), + }, + ) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", + }, + ) + if item_found is False: + kms_alias_record_id, iam_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + kms_alias_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + kms_alias_record_id, + { + "aws_service": "kms", + "component_state": "implemented", + "account": acct, + "description": "secrets kms alias", + "component_region": region, + "component_type": "alias", + "component_name": ALARM_SNS_KEY_ALIAS, + "key_id": alarm_key_id, + "arn": f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", + "date_time": dynamodb.get_date_time(), + }, + ) + # 4b) SNS topics for alarms sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) @@ -766,6 +836,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): SRA_ALARM_TOPIC_ARN = topic_search # 4c) Cloudwatch metric filters and alarms + # arn:aws:logs:::metric-filter: if DRY_RUN is False: if filter_deploy is True: cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) From 003fbf041aa95cfb7d77f1b78c61c1cd4588ca4c Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 18 Nov 2024 16:10:13 -0700 Subject: [PATCH 178/395] alarms sns topic state record --- .../genai/bedrock_org/lambda/src/app.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 857c417a5..08a87a965 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -31,6 +31,7 @@ # TODO(liamschn): deal with linting failures in pipeline # TODO(liamschn): deal with typechecking/mypy # TODO(liamschn): check for unused parameters +# TODO(liamschn): need to ensure DRY_RUN is false for any dynamodb state table record insertions from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional @@ -834,6 +835,42 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): else: LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") SRA_ALARM_TOPIC_ARN = topic_search + if DRY_RUN is False: + # SNS state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": SRA_ALARM_TOPIC_ARN, + }, + ) + if item_found is False: + sns_record_id, sns_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + sns_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + sns_record_id, + { + "aws_service": "sns", + "component_state": "implemented", + "account": acct, + "description": "alarms sns topic", + "component_region": region, + "component_type": "topic", + "component_name": f"{SOLUTION_NAME}-alarms", + "arn": SRA_ALARM_TOPIC_ARN, + "date_time": dynamodb.get_date_time(), + }, + ) + # 4c) Cloudwatch metric filters and alarms # arn:aws:logs:::metric-filter: From cc8578f34cc67e7e95d632f53375fe6f806c04c2 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 20 Nov 2024 09:37:55 -0700 Subject: [PATCH 179/395] metric filter state record --- .../genai/bedrock_org/lambda/src/app.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 08a87a965..0fd346074 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -873,7 +873,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): # 4c) Cloudwatch metric filters and alarms - # arn:aws:logs:::metric-filter: + metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter}" if DRY_RUN is False: if filter_deploy is True: cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) @@ -900,6 +900,44 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): LIVE_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + + # TODO(liamschn): check to ensure we got a 200 back from the service API call before inserting the dynamodb records + # metric filter state table record + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": metric_filter_arn, + }, + ) + if item_found is False: + filter_record_id, filter_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + filter_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + filter_record_id, + { + "aws_service": "cloudwatch", + "component_state": "implemented", + "account": acct, + "description": "log metric filter", + "component_region": region, + "component_type": "filter", + "component_name": filter, + "arn": metric_filter_arn, + "date_time": dynamodb.get_date_time(), + }, + ) + + else: LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" From 256e90fcaf3eecf70bdedefe7e3718cf672d5a17 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 20 Nov 2024 10:08:55 -0700 Subject: [PATCH 180/395] add kms module tracing --- .../solutions/genai/bedrock_org/lambda/src/sra_kms.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index f502be331..d9d4f39bd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -162,12 +162,15 @@ def create_kms_key(self, kms_client, key_policy, description="Key description"): # kms_client.put_key_policy(KeyId=key_id, PolicyName="default", Policy=json.dumps(key_policy), BypassPolicyLockoutSafetyCheck=False) def create_alias(self, kms_client, alias_name, target_key_id): + self.LOGGER.info(f"Create KMS alias: {alias_name}") kms_client.create_alias(AliasName=alias_name, TargetKeyId=target_key_id) def delete_alias(self, kms_client, alias_name): + self.LOGGER.info(f"Delete KMS alias: {alias_name}") kms_client.delete_alias(AliasName=alias_name) def schedule_key_deletion(self, kms_client, key_id, pending_window_in_days=30): + self.LOGGER.info(f"Schedule deletion of key: {key_id} in {pending_window_in_days} days") kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=pending_window_in_days) def search_key_policies(self, kms_client): From 74ecfdb32b39241a7dce782918223a2ee01fe5d4 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 20 Nov 2024 11:24:57 -0700 Subject: [PATCH 181/395] added state record function --- .../genai/bedrock_org/lambda/src/app.py | 479 +++++------------- 1 file changed, 115 insertions(+), 364 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0fd346074..33f37dedb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -492,6 +492,61 @@ def deploy_state_table(): DRY_RUN_DATA["StateTableCreate"] = f"DRY_RUN: Create the {STATE_TABLE} state table" +def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str, account_id: str, region: str, component_name: str, key_id: str = ""): + """Add a record to the state table + Args: + aws_service (str): aws service + component_state (str): component state + description (str): description of the component + component_type (str): component type + resource_arn (str): arn of the resource + account_id (str): account id + region (str): region + component_name (str): component name + key_id (str): key id + + Returns: + None + """ + LOGGER.info(f"Add a record to the state table for {component_name}") + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + # TODO(liamschn): check to ensure we got a 200 back from the service API call before inserting the dynamodb records + + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": resource_arn, + }, + ) + if item_found is False: + sra_resource_record_id, iam_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + else: + sra_resource_record_id = find_result["record_id"] + + dynamodb.update_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + sra_resource_record_id, + { + "aws_service": aws_service, + "component_state": component_state, + "account": account_id, + "description": description, + "component_region": region, + "component_type": component_type, + "component_name": component_name, + "key_id": key_id, + "arn": resource_arn, + "date_time": dynamodb.get_date_time(), + }, + ) + + def deploy_stage_config_rule_lambda_code(): global DRY_RUN_DATA global LIVE_RUN_DATA @@ -513,6 +568,7 @@ def deploy_stage_config_rule_lambda_code(): LOGGER.info(f"DRY_RUN: Preparing config rules for staging in the {repo.STAGING_UPLOAD_FOLDER} folder") LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") + def deploy_sns_configuration_topics(context): global DRY_RUN_DATA global LIVE_RUN_DATA @@ -539,6 +595,8 @@ def deploy_sns_configuration_topics(context): LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + # SNS State table record: + add_state_table_record("sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration") else: LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") @@ -554,40 +612,8 @@ def deploy_sns_configuration_topics(context): else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search - # SNS State table record: - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": topic_arn, - }, - ) - if item_found is False: - sns_record_id, sns_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - sns_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - sns_record_id, - { - "aws_service": "sns", - "component_state": "implemented", - "account": ACCOUNT, - "description": "configuration topic", - "component_region": sts.HOME_REGION, - "component_type": "topic", - "component_name": f"{SOLUTION_NAME}-configuration", - "arn": topic_arn, - "date_time": dynamodb.get_date_time(), - }, - ) - + # SNS State table record: + add_state_table_record("sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration") return topic_arn @@ -716,6 +742,9 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): LIVE_RUN_DATA["KMSAliasCreate"] = "Created SRA alarm KMS key alias" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # Add KMS resource records to sra state table + add_state_table_record("kms", "implemented", "secrets kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) + add_state_table_record("kms", "implemented", "secrets kms alias", "alias", f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) else: LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") @@ -724,76 +753,10 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" else: LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") - - if DRY_RUN is False: # Add KMS resource records to sra state table - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", - }, - ) - if item_found is False: - kms_key_record_id, iam_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - kms_key_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - kms_key_record_id, - { - "aws_service": "kms", - "component_state": "implemented", - "account": acct, - "description": "secrets kms key", - "component_region": region, - "component_type": "key", - "component_name": alarm_key_id, - "key_id": alarm_key_id, - "arn": f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", - "date_time": dynamodb.get_date_time(), - }, - ) - - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", - }, - ) - if item_found is False: - kms_alias_record_id, iam_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - kms_alias_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - kms_alias_record_id, - { - "aws_service": "kms", - "component_state": "implemented", - "account": acct, - "description": "secrets kms alias", - "component_region": region, - "component_type": "alias", - "component_name": ALARM_SNS_KEY_ALIAS, - "key_id": alarm_key_id, - "arn": f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", - "date_time": dynamodb.get_date_time(), - }, - ) - + add_state_table_record("kms", "implemented", "secrets kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) + add_state_table_record("kms", "implemented", "secrets kms alias", "alias", f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) + # 4b) SNS topics for alarms sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) @@ -818,6 +781,8 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): LIVE_RUN_DATA["SNSAlarmSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + # add SNS state table record + add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, "topic") else: LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") @@ -835,56 +800,24 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): else: LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") SRA_ALARM_TOPIC_ARN = topic_search - if DRY_RUN is False: - # SNS state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": SRA_ALARM_TOPIC_ARN, - }, - ) - if item_found is False: - sns_record_id, sns_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - sns_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - sns_record_id, - { - "aws_service": "sns", - "component_state": "implemented", - "account": acct, - "description": "alarms sns topic", - "component_region": region, - "component_type": "topic", - "component_name": f"{SOLUTION_NAME}-alarms", - "arn": SRA_ALARM_TOPIC_ARN, - "date_time": dynamodb.get_date_time(), - }, - ) - + # add SNS state table record + add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, "topic") # 4c) Cloudwatch metric filters and alarms - metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter}" + # metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter}" if DRY_RUN is False: if filter_deploy is True: cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter} CloudWatch metric filter...") - deploy_metric_filter(filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock", "1") + deploy_metric_filter(region, acct, filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock", "1") LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"DEBUG: Alarm topic ARN: {SRA_ALARM_TOPIC_ARN}") deploy_metric_alarm( + region, + acct, f"{filter}-alarm", f"{filter}-metric alarm", f"{filter}-metric", @@ -901,43 +834,6 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - # TODO(liamschn): check to ensure we got a 200 back from the service API call before inserting the dynamodb records - # metric filter state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": metric_filter_arn, - }, - ) - if item_found is False: - filter_record_id, filter_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - filter_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - filter_record_id, - { - "aws_service": "cloudwatch", - "component_state": "implemented", - "account": acct, - "description": "log metric filter", - "component_region": region, - "component_type": "filter", - "component_name": filter, - "arn": metric_filter_arn, - "date_time": dynamodb.get_date_time(), - }, - ) - - else: LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" @@ -1141,21 +1037,15 @@ def create_event(event, context): # TODO(liamschn): change the code to have the create events call the sns topic (by publishing events for accounts/regions) which calls the lambda for configuration/deployment topic_arn = deploy_sns_configuration_topics(context) - # 3, 4, and 5 handled by SNS + # 3 & 4) Deploy config rules, kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional SNS fanout) accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) - - # 3) Deploy config rules (regional) - # deploy_config_rules(event) create_sns_messages(accounts, regions, topic_arn, event["ResourceProperties"], "configure") - # 4) deploy kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional) - # deploy_metric_filters_and_alarms(event) - # 5) Central CloudWatch Observability (regional) deploy_central_cloudwatch_observability(event) # 6) Cloudwatch dashboard in security account (home region, security account) - # TODO(liamschn): Determine if the dashboard will be created if all the above is done asynchronously + # TODO(liamschn): Determine if the dashboard will be created if all the above is done asynchronously (update: seems to work; confirming - 11/20/24) deploy_cloudwatch_dashboard(event) # End @@ -1573,47 +1463,16 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: role_arn = iam.create_role(rule_name, iam.SRA_TRUST_DOCUMENTS["sra-config-rule"], SOLUTION_NAME)["Role"]["Arn"] CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # add IAM role state table record + add_state_table_record("iam", "implemented", "role for config rule", "role", role_arn, account_id, "Global", rule_name) else: LOGGER.info(f"DRY_RUN: Creating {rule_name} IAM role") else: LOGGER.info(f"{rule_name} IAM role already exists.") role_arn = iam_role_search[1] - - # IAM role state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": role_arn, - }, - ) - if item_found is False: - role_record_id, role_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - role_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - role_record_id, - { - "aws_service": "iam", - "component_state": "implemented", - "account": account_id, - "description": "role for config rule", - "component_region": "Global", - "component_type": "role", - "component_name": rule_name, - "arn": role_arn, - "date_time": dynamodb.get_date_time(), - }, - ) + # add IAM role state table record + add_state_table_record("iam", "implemented", "role for config rule", "role", role_arn, account_id, "Global", rule_name) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ "Statement" @@ -1633,45 +1492,14 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: iam.create_policy(f"{rule_name}-lamdba-basic-execution", iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"], SOLUTION_NAME) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # add IAM policy state table record + add_state_table_record("iam", "implemented", "policy for config rule role", "policy", policy_arn, account_id, "Global", f"{rule_name}-lamdba-basic-execution") else: LOGGER.info(f"DRY _RUN: Creating {rule_name}-lamdba-basic-execution IAM policy in {account_id}...") else: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") - - # IAM policy state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": policy_arn, - }, - ) - if item_found is False: - policy1_record_id, policy1_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - policy1_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - policy1_record_id, - { - "aws_service": "iam", - "component_state": "implemented", - "account": account_id, - "description": "policy for config rule role", - "component_region": "Global", - "component_type": "policy", - "component_name": f"{rule_name}-lamdba-basic-execution", - "arn": policy_arn, - "date_time": dynamodb.get_date_time(), - }, - ) + # add IAM policy state table record + add_state_table_record("iam", "implemented", "policy for config rule role", "policy", policy_arn, account_id, "Global", f"{rule_name}-lamdba-basic-execution") policy_arn2 = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}" iam_policy_search2 = iam.check_iam_policy_exists(policy_arn2) @@ -1681,45 +1509,14 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: iam.create_policy(f"{rule_name}", IAM_POLICY_DOCUMENTS[rule_name], SOLUTION_NAME) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # add IAM policy state table record + add_state_table_record("iam", "implemented", "policy for config rule", "policy", policy_arn2, account_id, "Global", rule_name) else: LOGGER.info(f"DRY _RUN: Creating {rule_name} IAM policy in {account_id}...") else: LOGGER.info(f"{rule_name} IAM policy already exists") - - # IAM policy state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": policy_arn2, - }, - ) - if item_found is False: - policy2_record_id, policy2_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - policy2_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - policy2_record_id, - { - "aws_service": "iam", - "component_state": "implemented", - "account": account_id, - "description": "policy for config rule role", - "component_region": "Global", - "component_type": "policy", - "component_name": f"{rule_name}-lamdba-basic-execution", - "arn": policy_arn2, - "date_time": dynamodb.get_date_time(), - }, - ) + # add IAM policy state table record + add_state_table_record("iam", "implemented", "policy for config rule", "policy", policy_arn2, account_id, "Global", rule_name) policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) if policy_attach_search1 is False: @@ -1791,45 +1588,13 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio SOLUTION_NAME, ) lambda_arn = lambda_create["Configuration"]["FunctionArn"] + # add Lambda state table record + add_state_table_record("lambda", "implemented", "lambda for config rule", "lambda", lambda_arn, account_id, region, rule_name) else: LOGGER.info(f"{rule_name} already exists in {account_id}. Search result: {lambda_function_search}") lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] - - # Lambda state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": lambda_arn, - }, - ) - if item_found is False: - lambda_record_id, lambda_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - lambda_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - lambda_record_id, - { - "aws_service": "lambda", - "component_state": "implemented", - "account": account_id, - "description": "lambda for config rule", - "component_region": region, - "component_type": "lambda", - "component_name": rule_name, - "arn": lambda_arn, - "date_time": dynamodb.get_date_time(), - }, - ) - + # add Lambda state table record + add_state_table_record("lambda", "implemented", "lambda for config rule", "lambda", lambda_arn, account_id, region, rule_name) return lambda_arn @@ -1867,51 +1632,21 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: ) config_rule_search = config.find_config_rule(rule_name) config_rule_arn = config_rule_search[1]["ConfigRules"][0]["ConfigRuleArn"] + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # add Config rule state table record + add_state_table_record("config", "implemented", "config rule", "rule", config_rule_arn, account_id, region, rule_name) else: LOGGER.info(f"DRY_RUN: Creating Config policy permissions for {rule_name} lambda function in {account_id} in {region}...") LOGGER.info(f"DRY_RUN: Creating {rule_name} config rule in {account_id} in {region}...") else: LOGGER.info(f"{rule_name} config rule already exists.") config_rule_arn = config_rule_search[1]["ConfigRules"][0]["ConfigRuleArn"] + # add Config rule state table record + add_state_table_record("config", "implemented", "config rule", "rule", config_rule_arn, account_id, region, rule_name) - # Config rule state table record - # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": config_rule_arn, - }, - ) - if item_found is False: - config_record_id, config_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) - else: - config_record_id = find_result["record_id"] - - dynamodb.update_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - config_record_id, - { - "aws_service": "config", - "component_state": "implemented", - "account": account_id, - "description": "custom config rule", - "component_region": region, - "component_type": "rule", - "component_name": rule_name, - "arn": config_rule_arn, - "date_time": dynamodb.get_date_time(), - }, - ) - - - -def deploy_metric_filter(log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str): +def deploy_metric_filter(region: str, acct: str, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str): """Deploy metric filter. Args: @@ -1922,18 +1657,27 @@ def deploy_metric_filter(log_group_name: str, filter_name: str, filter_pattern: metric_namespace: metric namespace metric_value: metric value """ + metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter_name}" search_metric_filter = cloudwatch.find_metric_filter(log_group_name, filter_name) if search_metric_filter is False: if DRY_RUN is False: LOGGER.info(f"Deploying metric filter {filter_name} to {log_group_name}...") cloudwatch.create_metric_filter(log_group_name, filter_name, filter_pattern, metric_name, metric_namespace, metric_value) + # add metric filter state table record + add_state_table_record("cloudwatch", "implemented", "log metric filter", "filter", metric_filter_arn, acct, region, filter_name) + else: LOGGER.info(f"DRY_RUN: Deploy metric filter {filter_name} to {log_group_name}...") else: LOGGER.info(f"Metric filter {filter_name} already exists.") + # add metric filter state table record + add_state_table_record("cloudwatch", "implemented", "log metric filter", "filter", metric_filter_arn, acct, region, filter_name) + def deploy_metric_alarm( + region: str, + acct: str, alarm_name: str, alarm_description: str, metric_name: str, @@ -1949,6 +1693,8 @@ def deploy_metric_alarm( """Deploy metric alarm. Args: + region: region + acct: account ID alarm_name: alarm name alarm_description: alarm description metric_name: metric name @@ -1961,6 +1707,7 @@ def deploy_metric_alarm( metric_treat_missing_data: metric treat missing data alarm_actions: alarm actions """ + alarm_arn = f"arn:aws:cloudwatch:{region}:{acct}:alarm:{alarm_name}" search_metric_alarm = cloudwatch.find_metric_alarm(alarm_name) if search_metric_alarm is False: LOGGER.info(f"Deploying metric alarm {alarm_name}...") @@ -1978,10 +1725,14 @@ def deploy_metric_alarm( metric_treat_missing_data, alarm_actions, ) + # add metric alarm state table record + add_state_table_record("cloudwatch", "implemented", "cloudwatch metric alarm", "alarm", alarm_arn, acct, region, alarm_name) else: LOGGER.info(f"DRY_RUN: Deploying metric alarm {alarm_name}...") else: LOGGER.info(f"Metric alarm {alarm_name} already exists.") + # add metric alarm state table record + add_state_table_record("cloudwatch", "implemented", "cloudwatch metric alarm", "alarm", alarm_arn, acct, region, alarm_name) def lambda_handler(event, context): From 2c92765ba4d1ca5c611d0696cff6e49f7422e5f0 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 20 Nov 2024 16:11:07 -0700 Subject: [PATCH 182/395] sink/link state records --- .../genai/bedrock_org/lambda/src/app.py | 19 +++++++++++++++++-- .../bedrock_org/lambda/src/sra_dynamodb.py | 6 ++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 33f37dedb..40e472900 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -865,12 +865,16 @@ def deploy_central_cloudwatch_observability(event): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LIVE_RUN_DATA["OAMSinkCreate"] = "Created CloudWatch observability access manager sink" + # add OAM sink state table record + add_state_table_record("oam", "implemented", "oam sink", "sink", oam_sink_arn, SECURITY_ACCOUNT, sts.HOME_REGION, "oam_sink") else: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink not found, creating...") DRY_RUN_DATA["OAMSinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager sink" else: oam_sink_arn = search_oam_sink[1] LOGGER.info(f"CloudWatch observability access manager sink found: {oam_sink_arn}") + # add OAM sink state table record + add_state_table_record("oam", "implemented", "oam sink", "sink", oam_sink_arn, SECURITY_ACCOUNT, sts.HOME_REGION, "oam_sink") # 5b) OAM Sink policy in security account cloudwatch.SINK_POLICY = CLOUDWATCH_OAM_SINK_POLICY["sra-oam-sink-policy"] @@ -924,15 +928,21 @@ def deploy_central_cloudwatch_observability(event): f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role..." ) if DRY_RUN is False: - iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) + xacct_role = iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) + xacct_role_arn = xacct_role["Role"]["Arn"] LIVE_RUN_DATA["OAMCrossAccountRoleCreate"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + # add cross account role state table record + add_state_table_record("iam", "implemented", "cross account sharing role", "role", xacct_role_arn, bedrock_account, iam.get_iam_global_region(), cloudwatch.CROSS_ACCOUNT_ROLE_NAME) else: DRY_RUN_DATA["OAMCrossAccountRoleCreate"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" else: LOGGER.info(f"CloudWatch observability access manager cross-account role found: {cloudwatch.CROSS_ACCOUNT_ROLE_NAME}") + xacct_role_arn = search_iam_role[1] + # add cross account role state table record + add_state_table_record("iam", "implemented", "cross account sharing role", "role", xacct_role_arn, bedrock_account, iam.get_iam_global_region(), cloudwatch.CROSS_ACCOUNT_ROLE_NAME) # 5d) Attach managed policies to CloudWatch-CrossAccountSharingRole IAM role cross_account_policies = [ @@ -963,16 +973,21 @@ def deploy_central_cloudwatch_observability(event): if search_oam_link[0] is False: if DRY_RUN is False: LOGGER.info("CloudWatch observability access manager link not found, creating...") - cloudwatch.create_oam_link(oam_sink_arn) + oam_link_arn = cloudwatch.create_oam_link(oam_sink_arn) LIVE_RUN_DATA["OAMLinkCreate"] = "Created CloudWatch observability access manager link" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info("Created CloudWatch observability access manager link") + # add OAM link state table record + add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") else: LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") DRY_RUN_DATA["OAMLinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager link" else: LOGGER.info("CloudWatch observability access manager link found") + oam_link_arn = search_oam_link[1] + # add OAM link state table record + add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") def deploy_cloudwatch_dashboard(event): global DRY_RUN_DATA diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index ed2ad131b..d4d6fedc4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -202,3 +202,9 @@ def get_resources_for_solutions_by_account(self, table_name, dynamodb_resource, self.LOGGER.info(f"response: {response}") query_results[solution] = response return query_results + + def delete_item(self, table_name, dynamodb_resource, solution_name, record_id): + self.LOGGER.info(f"Deleting {record_id} from {table_name} dynamodb table") + table = dynamodb_resource.Table(table_name) + response = table.delete_item(Key={"solution_name": solution_name, "record_id": record_id}) + return response \ No newline at end of file From 61b3570b82b3d851ec95c2c017319974f744fe46 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 00:20:13 -0700 Subject: [PATCH 183/395] update description for record --- .../solutions/genai/bedrock_org/lambda/src/app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 40e472900..64436ff15 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -743,8 +743,8 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 # Add KMS resource records to sra state table - add_state_table_record("kms", "implemented", "secrets kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) - add_state_table_record("kms", "implemented", "secrets kms alias", "alias", f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) + add_state_table_record("kms", "implemented", "alarms sns kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) + add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) else: LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") @@ -754,8 +754,8 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): else: LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") # Add KMS resource records to sra state table - add_state_table_record("kms", "implemented", "secrets kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) - add_state_table_record("kms", "implemented", "secrets kms alias", "alias", f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) + add_state_table_record("kms", "implemented", "alarms sns kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) + add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) # 4b) SNS topics for alarms From c769ec88aefcbae8f75a3d6b12fed547180f3961 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 10:08:25 -0700 Subject: [PATCH 184/395] removal of state records --- .../genai/bedrock_org/lambda/src/app.py | 91 ++++++++++++++++--- .../bedrock_org/lambda/src/sra_config.py | 13 ++- .../bedrock_org/lambda/src/sra_dynamodb.py | 11 +++ .../genai/bedrock_org/lambda/src/sra_kms.py | 8 +- 4 files changed, 103 insertions(+), 20 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 64436ff15..3fb9de69d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -547,6 +547,36 @@ def add_state_table_record(aws_service: str, component_state: str, description: ) +def remove_state_table_record(resource_arn): + """Remove a record from the state table + + Args: + resource_arn (str): arn of the resource + + Returns: + response: response from dynamodb delete_item + """ + # TODO(liamschn): move dynamodb resource to the dynamo class object/module + dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + LOGGER.info(f"Searching for {resource_arn} in {STATE_TABLE} dynamodb table...") + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": resource_arn, + }, + ) + if item_found is False: + LOGGER.info(f"Record not found in {STATE_TABLE} dynamodb table") + else: + sra_resource_record_id = find_result["record_id"] + LOGGER.info(f"Found record id {sra_resource_record_id}") + LOGGER.info(f"Removing {sra_resource_record_id} from {STATE_TABLE} dynamodb table...") + response = dynamodb.delete_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME, sra_resource_record_id) + return response + + def deploy_stage_config_rule_lambda_code(): global DRY_RUN_DATA global LIVE_RUN_DATA @@ -717,7 +747,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): LOGGER.info(f"{filter} filter not requested for {acct}. Skipping...") continue kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) - search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is False: LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section @@ -782,7 +812,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 # add SNS state table record - add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, "topic") + add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms") else: LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") @@ -801,7 +831,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") SRA_ALARM_TOPIC_ARN = topic_search # add SNS state table record - add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, "topic") + add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms") # 4c) Cloudwatch metric filters and alarms # metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter}" @@ -1107,6 +1137,7 @@ def delete_event(event, context): # 1a) Delete configuration topic sns.SNS_CLIENT = sts.assume_role(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "sns", sts.HOME_REGION) topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") + # TODO(liamschn): this will be a mypy error: need to have topic_search (sns.find_sns_topic) return a str, not None if topic_search is not None: if DRY_RUN is False: LOGGER.info(f"Deleting {SOLUTION_NAME}-configuration SNS topic") @@ -1114,6 +1145,7 @@ def delete_event(event, context): sns.delete_sns_topic(topic_search) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(topic_search) else: LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-configuration SNS topic" @@ -1146,6 +1178,7 @@ def delete_event(event, context): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 LOGGER.info("Deleted CloudWatch observability access manager link") + remove_state_table_record(search_oam_link[1]) else: LOGGER.info("DRY_RUN: CloudWatch observability access manager link found, deleting...") DRY_RUN_DATA["OAMLinkDelete"] = "DRY_RUN: Delete CloudWatch observability access manager link" @@ -1186,6 +1219,7 @@ def delete_event(event, context): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 LOGGER.info(f"Deleted {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + remove_state_table_record(search_iam_role[1]) else: LOGGER.info(f"DRY_RUN: Deleting {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") DRY_RUN_DATA["OAMCrossAccountRoleDelete"] = f"DRY_RUN: Delete {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" @@ -1202,6 +1236,7 @@ def delete_event(event, context): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 LOGGER.info("Deleted CloudWatch observability access manager sink") + remove_state_table_record(search_oam_sink[1]) else: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink found, deleting...") DRY_RUN_DATA["OAMSinkDelete"] = "DRY_RUN: Delete CloudWatch observability access manager sink" @@ -1213,20 +1248,27 @@ def delete_event(event, context): filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) for acct in filter_accounts: for region in filter_regions: - # 3a) Delete KMS key (schedule deletion) + # 3a) Delete KMS key (schedule deletion) and delete kms alias kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) - search_alarm_kms_key, alarm_key_alias, alarm_key_id = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is True: if DRY_RUN is False: LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key") - LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key" kms.delete_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + remove_state_table_record(alarm_key_arn) + kms.schedule_key_deletion(kms.KMS_CLIENT, alarm_key_id) + LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Scheduled deletion of {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + kms_key_arn = f"arn:{sts.PARTITION}:kms:{region}:{acct}:key/{alarm_key_id}" + remove_state_table_record(kms_key_arn) + else: LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key") DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key" @@ -1244,8 +1286,12 @@ def delete_event(event, context): search_metric_alarm = cloudwatch.find_metric_alarm(f"{filter}-alarm") if search_metric_alarm is True: cloudwatch.delete_metric_alarm(f"{filter}-alarm") + LIVE_RUN_DATA[f"{filter}-alarm_CloudWatchDelete"] = f"Deleted {filter}-alarm CloudWatch metric alarm" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleted {filter}-alarm CloudWatch metric alarm") + metric_alarm_arn = f"arn:{sts.PARTITION}:cloudwatch:{region}:{acct}:alarm:{filter}-alarm" + remove_state_table_record(metric_alarm_arn) else: LOGGER.info(f"{filter}-alarm CloudWatch metric alarm does not exist.") @@ -1255,17 +1301,23 @@ def delete_event(event, context): search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) if search_metric_filter is True: cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter) + LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleted {filter} CloudWatch metric filter") + metric_filter_arn = f"arn:{sts.PARTITION}:logs:{region}:{acct}:metric-filter:{filter}" + remove_state_table_record(metric_filter_arn) + else: LOGGER.info(f"{filter} CloudWatch metric filter does not exist.") else: - LOGGER.info(f"DRY_RUN: Deleting {filter} CloudWatch metric filter") + LOGGER.info(f"DRY_RUN: Delete {filter} CloudWatch metric filter") DRY_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter} CloudWatch metric filter" # 3d) Delete the alarm topic sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) + # TODO(liamschn): this will be a mypy error - need to have alarm_topic_search (sns.find_sns_topic) return string, not None alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) if alarm_topic_search is not None: if DRY_RUN is False: @@ -1274,21 +1326,22 @@ def delete_event(event, context): sns.delete_sns_topic(alarm_topic_search) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleted {SOLUTION_NAME}-alarms SNS topic") + remove_state_table_record(alarm_topic_search) else: - LOGGER.info(f"DRY_RUN: Deleting {SOLUTION_NAME}-alarms SNS topic") + LOGGER.info(f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic") DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") # 4) Delete config rules - # TODO(liamschn): deal with invalid rule names - # TODO(liamschn): deal with invalid account IDs + # TODO(liamschn): deal with invalid rule names? + # TODO(liamschn): deal with invalid account IDs? accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) for prop in event["ResourceProperties"]: if prop.startswith("SRA-BEDROCK-CHECK-"): rule_name: str = prop LOGGER.info(f"Delete operation: retrieving {rule_name} parameters...") - # rule_deploy, rule_input_params = get_rule_params(rule_name, event["ResourceProperties"]) rule_name = rule_name.lower() LOGGER.info(f"Delete operation: examining {rule_name} resources...") @@ -1304,15 +1357,17 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(config_rule_search[1]["ConfigRule"]["ConfigRuleArn"]) else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" else: LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" # 4b) Delete lambda for custom config rule lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) lambda_search = lambdas.find_lambda_function(rule_name) + # TODO(liamschn): this will be a mypy error - need to have lambda_search return string, not None if lambda_search is not None: if DRY_RUN is False: LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") @@ -1320,6 +1375,7 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(lambda_search["Configuration"]["FunctionArn"]) else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} lambda function" @@ -1356,11 +1412,14 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(policy_arn) else: LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") DRY_RUN_DATA[ f"{rule_name}_{acct}_{region}_PolicyDelete" ] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" + else: + LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region} does not exist.") policy_arn2 = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}" LOGGER.info(f"Policy ARN: {policy_arn2}") @@ -1372,11 +1431,14 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(policy_arn2) else: LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}") DRY_RUN_DATA[ f"{rule_name}_{acct}_{region}_PolicyDelete" ] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" + else: + LOGGER.info(f"{rule_name} IAM policy for account {acct} in {region} does not exist.") # 7) Delete IAM execution role for custom config rule lambda role_search = iam.check_iam_role_exists(rule_name) @@ -1387,6 +1449,7 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(role_search[1]) else: LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_RoleDelete"] = f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}" @@ -1672,7 +1735,7 @@ def deploy_metric_filter(region: str, acct: str, log_group_name: str, filter_nam metric_namespace: metric namespace metric_value: metric value """ - metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter_name}" + metric_filter_arn = f"arn:{sts.PARTITION}:logs:{region}:{acct}:metric-filter:{filter_name}" search_metric_filter = cloudwatch.find_metric_filter(log_group_name, filter_name) if search_metric_filter is False: if DRY_RUN is False: @@ -1722,7 +1785,7 @@ def deploy_metric_alarm( metric_treat_missing_data: metric treat missing data alarm_actions: alarm actions """ - alarm_arn = f"arn:aws:cloudwatch:{region}:{acct}:alarm:{alarm_name}" + alarm_arn = f"arn:{sts.PARTITION}:cloudwatch:{region}:{acct}:alarm:{alarm_name}" search_metric_alarm = cloudwatch.find_metric_alarm(alarm_name) if search_metric_alarm is False: LOGGER.info(f"Deploying metric alarm {alarm_name}...") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 5a5cb780a..ce058b3d2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -90,8 +90,17 @@ def put_organization_config_rule(self): return response def find_config_rule(self, rule_name): - """Get Config Rule.""" - # Get the Config Rule + """Get config rule + + Args: + rule_name (str): Config rule name + + Raises: + ValueError: If the config rule is not found + + Returns: + tuple[bool, dict]: True if the config rule is found, False if not, and the response + """ try: response = self.CONFIG_CLIENT.describe_config_rules( diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index d4d6fedc4..d1e22d34f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -204,6 +204,17 @@ def get_resources_for_solutions_by_account(self, table_name, dynamodb_resource, return query_results def delete_item(self, table_name, dynamodb_resource, solution_name, record_id): + """Delete an item from the dynamodb table + + Args: + table_name (str): dynamodb table name + dynamodb_resource (dynamodb_resource): dynamodb resource + solution_name (str): solution name + record_id (str): record id + + Returns: + response: response from dynamodb delete_item + """ self.LOGGER.info(f"Deleting {record_id} from {table_name} dynamodb table") table = dynamodb_resource.Table(table_name) response = table.delete_item(Key={"solution_name": solution_name, "record_id": record_id}) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index d9d4f39bd..f610e4b0c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -213,7 +213,7 @@ def check_alias_exists(self, kms_client, alias_name): alias_name (str): alias name to check for Returns: - tuple (bool, str, str): (exists, alias_name, target_key_id) + tuple: True if alias exists, False otherwise, alias name, target key id, and alias arn """ self.LOGGER.info(f"Checking alias: {alias_name}") try: @@ -223,8 +223,8 @@ def check_alias_exists(self, kms_client, alias_name): self.LOGGER.info(f"Alias: {alias}") if alias["AliasName"] == alias_name: self.LOGGER.info(f"Found alias: {alias}") - return True, alias["AliasName"], alias["TargetKeyId"] - return False, "", "" + return True, alias["AliasName"], alias["TargetKeyId"], alias["AliasArn"] + return False, "", "", "" except Exception as e: self.LOGGER.info(f"Unexpected error: {e}") - return False, "", "" + return False, "", "", "" From aa2d4965f0dad7b241ec899dc34fca5fe641bf66 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 10:29:56 -0700 Subject: [PATCH 185/395] update config rule search --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 3fb9de69d..0f8a9099a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1357,7 +1357,7 @@ def delete_event(event, context): LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - remove_state_table_record(config_rule_search[1]["ConfigRule"]["ConfigRuleArn"]) + remove_state_table_record(config_rule_search[1]["ConfigRules"][0]["ConfigRuleArn"]) else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" From ad42f90f3cc08e407bd4fbb2238586a64b3e3d2b Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 10:52:19 -0700 Subject: [PATCH 186/395] added todo comment --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0f8a9099a..e961062a0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1073,6 +1073,7 @@ def create_event(event, context): event_info = {"Event": event} LOGGER.info(event_info) # Deploy state table + # TODO(liamschn): need to ensure the solution name for the state table record is sra-common-prerequisites (if it is created here), not bedrock deploy_state_table() # 1) Stage config rule lambda code (global/home region) From f7ea39dc8b7f6c771dde531c7d776977617281df Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 13:09:17 -0700 Subject: [PATCH 187/395] need to use all bedrock accts and regions for delete --- .../solutions/genai/bedrock_org/lambda/src/app.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index e961062a0..868d3eccb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1245,10 +1245,11 @@ def delete_event(event, context): LOGGER.info("CloudWatch observability access manager sink not found") # 3) Delete metric alarms and filters + accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) for filter in CLOUDWATCH_METRIC_FILTERS: - filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) - for acct in filter_accounts: - for region in filter_regions: + # filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) + for acct in accounts: + for region in regions: # 3a) Delete KMS key (schedule deletion) and delete kms alias kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") From 33884072b67bfe405e42c5b04ad65dfdbe287d16 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 13:36:35 -0700 Subject: [PATCH 188/395] fix remove state table record function --- .../solutions/genai/bedrock_org/lambda/src/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 868d3eccb..1b894a661 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -569,11 +569,12 @@ def remove_state_table_record(resource_arn): ) if item_found is False: LOGGER.info(f"Record not found in {STATE_TABLE} dynamodb table") + response = {} else: sra_resource_record_id = find_result["record_id"] LOGGER.info(f"Found record id {sra_resource_record_id}") - LOGGER.info(f"Removing {sra_resource_record_id} from {STATE_TABLE} dynamodb table...") - response = dynamodb.delete_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME, sra_resource_record_id) + LOGGER.info(f"Removing {sra_resource_record_id} from {STATE_TABLE} dynamodb table...") + response = dynamodb.delete_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME, sra_resource_record_id) return response From 52b45a2e774eb52cfd00570ffd8f3dcaf460b405 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 13:39:35 -0700 Subject: [PATCH 189/395] fix kms key alias Arn format --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 1b894a661..c3bf1aa9f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -775,7 +775,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 # Add KMS resource records to sra state table add_state_table_record("kms", "implemented", "alarms sns kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) - add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) + add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) else: LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") @@ -786,7 +786,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") # Add KMS resource records to sra state table add_state_table_record("kms", "implemented", "alarms sns kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) - add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) + add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) # 4b) SNS topics for alarms From 019007c918fc64b1c340f1ea9dfadb6314840b6a Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 13:40:29 -0700 Subject: [PATCH 190/395] change docstring; update return val --- .../bedrock_org/lambda/src/sra_cloudwatch.py | 30 +++++++++---------- .../bedrock_org/lambda/src/sra_config.py | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index c6e029ab5..6ae2a810e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -247,21 +247,21 @@ def create_oam_sink(self, sink_name: str) -> str: self.LOGGER.error(f"{self.UNEXPECTED} error: {e}") raise ValueError(f"Unexpected error executing Lambda function. {e}") from None - def delete_oam_sink(self, sink_arn: str) -> None: - """Delete the Observability Access Manager sink for SRA in the organization. - - Args: - sink_arn (str): ARN of the sink - - Returns: - None - """ - try: - self.CWOAM_CLIENT.delete_sink(Identifier=sink_arn) - self.LOGGER.info(f"Observability access manager sink {sink_arn} deleted") - except ClientError as e: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + # def delete_oam_sink(self, sink_arn: str) -> None: + # """Delete the Observability Access Manager sink for SRA in the organization. + + # Args: + # sink_arn (str): ARN of the sink + + # Returns: + # None + # """ + # try: + # self.CWOAM_CLIENT.delete_sink(Identifier=sink_arn) + # self.LOGGER.info(f"Observability access manager sink {sink_arn} deleted") + # except ClientError as e: + # self.LOGGER.info(self.UNEXPECTED) + # raise ValueError(f"Unexpected error executing Lambda function. {e}") from None def find_oam_sink_policy(self, sink_arn: str) -> tuple[bool, dict]: """Check if the Observability Access Manager sink policy for SRA in the organization exists. diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index ce058b3d2..b0cd62764 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -111,7 +111,7 @@ def find_config_rule(self, rule_name): except ClientError as e: if e.response["Error"]["Code"] == "NoSuchConfigRuleException": self.LOGGER.info(f"No such config rule: {rule_name}") - return False, None + return False, {} else: self.LOGGER.info(f"Unexpected error: {e}") raise e From b429242b5f2622d6713c96d278714f6fb0ca8cf0 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 13:55:10 -0700 Subject: [PATCH 191/395] fix delete logic --- .../solutions/genai/bedrock_org/lambda/src/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index c3bf1aa9f..bd9b40b1b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1246,11 +1246,11 @@ def delete_event(event, context): LOGGER.info("CloudWatch observability access manager sink not found") # 3) Delete metric alarms and filters - accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) + # accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) for filter in CLOUDWATCH_METRIC_FILTERS: - # filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event) - for acct in accounts: - for region in regions: + filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event["ResourceProperties"]) + for acct in filter_accounts: + for region in filter_regions: # 3a) Delete KMS key (schedule deletion) and delete kms alias kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") @@ -1301,9 +1301,9 @@ def delete_event(event, context): # 3c) Delete the CloudWatch metric filter LOGGER.info(f"Deleting {filter} CloudWatch metric filter") LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" - search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) + search_metric_filter = cloudwatch.find_metric_filter(event["ResourceProperties"][filter.upper()]["log_group_name"], filter) if search_metric_filter is True: - cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter) + cloudwatch.delete_metric_filter(event["ResourceProperties"][filter.upper()]["log_group_name"], filter) LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 From 0fc4b0b9175218ca7fa11353a9862fc06a3603ba Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 14:01:44 -0700 Subject: [PATCH 192/395] more fixes to delete logic --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index bd9b40b1b..c1e132df9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1301,9 +1301,9 @@ def delete_event(event, context): # 3c) Delete the CloudWatch metric filter LOGGER.info(f"Deleting {filter} CloudWatch metric filter") LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" - search_metric_filter = cloudwatch.find_metric_filter(event["ResourceProperties"][filter.upper()]["log_group_name"], filter) + search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) if search_metric_filter is True: - cloudwatch.delete_metric_filter(event["ResourceProperties"][filter.upper()]["log_group_name"], filter) + cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter) LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 From 4e07da5aa80440823626b540297d78c38b8f0a24 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 14:38:43 -0700 Subject: [PATCH 193/395] change state table solution --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index c1e132df9..aeb15df24 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -458,19 +458,19 @@ def deploy_state_table(): item_found, find_result = dynamodb.find_item( STATE_TABLE, dynamodb_resource, - SOLUTION_NAME, + "sra-common-prerequisites", { "arn": f"arn:aws:dynamodb:{sts.HOME_REGION}:{ssm_params.SRA_SECURITY_ACCT}:table/{STATE_TABLE}", }, ) if item_found is False: - dynamodb_record_id, dynamodb_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + dynamodb_record_id, dynamodb_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, "sra-common-prerequisites") else: dynamodb_record_id = find_result["record_id"] dynamodb.update_item( STATE_TABLE, dynamodb_resource, - SOLUTION_NAME, + "sra-common-prerequisites", dynamodb_record_id, { "aws_service": "dynamodb", From 9d66c1d2099dcc638e70b6f1be229202c67a1484 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 18:02:12 -0700 Subject: [PATCH 194/395] making lambda summary message accurate --- .../genai/bedrock_org/lambda/src/app.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index aeb15df24..a253e29a4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -956,21 +956,21 @@ def deploy_central_cloudwatch_observability(event): search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) if search_iam_role[0] is False: LOGGER.info( - f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role..." + f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}..." ) if DRY_RUN is False: xacct_role = iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) xacct_role_arn = xacct_role["Role"]["Arn"] - LIVE_RUN_DATA["OAMCrossAccountRoleCreate"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + LIVE_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") # add cross account role state table record add_state_table_record("iam", "implemented", "cross account sharing role", "role", xacct_role_arn, bedrock_account, iam.get_iam_global_region(), cloudwatch.CROSS_ACCOUNT_ROLE_NAME) else: - DRY_RUN_DATA["OAMCrossAccountRoleCreate"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + DRY_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" else: - LOGGER.info(f"CloudWatch observability access manager cross-account role found: {cloudwatch.CROSS_ACCOUNT_ROLE_NAME}") + LOGGER.info(f"CloudWatch observability access manager {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} cross-account role found in {bedrock_account}") xacct_role_arn = search_iam_role[1] # add cross account role state table record add_state_table_record("iam", "implemented", "cross account sharing role", "role", xacct_role_arn, bedrock_account, iam.get_iam_global_region(), cloudwatch.CROSS_ACCOUNT_ROLE_NAME) @@ -984,19 +984,19 @@ def deploy_central_cloudwatch_observability(event): for policy_arn in cross_account_policies: search_attached_policies = iam.check_iam_policy_attached(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) if search_attached_policies is False: - LOGGER.info(f"Attaching {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") + LOGGER.info(f"Attaching {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}...") if DRY_RUN is False: iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) LIVE_RUN_DATA[ - "OAMCrossAccountRolePolicyAttach" + f"OAMCrossAccountRolePolicyAttach_{bedrock_account}" ] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") + LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}") else: DRY_RUN_DATA[ - "OAMCrossAccountRolePolicyAttach" - ] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + f"OAMCrossAccountRolePolicyAttach_{bedrock_account}" + ] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" # 5e) OAM link in bedrock account cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) @@ -1005,7 +1005,7 @@ def deploy_central_cloudwatch_observability(event): if DRY_RUN is False: LOGGER.info("CloudWatch observability access manager link not found, creating...") oam_link_arn = cloudwatch.create_oam_link(oam_sink_arn) - LIVE_RUN_DATA["OAMLinkCreate"] = "Created CloudWatch observability access manager link" + LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = f"Created CloudWatch observability access manager link in {bedrock_account}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info("Created CloudWatch observability access manager link") @@ -1013,9 +1013,9 @@ def deploy_central_cloudwatch_observability(event): add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") else: LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") - DRY_RUN_DATA["OAMLinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager link" + DRY_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = f"DRY_RUN: Create CloudWatch observability access manager link in {bedrock_account}" else: - LOGGER.info("CloudWatch observability access manager link found") + LOGGER.info(f"CloudWatch observability access manager link found in {bedrock_account}") oam_link_arn = search_oam_link[1] # add OAM link state table record add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") From 54a4a1fbe9ef2f70a6211d8c8dd432c1cb026145 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 18:08:26 -0700 Subject: [PATCH 195/395] making lambda summary message accurate again --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a253e29a4..e7860cd0f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1005,7 +1005,7 @@ def deploy_central_cloudwatch_observability(event): if DRY_RUN is False: LOGGER.info("CloudWatch observability access manager link not found, creating...") oam_link_arn = cloudwatch.create_oam_link(oam_sink_arn) - LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = f"Created CloudWatch observability access manager link in {bedrock_account}" + LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}_{bedrock_region}"] = f"Created CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info("Created CloudWatch observability access manager link") @@ -1013,9 +1013,9 @@ def deploy_central_cloudwatch_observability(event): add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") else: LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") - DRY_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = f"DRY_RUN: Create CloudWatch observability access manager link in {bedrock_account}" + DRY_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = f"DRY_RUN: Create CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" else: - LOGGER.info(f"CloudWatch observability access manager link found in {bedrock_account}") + LOGGER.info(f"CloudWatch observability access manager link found in {bedrock_account} in {bedrock_region}") oam_link_arn = search_oam_link[1] # add OAM link state table record add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") From edb1185d7ee99e8f2c557a93d0f8035b1f44d31f Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 19:08:30 -0700 Subject: [PATCH 196/395] add CFN_RESPONSE_DATA debug tracing --- .../solutions/genai/bedrock_org/lambda/src/app.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index e7860cd0f..9e95603e3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -99,8 +99,6 @@ def load_sra_cloudwatch_dashboard() -> dict: # resources_deployed: int - number of resources deployed # configuration_changes: int - number of configuration changes CFN_RESPONSE_DATA: dict = {"dry_run": True, "deployment_info": {"action_count": 0, "resources_deployed": 0, "configuration_changes": 0}} -# TODO(liamschn): Consider adding "regions_targeted": int and "accounts_targeted": in to "deployment_info" of CFN_RESPONSE_DATA - # dry run global variables DRY_RUN: bool = True @@ -1073,27 +1071,32 @@ def create_event(event, context): event_info = {"Event": event} LOGGER.info(event_info) + LOGGER.info(f"CFN_RESPONSE_DATA START: {CFN_RESPONSE_DATA}") # Deploy state table # TODO(liamschn): need to ensure the solution name for the state table record is sra-common-prerequisites (if it is created here), not bedrock deploy_state_table() + LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_state_table: {CFN_RESPONSE_DATA}") # 1) Stage config rule lambda code (global/home region) deploy_stage_config_rule_lambda_code() + LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_stage_config_rule_lambda_code: {CFN_RESPONSE_DATA}") # 2) SNS topics for fanout configuration operations (global/home region) - # TODO(liamschn): change the code to have the create events call the sns topic (by publishing events for accounts/regions) which calls the lambda for configuration/deployment topic_arn = deploy_sns_configuration_topics(context) + LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_sns_configuration_topics: {CFN_RESPONSE_DATA}") # 3 & 4) Deploy config rules, kms cmk, cloudwatch metric filters, and SNS topics for alarms (regional SNS fanout) accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) create_sns_messages(accounts, regions, topic_arn, event["ResourceProperties"], "configure") + LOGGER.info(f"CFN_RESPONSE_DATA POST create_sns_messages: {CFN_RESPONSE_DATA}") # 5) Central CloudWatch Observability (regional) deploy_central_cloudwatch_observability(event) + LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_central_cloudwatch_observability: {CFN_RESPONSE_DATA}") # 6) Cloudwatch dashboard in security account (home region, security account) - # TODO(liamschn): Determine if the dashboard will be created if all the above is done asynchronously (update: seems to work; confirming - 11/20/24) deploy_cloudwatch_dashboard(event) + LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_cloudwatch_dashboard: {CFN_RESPONSE_DATA}") # End # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message From 5bd9c80e7e2382bc3dacb6a4756a04b77a2f7af3 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 19:21:50 -0700 Subject: [PATCH 197/395] add more CFN_RESPONSE_DATA debug tracing --- .../solutions/genai/bedrock_org/lambda/src/app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 9e95603e3..02c09e4e5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -892,6 +892,7 @@ def deploy_central_cloudwatch_observability(event): oam_sink_arn = cloudwatch.create_oam_sink(cloudwatch.SINK_NAME) LOGGER.info(f"CloudWatch observability access manager sink created: {oam_sink_arn}") CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_oam_sink: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LIVE_RUN_DATA["OAMSinkCreate"] = "Created CloudWatch observability access manager sink" # add OAM sink state table record @@ -920,6 +921,8 @@ def deploy_central_cloudwatch_observability(event): LOGGER.info("CloudWatch observability access manager sink policy created") LIVE_RUN_DATA["OAMSinkPolicyCreate"] = "Created CloudWatch observability access manager sink policy" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - put_oam_sink_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink policy not found, creating...") @@ -933,6 +936,8 @@ def deploy_central_cloudwatch_observability(event): LOGGER.info("CloudWatch observability access manager sink policy updated") LIVE_RUN_DATA["OAMSinkPolicyUpdate"] = "Updated CloudWatch observability access manager sink policy" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - COMPARE put_oam_sink_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink policy needs updating...") @@ -961,6 +966,8 @@ def deploy_central_cloudwatch_observability(event): xacct_role_arn = xacct_role["Role"]["Arn"] LIVE_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_role: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") # add cross account role state table record @@ -989,6 +996,8 @@ def deploy_central_cloudwatch_observability(event): f"OAMCrossAccountRolePolicyAttach_{bedrock_account}" ] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - attach_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}") else: @@ -1005,6 +1014,8 @@ def deploy_central_cloudwatch_observability(event): oam_link_arn = cloudwatch.create_oam_link(oam_sink_arn) LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}_{bedrock_region}"] = f"Created CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_oam_link: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info("Created CloudWatch observability access manager link") # add OAM link state table record From 2671060660ebe354e9447e0a825e1747991f525a Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 20:00:57 -0700 Subject: [PATCH 198/395] fixed action summary --- .../solutions/genai/bedrock_org/lambda/src/app.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 02c09e4e5..e1c7e9e3a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -892,7 +892,7 @@ def deploy_central_cloudwatch_observability(event): oam_sink_arn = cloudwatch.create_oam_sink(cloudwatch.SINK_NAME) LOGGER.info(f"CloudWatch observability access manager sink created: {oam_sink_arn}") CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_oam_sink: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_oam_sink: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LIVE_RUN_DATA["OAMSinkCreate"] = "Created CloudWatch observability access manager sink" # add OAM sink state table record @@ -921,7 +921,7 @@ def deploy_central_cloudwatch_observability(event): LOGGER.info("CloudWatch observability access manager sink policy created") LIVE_RUN_DATA["OAMSinkPolicyCreate"] = "Created CloudWatch observability access manager sink policy" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - put_oam_sink_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - put_oam_sink_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: @@ -936,7 +936,7 @@ def deploy_central_cloudwatch_observability(event): LOGGER.info("CloudWatch observability access manager sink policy updated") LIVE_RUN_DATA["OAMSinkPolicyUpdate"] = "Updated CloudWatch observability access manager sink policy" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - COMPARE put_oam_sink_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - COMPARE put_oam_sink_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: @@ -966,7 +966,7 @@ def deploy_central_cloudwatch_observability(event): xacct_role_arn = xacct_role["Role"]["Arn"] LIVE_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_role: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_role: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") @@ -993,10 +993,10 @@ def deploy_central_cloudwatch_observability(event): if DRY_RUN is False: iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) LIVE_RUN_DATA[ - f"OAMCrossAccountRolePolicyAttach_{bedrock_account}" + f"OamXacctRolePolicyAttach_{policy_arn.split("/")[1]}_{bedrock_account}" ] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - attach_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - attach_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}") @@ -1014,7 +1014,7 @@ def deploy_central_cloudwatch_observability(event): oam_link_arn = cloudwatch.create_oam_link(oam_sink_arn) LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}_{bedrock_region}"] = f"Created CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_oam_link: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") + # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_oam_link: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info("Created CloudWatch observability access manager link") From e21d21dbeb73f11a6c22fe66342e1e55a3522f4b Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 20:05:34 -0700 Subject: [PATCH 199/395] error handling for state table record removal --- .../genai/bedrock_org/lambda/src/app.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index e1c7e9e3a..c31da4b3c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -557,22 +557,26 @@ def remove_state_table_record(resource_arn): # TODO(liamschn): move dynamodb resource to the dynamo class object/module dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) LOGGER.info(f"Searching for {resource_arn} in {STATE_TABLE} dynamodb table...") - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - dynamodb_resource, - SOLUTION_NAME, - { - "arn": resource_arn, - }, - ) - if item_found is False: - LOGGER.info(f"Record not found in {STATE_TABLE} dynamodb table") + try: + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + dynamodb_resource, + SOLUTION_NAME, + { + "arn": resource_arn, + }, + ) + if item_found is False: + LOGGER.info(f"Record not found in {STATE_TABLE} dynamodb table") + response = {} + else: + sra_resource_record_id = find_result["record_id"] + LOGGER.info(f"Found record id {sra_resource_record_id}") + LOGGER.info(f"Removing {sra_resource_record_id} from {STATE_TABLE} dynamodb table...") + response = dynamodb.delete_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME, sra_resource_record_id) + except Exception as error: + LOGGER.error(f"Error removing {resource_arn} record from {STATE_TABLE} dynamodb table: {error}") response = {} - else: - sra_resource_record_id = find_result["record_id"] - LOGGER.info(f"Found record id {sra_resource_record_id}") - LOGGER.info(f"Removing {sra_resource_record_id} from {STATE_TABLE} dynamodb table...") - response = dynamodb.delete_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME, sra_resource_record_id) return response From 3d060faf545004830c7d53ef4527cdf5ee040fa6 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 20:14:24 -0700 Subject: [PATCH 200/395] add removal of dashboard on delete --- .../genai/bedrock_org/lambda/src/app.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index c31da4b3c..165f38413 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1074,6 +1074,28 @@ def deploy_cloudwatch_dashboard(event): # else: # LOGGER.info("CloudWatch observability dashboard is correct") +def remove_cloudwatch_dashboard(): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) + + search_dashboard = cloudwatch.find_dashboard(SOLUTION_NAME) + if search_dashboard[0] is True: + if DRY_RUN is False: + LOGGER.info(f"CloudWatch observability dashboard found: {search_dashboard[1]}, deleting...") + cloudwatch.delete_dashboard(SOLUTION_NAME) + LIVE_RUN_DATA["CloudWatchDashboardCreate"] = "Created CloudWatch observability dashboard" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info("Deleted CloudWatch observability dashboard") + else: + LOGGER.info("DRY_RUN: CloudWatch observability dashboard found, needs to be deleted...") + DRY_RUN_DATA["CloudWatchDashboardDelete"] = "DRY_RUN: Delete CloudWatch observability dashboard" + else: + LOGGER.info(f"Cloudwatch dashboard not found...") + def create_event(event, context): global DRY_RUN_DATA @@ -1152,7 +1174,11 @@ def delete_event(event, context): global CFN_RESPONSE_DATA DRY_RUN_DATA = {} LIVE_RUN_DATA = {} - LOGGER.info("delete event function") + LOGGER.info("Delete event function") + + # 0) Delete cloudwatch dashboard + remove_cloudwatch_dashboard() + # 1) Delete SNS topic # 1a) Delete configuration topic sns.SNS_CLIENT = sts.assume_role(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "sns", sts.HOME_REGION) From fa2c7d38a42fcefa4472f897790577286815e8d9 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 20:38:16 -0700 Subject: [PATCH 201/395] add sns fanout action to the count --- .../solutions/genai/bedrock_org/lambda/src/app.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 165f38413..e1b9c164b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1521,6 +1521,10 @@ def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, resou sns_topic_arn: SNS Topic ARN action: Action """ + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + LOGGER.info("Creating SNS Messages...") sns_messages = [] LOGGER.info("ResourceProperties found in event") @@ -1535,6 +1539,11 @@ def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, resou } ) sns.process_sns_message_batches(sns_messages, sns_topic_arn) + if DRY_RUN is False: + LIVE_RUN_DATA["SNSFanout"] = "Published SNS messages for regional fanout configuration" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + else: + DRY_RUN_DATA["SNSFanout"] = "DRY_RUN: Publish SNS messages for regional fanout configuration" def process_sns_records(event) -> None: From ef992f88a0464d3c0d62985af9dc8a2a17f8c7b7 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 21 Nov 2024 20:40:56 -0700 Subject: [PATCH 202/395] add attach policy actions to dry_run data --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index e1b9c164b..b889dc003 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1006,7 +1006,7 @@ def deploy_central_cloudwatch_observability(event): LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}") else: DRY_RUN_DATA[ - f"OAMCrossAccountRolePolicyAttach_{bedrock_account}" + f"OAMCrossAccountRolePolicyAttach_{policy_arn.split("/")[1]}_{bedrock_account}" ] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" # 5e) OAM link in bedrock account From c1275f5db2fbb0070fc5a0a2fcec18654a060da2 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 26 Nov 2024 11:29:22 -0700 Subject: [PATCH 203/395] simulate topic_arn for dry_run --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b889dc003..ce69c1981 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -642,6 +642,8 @@ def deploy_sns_configuration_topics(context): LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {SOLUTION_NAME}-configuration SNS topic") DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" + topic_arn = f"arn:aws:sns:{sts.HOME_REGION}:{ACCOUNT}:{SOLUTION_NAME}-configuration" + else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search From fb64ed0d8735ad4f1e929529a5d7e114b4c1cc6c Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 26 Nov 2024 11:50:25 -0700 Subject: [PATCH 204/395] must create topic for fanout in dry_run mode --- .../genai/bedrock_org/lambda/src/app.py | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ce69c1981..a503f6561 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -609,46 +609,54 @@ def deploy_sns_configuration_topics(context): topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is None: - if DRY_RUN is False: - LOGGER.info(f"Creating {SOLUTION_NAME}-configuration SNS topic") - topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) - LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-configuration SNS topic" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # if DRY_RUN is False: + LOGGER.info(f"Creating {SOLUTION_NAME}-configuration SNS topic") + topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) + LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-configuration SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") - # TODO(liamschn): search for permissions on lambda before adding the policy - lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) - LIVE_RUN_DATA["SNSPermissions"] = "Added lambda sns-invoke permissions for SNS topic" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") + # TODO(liamschn): search for permissions on lambda before adding the policy + lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) + LIVE_RUN_DATA["SNSPermissions"] = "Added lambda sns-invoke permissions for SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - LOGGER.info(f"Subscribing {context.invoked_function_arn} to {topic_arn}") - sns.create_sns_subscription(topic_arn, "lambda", context.invoked_function_arn) - LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + LOGGER.info(f"Subscribing {context.invoked_function_arn} to {topic_arn}") + sns.create_sns_subscription(topic_arn, "lambda", context.invoked_function_arn) + LIVE_RUN_DATA["SNSSubscription"] = f"Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 + if DRY_RUN is False: # SNS State table record: add_state_table_record("sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration") - else: - LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") - DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-configuration SNS topic" + DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Created {SOLUTION_NAME}-configuration SNS topic" + DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Added lambda sns-invoke permissions for SNS topic" + DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" - LOGGER.info( - f"DRY_RUN: Creating SNS topic policy permissions for {SOLUTION_NAME}-configuration SNS topic on {context.function_name} lambda function" - ) - DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" + # else: + # LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") + # DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-configuration SNS topic" + + # LOGGER.info( + # f"DRY_RUN: Creating SNS topic policy permissions for {SOLUTION_NAME}-configuration SNS topic on {context.function_name} lambda function" + # ) + # DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" - LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {SOLUTION_NAME}-configuration SNS topic") - DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" - topic_arn = f"arn:aws:sns:{sts.HOME_REGION}:{ACCOUNT}:{SOLUTION_NAME}-configuration" + # LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {SOLUTION_NAME}-configuration SNS topic") + # DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" + # topic_arn = f"arn:aws:sns:{sts.HOME_REGION}:{ACCOUNT}:{SOLUTION_NAME}-configuration" else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search - # SNS State table record: - add_state_table_record("sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration") + if DRY_RUN is False: + # SNS State table record: + add_state_table_record("sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration") + else: + DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: {SOLUTION_NAME}-configuration SNS topic already exists" return topic_arn From 2dd7862c1e8eac77e6f12752c348bfae66ecf262 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 26 Nov 2024 15:55:13 -0700 Subject: [PATCH 205/395] handle nosuchentity error --- .../solutions/genai/bedrock_org/lambda/src/sra_iam.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 0b6472a75..afeba2d9c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -440,8 +440,12 @@ def check_iam_policy_attached(self, role_name, policy_arn): self.LOGGER.info(f"The policy '{policy_arn}' is not attached to the role '{role_name}'.") return False except ClientError as error: - self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") - raise + if error.response["Error"]["Code"] == "NoSuchEntity": + self.LOGGER.info(f"The role '{role_name}' does not exist.") + return False + else: + self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") + raise ValueError(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") from None def list_attached_iam_policies(self, role_name): """ From 34d71d34228351c5ee000e94a539f20e06677c41 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 26 Nov 2024 16:50:29 -0700 Subject: [PATCH 206/395] handle sink arn in dry_run mode --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a503f6561..6f36542db 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -914,6 +914,8 @@ def deploy_central_cloudwatch_observability(event): else: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink not found, creating...") DRY_RUN_DATA["OAMSinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager sink" + # set default value for an oam sink arn (for dry run) + oam_sink_arn = f"arn:aws:cloudwatch::{SECURITY_ACCOUNT}:sink/arn" else: oam_sink_arn = search_oam_sink[1] LOGGER.info(f"CloudWatch observability access manager sink found: {oam_sink_arn}") From c075d5667920ab6e1a7237fd07e4bcc52aa41763 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 26 Nov 2024 21:02:56 -0700 Subject: [PATCH 207/395] update dry run sns publish message --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 6f36542db..63358fb02 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1039,6 +1039,8 @@ def deploy_central_cloudwatch_observability(event): else: LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") DRY_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = f"DRY_RUN: Create CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" + # Set link arn to default value (for dry run) + oam_link_arn = f"arn:aws:cloudwatch::{bedrock_account}:link/arn" else: LOGGER.info(f"CloudWatch observability access manager link found in {bedrock_account} in {bedrock_region}") oam_link_arn = search_oam_link[1] @@ -1555,7 +1557,7 @@ def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, resou LIVE_RUN_DATA["SNSFanout"] = "Published SNS messages for regional fanout configuration" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 else: - DRY_RUN_DATA["SNSFanout"] = "DRY_RUN: Publish SNS messages for regional fanout configuration" + DRY_RUN_DATA["SNSFanout"] = "DRY_RUN: Published SNS messages for regional fanout configuration. More dry run data in subsequent log streams." def process_sns_records(event) -> None: From 380130489b9542963fd0350808d8e95ca74b6377 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 26 Nov 2024 21:16:48 -0700 Subject: [PATCH 208/395] add run data logging to sns fanout --- .../solutions/genai/bedrock_org/lambda/src/app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 63358fb02..b11308091 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1593,6 +1593,11 @@ def process_sns_records(event) -> None: # deploy_central_cloudwatch_observability(event) else: LOGGER.info(f"Action specified is {message['Action']}") + LOGGER.info("SNS records processed.") + if DRY_RUN is False: + LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) + else: + LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) def deploy_iam_role(account_id: str, rule_name: str) -> str: """Deploy IAM role. From c2a75c23d6095b2df29b2b60211073aabd33427a Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 27 Nov 2024 12:23:07 -0700 Subject: [PATCH 209/395] create/upload dry_run data file --- .../genai/bedrock_org/lambda/src/app.py | 15 +++++++++++++++ .../genai/bedrock_org/lambda/src/sra_s3.py | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b11308091..4a21afbf9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1,4 +1,5 @@ import copy +from datetime import datetime import json import os import logging @@ -1155,6 +1156,10 @@ def create_event(event, context): LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) + create_json_file("dry_run_data.json", DRY_RUN_DATA) + LOGGER.info("Dry run data saved to file") + s3.upload_file_to_s3("/tmp/dry_run_data.json", s3.STAGING_BUCKET, f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") + LOGGER.info(f"Dry run data file uploaded to s3://{s3.STAGING_BUCKET}/dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: LOGGER.info("Resource type is a custom resource") @@ -1599,6 +1604,16 @@ def process_sns_records(event) -> None: else: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) +def create_json_file(file_name: str, data: dict) -> None: + """Create JSON file. + + Args: + file_name: name of file to be created + data: data to be written to file + """ + with open(f"/tmp/{file_name}", "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + def deploy_iam_role(account_id: str, rule_name: str) -> str: """Deploy IAM role. diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index c3cdd21f7..45195e048 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -150,4 +150,19 @@ def download_s3_file(self, local_file_path, s3_key, bucket_name): # list the directory contents self.LOGGER.info(f"Listing directory contents: {os.listdir(os.path.dirname(local_file_path))}") else: - self.LOGGER.info(f"File not found: {local_file_path}") \ No newline at end of file + self.LOGGER.info(f"File not found: {local_file_path}") + + def upload_file_to_s3(self, local_file_path, bucket_name, s3_key): + """ + Uploads a file to an S3 bucket. + + :param local_file_path: Local path to the file to be uploaded + :param bucket_name: Name of the S3 bucket + :param s3_key: S3 key (path) where the file will be uploaded + """ + try: + # Upload the file to S3 + self.S3_CLIENT.upload_file(local_file_path, bucket_name, s3_key) + self.LOGGER.info(f"File uploaded successfully to {bucket_name}/{s3_key}") + except ClientError as e: + self.LOGGER.info(f"Error uploading file: {e}") \ No newline at end of file From c0eac298914a86f3c3d027c8867296fcb0e5ccb9 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 27 Nov 2024 12:37:41 -0700 Subject: [PATCH 210/395] upload sns dry run data to s3 --- .../solutions/genai/bedrock_org/lambda/src/app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 4a21afbf9..892716fbd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1603,6 +1603,11 @@ def process_sns_records(event) -> None: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) + create_json_file("dry_run_data.json", DRY_RUN_DATA) + LOGGER.info("Dry run data saved to file") + s3.upload_file_to_s3("/tmp/dry_run_data.json", s3.STAGING_BUCKET, f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") + LOGGER.info(f"Dry run data file uploaded to s3://{s3.STAGING_BUCKET}/dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") + def create_json_file(file_name: str, data: dict) -> None: """Create JSON file. From a9337383ac6b650d7d2a3fc0f4344fc9542e74f4 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 27 Nov 2024 12:49:41 -0700 Subject: [PATCH 211/395] handle errors on cfn delete when dry_run is true --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 892716fbd..619b16cc6 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1194,7 +1194,7 @@ def delete_event(event, context): DRY_RUN_DATA = {} LIVE_RUN_DATA = {} LOGGER.info("Delete event function") - + # 0) Delete cloudwatch dashboard remove_cloudwatch_dashboard() @@ -1939,6 +1939,10 @@ def lambda_handler(event, context): update_event(event, context) if event["RequestType"] == "Delete": LOGGER.info("DELETE EVENT!!") + # Set DRY_RUN to False if we are deleting via CloudFormation (should do this with Terraform as well); stack will be gone. + if RESOURCE_TYPE != "Other": + global DRY_RUN + DRY_RUN = False delete_event(event, context) except Exception: From 38ec59c9d37f5dffbf9b300f9f18c2b4fe718876 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 27 Nov 2024 14:09:34 -0700 Subject: [PATCH 212/395] removing completed todo comments --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ---- .../solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py | 1 - 2 files changed, 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 619b16cc6..35700d202 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -24,15 +24,12 @@ # import sra_lambda -# TODO(liamschn): If dynamoDB sra_state table exists, use it -# TODO(liamschn): Where do we see dry-run data? Maybe S3 staging bucket file? The sra_state table? Another DynamoDB table? # TODO(liamschn): deploy example bedrock guardrail # TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority/not necessary? # TODO(liamschn): deploy example bucket policy(ies) - lower priority/not necessary? # TODO(liamschn): deal with linting failures in pipeline # TODO(liamschn): deal with typechecking/mypy # TODO(liamschn): check for unused parameters -# TODO(liamschn): need to ensure DRY_RUN is false for any dynamodb state table record insertions from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional @@ -1792,7 +1789,6 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: lambdas.put_permissions_acct(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", account_id) LOGGER.info(f"Creating {rule_name} config rule in {account_id} in {region}...") # TODO(liamschn): Determine if we need to add a description for the config rules - # TODO(liamschn): Determine what we will do for input parameters variable in the config rule create function;need an s3 bucket currently config_response = config.create_config_rule( rule_name, lambda_arn, diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 6ae2a810e..17b4d1e68 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -76,7 +76,6 @@ def create_metric_filter( ) -> None: try: if not self.find_metric_filter(log_group_name, filter_name): - # TODO(liamschn): finalize what parameters should be setup for this create_metric_filter function self.CWLOGS_CLIENT.put_metric_filter( logGroupName=log_group_name, filterName=filter_name, From 885282da11ead2f5aeafe726edaa07b518d7d5b8 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 09:43:12 -0700 Subject: [PATCH 213/395] switched from SECURITY_ACCOUNT to ssm_params.SRA_SECURITY_ACCT --- .../genai/bedrock_org/lambda/src/app.py | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 35700d202..72444a361 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -76,7 +76,6 @@ def load_sra_cloudwatch_dashboard() -> dict: STATE_TABLE: str = "sra_state" SOLUTION_NAME: str = "sra-bedrock-org" GOVERNED_REGIONS = [] -SECURITY_ACCOUNT = "" ORGANIZATION_ID = "" SRA_ALARM_EMAIL: str = "" SRA_ALARM_TOPIC_ARN: str = "" @@ -162,7 +161,6 @@ def get_resource_parameters(event): global GOVERNED_REGIONS global CFN_RESPONSE_DATA global SRA_ALARM_EMAIL - global SECURITY_ACCOUNT global ORGANIZATION_ID param_validation: dict = validate_parameters(event["ResourceProperties"], PARAMETER_VALIDATION_RULES) @@ -190,9 +188,8 @@ def get_resource_parameters(event): security_acct_param = ssm_params.get_ssm_parameter(ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/control-tower/audit-account-id") if security_acct_param[0] is True: - SECURITY_ACCOUNT = security_acct_param[1] # TODO(liamschn): switch to using the class SRA_SECURITY_ACCT variable? ssm_params.SRA_SECURITY_ACCT = security_acct_param[1] - LOGGER.info(f"Successfully retrieved the SRA security account parameter: {SECURITY_ACCOUNT}") + LOGGER.info(f"Successfully retrieved the SRA security account parameter: {ssm_params.SRA_SECURITY_ACCT}") else: LOGGER.info("Error retrieving SRA security account ssm parameter. Is the SRA common prerequisites solution deployed?") raise ValueError("Error retrieving SRA security account ssm parameter. Is the SRA common prerequisites solution deployed?") from None @@ -896,7 +893,7 @@ def deploy_central_cloudwatch_observability(event): central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) # TODO(liamschn): create a parameter to choose to deploy central observability or not: deploy_central_observability = true/false # 5a) OAM Sink in security account - cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) + cloudwatch.CWOAM_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) search_oam_sink = cloudwatch.find_oam_sink() if search_oam_sink[0] is False: if DRY_RUN is False: @@ -908,17 +905,17 @@ def deploy_central_cloudwatch_observability(event): CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LIVE_RUN_DATA["OAMSinkCreate"] = "Created CloudWatch observability access manager sink" # add OAM sink state table record - add_state_table_record("oam", "implemented", "oam sink", "sink", oam_sink_arn, SECURITY_ACCOUNT, sts.HOME_REGION, "oam_sink") + add_state_table_record("oam", "implemented", "oam sink", "sink", oam_sink_arn, ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, "oam_sink") else: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink not found, creating...") DRY_RUN_DATA["OAMSinkCreate"] = "DRY_RUN: Create CloudWatch observability access manager sink" # set default value for an oam sink arn (for dry run) - oam_sink_arn = f"arn:aws:cloudwatch::{SECURITY_ACCOUNT}:sink/arn" + oam_sink_arn = f"arn:aws:cloudwatch::{ssm_params.SRA_SECURITY_ACCT}:sink/arn" else: oam_sink_arn = search_oam_sink[1] LOGGER.info(f"CloudWatch observability access manager sink found: {oam_sink_arn}") # add OAM sink state table record - add_state_table_record("oam", "implemented", "oam sink", "sink", oam_sink_arn, SECURITY_ACCOUNT, sts.HOME_REGION, "oam_sink") + add_state_table_record("oam", "implemented", "oam sink", "sink", oam_sink_arn, ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, "oam_sink") # 5b) OAM Sink policy in security account cloudwatch.SINK_POLICY = CLOUDWATCH_OAM_SINK_POLICY["sra-oam-sink-policy"] @@ -969,7 +966,7 @@ def deploy_central_cloudwatch_observability(event): cloudwatch.CROSS_ACCOUNT_TRUST_POLICY = CLOUDWATCH_OAM_TRUST_POLICY[cloudwatch.CROSS_ACCOUNT_ROLE_NAME] cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0][ "Principal" - ]["AWS"].replace("", SECURITY_ACCOUNT) + ]["AWS"].replace("", ssm_params.SRA_SECURITY_ACCT) search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) if search_iam_role[0] is False: LOGGER.info( @@ -1053,7 +1050,7 @@ def deploy_cloudwatch_dashboard(event): central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) cloudwatch_dashboard = build_cloudwatch_dashboard(CLOUDWATCH_DASHBOARD, SOLUTION_NAME, central_observability_params["bedrock_accounts"], central_observability_params["regions"]) - cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) # sra-bedrock-filter-prompt-injection-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2] # sra-bedrock-filter-sensitive-info-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3] @@ -1091,7 +1088,7 @@ def remove_cloudwatch_dashboard(): global LIVE_RUN_DATA global CFN_RESPONSE_DATA - cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) search_dashboard = cloudwatch.find_dashboard(SOLUTION_NAME) if search_dashboard[0] is True: @@ -1217,7 +1214,7 @@ def delete_event(event, context): # 2) Delete Central CloudWatch Observability central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) - cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) + cloudwatch.CWOAM_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) search_oam_sink = cloudwatch.find_oam_sink() if search_oam_sink[0] is True: oam_sink_arn = search_oam_sink[1] @@ -1289,7 +1286,7 @@ def delete_event(event, context): LOGGER.info(f"{cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role does not exist") # 2d) Delete OAM Sink in security account - cloudwatch.CWOAM_CLIENT = sts.assume_role(SECURITY_ACCOUNT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) + cloudwatch.CWOAM_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) if search_oam_sink[0] is True: if DRY_RUN is False: LOGGER.info("CloudWatch observability access manager sink found, deleting...") From 8bd47b6312d4d70698e10d364f8bc1cdc89c0adc Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 10:19:26 -0700 Subject: [PATCH 214/395] testing dynamodb client typechecking (related to mypy) --- .../bedrock_org/lambda/src/sra_dynamodb.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index d1e22d34f..bbe8e4989 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -9,10 +9,10 @@ import botocore from boto3.session import Session from typing import TYPE_CHECKING -# if TYPE_CHECKING: - # from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table - # from mypy_boto3_dynamodb.client import DynamoDBClient - +if TYPE_CHECKING: + from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table + from mypy_boto3_dynamodb.client import DynamoDBClient + print("DEBUG: WE ARE TYPECHECKING NOW...") class sra_dynamodb: @@ -21,16 +21,21 @@ class sra_dynamodb: LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") - LOGGER.setLevel(log_level) + LOGGER.setLevel(log_level) try: MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() - # DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") - # DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") except Exception: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + try: + DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") + except Exception as error: + LOGGER.warning(f"Error creating boto3 dymanodb resource and client: {error}") + + def __init__(self, profile="default") -> None: self.PROFILE = profile try: From ec2febeb7f6f53e24bc6bf13e1fdc12f33e1e973 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 10:20:10 -0700 Subject: [PATCH 215/395] added tracing --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index bbe8e4989..198adf0dc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -32,6 +32,7 @@ class sra_dynamodb: try: DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") + LOGGER.info("DynamoDB resource and client created successfully.") except Exception as error: LOGGER.warning(f"Error creating boto3 dymanodb resource and client: {error}") From eef986eaa4fef7cd63846eb01f01a901365bcff7 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 10:55:15 -0700 Subject: [PATCH 216/395] moving DynamoDBServiceResource out of if statement --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 198adf0dc..d63bc7f2b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -10,10 +10,10 @@ from boto3.session import Session from typing import TYPE_CHECKING if TYPE_CHECKING: - from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table from mypy_boto3_dynamodb.client import DynamoDBClient - print("DEBUG: WE ARE TYPECHECKING NOW...") + # print("DEBUG: WE ARE TYPECHECKING NOW...") +from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table class sra_dynamodb: PROFILE = "default" From 9f8a670fa807a4e4d22fd6e6c262843333e8026f Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 11:43:32 -0700 Subject: [PATCH 217/395] update project.toml to support dynamodb in mypy --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index de62e04bf..88ebbfb77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ boto3 = "1.20.32" crhelper = "^2.0.11" [tool.poetry.dev-dependencies] -boto3-stubs = { extras = ["all"], version = "1.20.32" } +boto3-stubs = { extras = ["all"], version = "^1.28.0" } pytest = "^7.2.1" pytest-cov = "^4.0.0" pytest-mock = "^3.10.0" From 9e692d46465c333354f1e4f50900c83f6e05b967 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 13:01:04 -0700 Subject: [PATCH 218/395] add debug tracing --- .../bedrock_org/lambda/src/sra_dynamodb.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index d63bc7f2b..c46f422ee 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -13,16 +13,36 @@ from mypy_boto3_dynamodb.client import DynamoDBClient # print("DEBUG: WE ARE TYPECHECKING NOW...") -from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table +# from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table class sra_dynamodb: PROFILE = "default" UNEXPECTED = "Unexpected!" LOGGER = logging.getLogger(__name__) - log_level: str = os.environ.get("LOG_LEVEL", "INFO") + log_level: str = os.environ.get("LOG_LEVEL", "DEBUG") LOGGER.setLevel(log_level) + # DEBUG STUFF + import sys + LOGGER.debug("Python import paths:") + for path in sys.path: + LOGGER.debug(path) + + import pkgutil + LOGGER.debug("Installed packages:") + for module in pkgutil.iter_modules(): + LOGGER.debug(module.name) + + try: + from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource + LOGGER.info("Successfully imported DynamoDBServiceResource.") + except ModuleNotFoundError as e: + LOGGER.error(f"Failed to import DynamoDBServiceResource: {e}") + except Exception as e: + LOGGER.error(f"Unexpected error during import: {e}") + # END DEBUG STUFF + try: MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() except Exception: From 9d29ab0b070b6e29336be5e90acb4d0d106e7e2a Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 13:32:33 -0700 Subject: [PATCH 219/395] try adding mypy boto3 dynamodb to requirements --- .../solutions/genai/bedrock_org/lambda/src/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt index 9acb7c1db..0fa70dbcb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt @@ -1,3 +1,4 @@ #install latest # TODO(liamschn): not using crhelper -crhelper \ No newline at end of file +crhelper +mypy_boto3_dynamodb \ No newline at end of file From 0930bf2607aa1fb099c80c57b0fda6094cbcd18f Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 14:43:31 -0700 Subject: [PATCH 220/395] testing new method for dynamodb typechecking --- .../bedrock_org/lambda/src/requirements.txt | 2 +- .../bedrock_org/lambda/src/sra_dynamodb.py | 45 ++++++++++++------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt index 0fa70dbcb..84ba6036f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt @@ -1,4 +1,4 @@ #install latest # TODO(liamschn): not using crhelper crhelper -mypy_boto3_dynamodb \ No newline at end of file +# mypy_boto3_dynamodb \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index c46f422ee..62ddbcf46 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -11,9 +11,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient - # print("DEBUG: WE ARE TYPECHECKING NOW...") - -# from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table + from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource class sra_dynamodb: PROFILE = "default" @@ -25,22 +23,32 @@ class sra_dynamodb: # DEBUG STUFF import sys - LOGGER.debug("Python import paths:") + system_path = [] for path in sys.path: - LOGGER.debug(path) + system_path.append(path) + LOGGER.debug(f"Python import paths: {system_path}") import pkgutil - LOGGER.debug("Installed packages:") + packages_installed = [] for module in pkgutil.iter_modules(): - LOGGER.debug(module.name) - - try: - from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource - LOGGER.info("Successfully imported DynamoDBServiceResource.") - except ModuleNotFoundError as e: - LOGGER.error(f"Failed to import DynamoDBServiceResource: {e}") - except Exception as e: - LOGGER.error(f"Unexpected error during import: {e}") + packages_installed.append(module.name) + LOGGER.debug(f"Installed packages: {packages_installed}") + + # try: + # from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource + # LOGGER.info("Successfully imported DynamoDBServiceResource.") + # except ModuleNotFoundError as e: + # LOGGER.error(f"Failed to import DynamoDBServiceResource: {e}") + # except Exception as e: + # LOGGER.error(f"Unexpected error during import: {e}") + + # try: + # from mypy_boto3_dynamodb.client import DynamoDBClient + # LOGGER.info("Successfully imported DynamoDBClient.") + # except ModuleNotFoundError as e: + # LOGGER.error(f"Failed to import DynamoDBClient: {e}") + # except Exception as e: + # LOGGER.error(f"Unexpected error during import: {e}") # END DEBUG STUFF try: @@ -50,8 +58,11 @@ class sra_dynamodb: raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None try: - DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") - DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") + # Use string-based type annotations + DYNAMODB_CLIENT: "DynamoDBClient" = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb"a) + DYNAMODB_RESOURCE: "DynamoDBServiceResource" = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + # DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + # DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") LOGGER.info("DynamoDB resource and client created successfully.") except Exception as error: LOGGER.warning(f"Error creating boto3 dymanodb resource and client: {error}") From ef8631d983f308d3580a2c276b8cd38c9fa36017 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 14:55:47 -0700 Subject: [PATCH 221/395] fixing extra char in line --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 62ddbcf46..dd46cb70a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -59,7 +59,7 @@ class sra_dynamodb: try: # Use string-based type annotations - DYNAMODB_CLIENT: "DynamoDBClient" = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb"a) + DYNAMODB_CLIENT: "DynamoDBClient" = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") DYNAMODB_RESOURCE: "DynamoDBServiceResource" = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") # DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") # DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") From 1cbeec15e6239760cb388f422e67ed86f371e077 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 16:20:49 -0700 Subject: [PATCH 222/395] moved dynamodb client and resource to class module --- .../genai/bedrock_org/lambda/src/app.py | 25 +++---- .../bedrock_org/lambda/src/requirements.txt | 3 +- .../bedrock_org/lambda/src/sra_dynamodb.py | 65 +++++-------------- 3 files changed, 27 insertions(+), 66 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 72444a361..ca6dd017a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -440,29 +440,26 @@ def deploy_state_table(): if DRY_RUN is False: LOGGER.info("Live run: creating the state table...") - # TODO(liamschn): move dynamodb client and resource to the dynamo class object/module # TODO(liamschn): move the deploy state table function to the dynamo class object/module? - dynamodb_client = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + dynamodb.DYNAMODB_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) - if dynamodb.table_exists(STATE_TABLE, dynamodb_client) is False: - dynamodb.create_table(STATE_TABLE, dynamodb_client) - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + if dynamodb.table_exists(STATE_TABLE) is False: + dynamodb.create_table(STATE_TABLE) item_found, find_result = dynamodb.find_item( STATE_TABLE, - dynamodb_resource, "sra-common-prerequisites", { "arn": f"arn:aws:dynamodb:{sts.HOME_REGION}:{ssm_params.SRA_SECURITY_ACCT}:table/{STATE_TABLE}", }, ) if item_found is False: - dynamodb_record_id, dynamodb_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, "sra-common-prerequisites") + dynamodb_record_id, dynamodb_date_time = dynamodb.insert_item(STATE_TABLE, "sra-common-prerequisites") else: dynamodb_record_id = find_result["record_id"] dynamodb.update_item( STATE_TABLE, - dynamodb_resource, "sra-common-prerequisites", dynamodb_record_id, { @@ -502,27 +499,24 @@ def add_state_table_record(aws_service: str, component_state: str, description: None """ LOGGER.info(f"Add a record to the state table for {component_name}") - # TODO(liamschn): move dynamodb resource to the dynamo class object/module # TODO(liamschn): check to ensure we got a 200 back from the service API call before inserting the dynamodb records - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) item_found, find_result = dynamodb.find_item( STATE_TABLE, - dynamodb_resource, SOLUTION_NAME, { "arn": resource_arn, }, ) if item_found is False: - sra_resource_record_id, iam_date_time = dynamodb.insert_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME) + sra_resource_record_id, iam_date_time = dynamodb.insert_item(STATE_TABLE, SOLUTION_NAME) else: sra_resource_record_id = find_result["record_id"] dynamodb.update_item( STATE_TABLE, - dynamodb_resource, SOLUTION_NAME, sra_resource_record_id, { @@ -550,12 +544,11 @@ def remove_state_table_record(resource_arn): response: response from dynamodb delete_item """ # TODO(liamschn): move dynamodb resource to the dynamo class object/module - dynamodb_resource = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) LOGGER.info(f"Searching for {resource_arn} in {STATE_TABLE} dynamodb table...") try: item_found, find_result = dynamodb.find_item( STATE_TABLE, - dynamodb_resource, SOLUTION_NAME, { "arn": resource_arn, @@ -568,7 +561,7 @@ def remove_state_table_record(resource_arn): sra_resource_record_id = find_result["record_id"] LOGGER.info(f"Found record id {sra_resource_record_id}") LOGGER.info(f"Removing {sra_resource_record_id} from {STATE_TABLE} dynamodb table...") - response = dynamodb.delete_item(STATE_TABLE, dynamodb_resource, SOLUTION_NAME, sra_resource_record_id) + response = dynamodb.delete_item(STATE_TABLE, SOLUTION_NAME, sra_resource_record_id) except Exception as error: LOGGER.error(f"Error removing {resource_arn} record from {STATE_TABLE} dynamodb table: {error}") response = {} diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt index 84ba6036f..9acb7c1db 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt @@ -1,4 +1,3 @@ #install latest # TODO(liamschn): not using crhelper -crhelper -# mypy_boto3_dynamodb \ No newline at end of file +crhelper \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index dd46cb70a..0c87fbbed 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -18,54 +18,22 @@ class sra_dynamodb: UNEXPECTED = "Unexpected!" LOGGER = logging.getLogger(__name__) - log_level: str = os.environ.get("LOG_LEVEL", "DEBUG") + log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - # DEBUG STUFF - import sys - system_path = [] - for path in sys.path: - system_path.append(path) - LOGGER.debug(f"Python import paths: {system_path}") - - import pkgutil - packages_installed = [] - for module in pkgutil.iter_modules(): - packages_installed.append(module.name) - LOGGER.debug(f"Installed packages: {packages_installed}") - - # try: - # from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource - # LOGGER.info("Successfully imported DynamoDBServiceResource.") - # except ModuleNotFoundError as e: - # LOGGER.error(f"Failed to import DynamoDBServiceResource: {e}") - # except Exception as e: - # LOGGER.error(f"Unexpected error during import: {e}") - - # try: - # from mypy_boto3_dynamodb.client import DynamoDBClient - # LOGGER.info("Successfully imported DynamoDBClient.") - # except ModuleNotFoundError as e: - # LOGGER.error(f"Failed to import DynamoDBClient: {e}") - # except Exception as e: - # LOGGER.error(f"Unexpected error during import: {e}") - # END DEBUG STUFF - try: - MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() - except Exception: - LOGGER.exception(UNEXPECTED) - raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() + except Exception as error: + LOGGER.exception(f"Error creating boto3 session: {error}") + raise ValueError(f"Error creating boto3 session: {error}") from None try: - # Use string-based type annotations DYNAMODB_CLIENT: "DynamoDBClient" = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") DYNAMODB_RESOURCE: "DynamoDBServiceResource" = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") - # DYNAMODB_RESOURCE: DynamoDBServiceResource = MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") - # DYNAMODB_CLIENT: DynamoDBClient = MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") LOGGER.info("DynamoDB resource and client created successfully.") except Exception as error: - LOGGER.warning(f"Error creating boto3 dymanodb resource and client: {error}") + LOGGER.info(f"Error creating boto3 dymanodb resource and/or client: {error}") + raise ValueError(f"Error creating boto3 dymanodb resource and/or client: {error}") from None def __init__(self, profile="default") -> None: @@ -76,12 +44,13 @@ def __init__(self, profile="default") -> None: else: self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() - # self.DYNAMODB_RESOURCE = self.MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + self.DYNAMODB_RESOURCE = self.MANAGEMENT_ACCOUNT_SESSION.resource("dynamodb") + self.DYNAMODB_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("dynamodb") except Exception: self.LOGGER.exception(self.UNEXPECTED) raise ValueError("Unexpected error!") from None - def create_table(self, table_name, dynamodb_client): + def create_table(self, table_name, dynamodb_client=DYNAMODB_CLIENT): # Define table schema key_schema = [ {"AttributeName": "solution_name", "KeyType": "HASH"}, @@ -112,7 +81,7 @@ def create_table(self, table_name, dynamodb_client): # TODO(liamschn): need to add a maximum retry mechanism here sleep(5) - def table_exists(self, table_name, dynamodb_client): + def table_exists(self, table_name, dynamodb_client=DYNAMODB_CLIENT): # Check if table exists try: dynamodb_client.describe_table(TableName=table_name) @@ -130,7 +99,7 @@ def get_date_time(self): now = datetime.now() return now.strftime("%Y%m%d%H%M%S") - def insert_item(self, table_name, dynamodb_resource, solution_name): + def insert_item(self, table_name, solution_name, dynamodb_resource=DYNAMODB_RESOURCE): table = dynamodb_resource.Table(table_name) record_id = self.generate_id() date_time = self.get_date_time() @@ -144,7 +113,7 @@ def insert_item(self, table_name, dynamodb_resource, solution_name): # self.LOGGER.info({"insert_record_response": response}) return record_id, date_time - def update_item(self, table_name, dynamodb_resource, solution_name, record_id, attributes_and_values): + def update_item(self, table_name, solution_name, record_id, attributes_and_values, dynamodb_resource=DYNAMODB_RESOURCE): self.LOGGER.info(f"Updating {table_name} dynamodb table with {attributes_and_values}") table = dynamodb_resource.Table(table_name) update_expression = "" @@ -167,7 +136,7 @@ def update_item(self, table_name, dynamodb_resource, solution_name, record_id, a ) return response - def find_item(self, table_name, dynamodb_resource, solution_name, additional_attributes) -> tuple[bool, dict]: + def find_item(self, table_name, solution_name, additional_attributes, dynamodb_resource=DYNAMODB_RESOURCE) -> tuple[bool, dict]: """Find an item in the dynamodb table based on the solution name and additional attributes. Args: @@ -213,7 +182,7 @@ def get_unique_values_from_list(self, list_of_values): unique_values.append(value) return unique_values - def get_distinct_solutions_and_accounts(self, table_name, dynamodb_resource): + def get_distinct_solutions_and_accounts(self, table_name, dynamodb_resource=DYNAMODB_RESOURCE): table = dynamodb_resource.Table(table_name) response = table.scan() solution_names = [item["solution_name"] for item in response["Items"]] @@ -222,7 +191,7 @@ def get_distinct_solutions_and_accounts(self, table_name, dynamodb_resource): accounts = self.get_unique_values_from_list(accounts) return solution_names, accounts - def get_resources_for_solutions_by_account(self, table_name, dynamodb_resource, solutions, account): + def get_resources_for_solutions_by_account(self, table_name, solutions, account, dynamodb_resource=DYNAMODB_RESOURCE): table = dynamodb_resource.Table(table_name) query_results = {} for solution in solutions: @@ -240,7 +209,7 @@ def get_resources_for_solutions_by_account(self, table_name, dynamodb_resource, query_results[solution] = response return query_results - def delete_item(self, table_name, dynamodb_resource, solution_name, record_id): + def delete_item(self, table_name, solution_name, record_id, dynamodb_resource=DYNAMODB_RESOURCE): """Delete an item from the dynamodb table Args: From f4961727fcb84f007caf8a8b294e01d43e78c720 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 16:58:27 -0700 Subject: [PATCH 223/395] add more debug for assume role --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 1 + .../solutions/genai/bedrock_org/lambda/src/sra_sts.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ca6dd017a..d5c7bae1b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -30,6 +30,7 @@ # TODO(liamschn): deal with linting failures in pipeline # TODO(liamschn): deal with typechecking/mypy # TODO(liamschn): check for unused parameters +# TODO(liamschn): make sure things don't fail (create or delete) if the dynamodb table is deleted/doesn't exist (use case, maybe someone deletes it) from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index 031f71c4b..bcbcb42f0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -70,6 +70,7 @@ def assume_role(self, account, role_name, service, region_name): client: boto3 client """ self.LOGGER.info(f"ASSUME ROLE CALLER ID INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") + self.LOGGER.info(f"ASSUME ROLE ACCOUNT (CLIENT): {account}; ROLE NAME: {role_name}; SERVICE: {service}; REGION: {region_name}") client = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") if account != self.MANAGEMENT_ACCOUNT: sts_response = client.assume_role( @@ -100,6 +101,8 @@ def assume_role_resource(self, account, role_name, service, region_name): Returns: client: boto3 client """ + self.LOGGER.info(f"ASSUME ROLE CALLER ID INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") + self.LOGGER.info(f"ASSUME ROLE ACCOUNT (RESOURCE): {account}; ROLE NAME: {role_name}; SERVICE: {service}; REGION: {region_name}") client = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") sts_response = client.assume_role( RoleArn="arn:" + self.PARTITION + ":iam::" + account + ":role/" + role_name, From 7d6c6c577bc37eefbd6e98469ba017259691b565 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 29 Nov 2024 17:16:32 -0700 Subject: [PATCH 224/395] remove dynamodb client/resource function arguments --- .../bedrock_org/lambda/src/sra_dynamodb.py | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 0c87fbbed..42574b1e4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -50,7 +50,7 @@ def __init__(self, profile="default") -> None: self.LOGGER.exception(self.UNEXPECTED) raise ValueError("Unexpected error!") from None - def create_table(self, table_name, dynamodb_client=DYNAMODB_CLIENT): + def create_table(self, table_name): # Define table schema key_schema = [ {"AttributeName": "solution_name", "KeyType": "HASH"}, @@ -64,7 +64,7 @@ def create_table(self, table_name, dynamodb_client=DYNAMODB_CLIENT): # Create table try: - dynamodb_client.create_table( + self.DYNAMODB_CLIENT.create_table( TableName=table_name, KeySchema=key_schema, AttributeDefinitions=attribute_definitions, ProvisionedThroughput=provisioned_throughput ) self.LOGGER.info(f"{table_name} dynamodb table created successfully.") @@ -72,7 +72,7 @@ def create_table(self, table_name, dynamodb_client=DYNAMODB_CLIENT): self.LOGGER.info("Error creating table:", e) # wait for the table to become active while True: - wait_response = dynamodb_client.describe_table(TableName=table_name) + wait_response = self.DYNAMODB_CLIENT.describe_table(TableName=table_name) if wait_response["Table"]["TableStatus"] == "ACTIVE": self.LOGGER.info(f"{table_name} dynamodb table is active") break @@ -81,13 +81,13 @@ def create_table(self, table_name, dynamodb_client=DYNAMODB_CLIENT): # TODO(liamschn): need to add a maximum retry mechanism here sleep(5) - def table_exists(self, table_name, dynamodb_client=DYNAMODB_CLIENT): + def table_exists(self, table_name): # Check if table exists try: - dynamodb_client.describe_table(TableName=table_name) + self.DYNAMODB_CLIENT.describe_table(TableName=table_name) self.LOGGER.info(f"{table_name} dynamodb table already exists...") return True - except dynamodb_client.exceptions.ResourceNotFoundException: + except self.DYNAMODB_CLIENT.exceptions.ResourceNotFoundException: self.LOGGER.info(f"{table_name} dynamodb table does not exist...") return False @@ -99,8 +99,8 @@ def get_date_time(self): now = datetime.now() return now.strftime("%Y%m%d%H%M%S") - def insert_item(self, table_name, solution_name, dynamodb_resource=DYNAMODB_RESOURCE): - table = dynamodb_resource.Table(table_name) + def insert_item(self, table_name, solution_name): + table = self.DYNAMODB_RESOURCE.Table(table_name) record_id = self.generate_id() date_time = self.get_date_time() response = table.put_item( @@ -113,9 +113,9 @@ def insert_item(self, table_name, solution_name, dynamodb_resource=DYNAMODB_RESO # self.LOGGER.info({"insert_record_response": response}) return record_id, date_time - def update_item(self, table_name, solution_name, record_id, attributes_and_values, dynamodb_resource=DYNAMODB_RESOURCE): + def update_item(self, table_name, solution_name, record_id, attributes_and_values): self.LOGGER.info(f"Updating {table_name} dynamodb table with {attributes_and_values}") - table = dynamodb_resource.Table(table_name) + table = self.DYNAMODB_RESOURCE.Table(table_name) update_expression = "" expression_attribute_values = {} for attribute in attributes_and_values: @@ -136,7 +136,7 @@ def update_item(self, table_name, solution_name, record_id, attributes_and_value ) return response - def find_item(self, table_name, solution_name, additional_attributes, dynamodb_resource=DYNAMODB_RESOURCE) -> tuple[bool, dict]: + def find_item(self, table_name, solution_name, additional_attributes) -> tuple[bool, dict]: """Find an item in the dynamodb table based on the solution name and additional attributes. Args: @@ -149,7 +149,7 @@ def find_item(self, table_name, solution_name, additional_attributes, dynamodb_r True and the item if found, otherwise False and empty dict """ self.LOGGER.info(f"Searching for {additional_attributes} in {table_name} dynamodb table") - table = dynamodb_resource.Table(table_name) + table = self.DYNAMODB_RESOURCE.Table(table_name) expression_attribute_values = {":solution_name": solution_name} filter_expression = " AND ".join([f"{attr} = :{attr}" for attr in additional_attributes.keys()]) @@ -182,8 +182,8 @@ def get_unique_values_from_list(self, list_of_values): unique_values.append(value) return unique_values - def get_distinct_solutions_and_accounts(self, table_name, dynamodb_resource=DYNAMODB_RESOURCE): - table = dynamodb_resource.Table(table_name) + def get_distinct_solutions_and_accounts(self, table_name): + table = self.DYNAMODB_RESOURCE.Table(table_name) response = table.scan() solution_names = [item["solution_name"] for item in response["Items"]] solution_names = self.get_unique_values_from_list(solution_names) @@ -191,8 +191,8 @@ def get_distinct_solutions_and_accounts(self, table_name, dynamodb_resource=DYNA accounts = self.get_unique_values_from_list(accounts) return solution_names, accounts - def get_resources_for_solutions_by_account(self, table_name, solutions, account, dynamodb_resource=DYNAMODB_RESOURCE): - table = dynamodb_resource.Table(table_name) + def get_resources_for_solutions_by_account(self, table_name, solutions, account): + table = self.DYNAMODB_RESOURCE.Table(table_name) query_results = {} for solution in solutions: # expression_attribute_values = {":solution_name": solution} @@ -209,7 +209,7 @@ def get_resources_for_solutions_by_account(self, table_name, solutions, account, query_results[solution] = response return query_results - def delete_item(self, table_name, solution_name, record_id, dynamodb_resource=DYNAMODB_RESOURCE): + def delete_item(self, table_name, solution_name, record_id): """Delete an item from the dynamodb table Args: @@ -222,6 +222,6 @@ def delete_item(self, table_name, solution_name, record_id, dynamodb_resource=DY response: response from dynamodb delete_item """ self.LOGGER.info(f"Deleting {record_id} from {table_name} dynamodb table") - table = dynamodb_resource.Table(table_name) + table = self.DYNAMODB_RESOURCE.Table(table_name) response = table.delete_item(Key={"solution_name": solution_name, "record_id": record_id}) return response \ No newline at end of file From f045a22ae81989135c57e59b629a9b269cd42967 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 2 Dec 2024 11:14:25 -0700 Subject: [PATCH 225/395] remove config rule if deploy set to false (testing) --- .../genai/bedrock_org/lambda/src/app.py | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index d5c7bae1b..3e7819f14 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -669,6 +669,9 @@ def deploy_config_rules(region, accounts, resource_properties): for acct in accounts: if rule_deploy is False: + LOGGER.info(f"{rule_name} is not to be deployed. Checking to see if it needs to be removed...") + delete_custom_config_rule(rule_name, acct, region) + delete_custom_config_iam_role(rule_name, acct) continue if rule_accounts: LOGGER.info(f"{rule_name} accounts: {rule_accounts}") @@ -1173,9 +1176,127 @@ def update_event(event, context): # cfnresponse.send(event, context, cfnresponse.SUCCESS, data, CFN_RESOURCE_ID) return CFN_RESOURCE_ID +def delete_custom_config_rule(rule_name: str, acct: str, region: str): + # Delete the config rule + config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) + config_rule_search = config.find_config_rule(rule_name) + if config_rule_search[0] is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} config rule for account {acct} in {region}") + config.delete_config_rule(rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(config_rule_search[1]["ConfigRules"][0]["ConfigRuleArn"]) + else: + LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" + else: + LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") + + # Delete lambda for custom config rule + lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) + lambda_search = lambdas.find_lambda_function(rule_name) + # TODO(liamschn): this will be a mypy error - need to have lambda_search return string, not None + if lambda_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") + lambdas.delete_lambda_function(rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(lambda_search["Configuration"]["FunctionArn"]) + else: + LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} lambda function" + else: + LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") + +def delete_custom_config_iam_role(rule_name: str, acct: str): + global DRY_RUN_DATA + global LIVE_RUN_DATA + global CFN_RESPONSE_DATA + + region = iam.get_iam_global_region() + # Detach IAM policies + iam.IAM_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "iam", region) + attached_policies = iam.list_attached_iam_policies(rule_name) + if attached_policies is not None: + for policy in attached_policies: + if DRY_RUN is False: + LOGGER.info(f"Detaching {policy['PolicyName']} IAM policy from account {acct} in {region}") + iam.detach_policy(rule_name, policy["PolicyArn"]) + LIVE_RUN_DATA[ + f"{rule_name}_{acct}_{region}_PolicyDetach" + ] = f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + else: + LOGGER.info(f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}") + DRY_RUN_DATA[ + f"{rule_name}_{acct}_{region}_Delete" + ] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" + else: + LOGGER.info(f"No IAM policies attached to {rule_name} for account {acct} in {region}") + + # Delete IAM policy + policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" + LOGGER.info(f"Policy ARN: {policy_arn}") + policy_search = iam.check_iam_policy_exists(policy_arn) + if policy_search is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") + iam.delete_policy(policy_arn) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(policy_arn) + else: + LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") + DRY_RUN_DATA[ + f"{rule_name}_{acct}_{region}_PolicyDelete" + ] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" + else: + LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region} does not exist.") + + policy_arn2 = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}" + LOGGER.info(f"Policy ARN: {policy_arn2}") + policy_search = iam.check_iam_policy_exists(policy_arn2) + if policy_search is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} IAM policy for account {acct} in {region}") + iam.delete_policy(policy_arn2) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(policy_arn2) + else: + LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}") + DRY_RUN_DATA[ + f"{rule_name}_{acct}_{region}_PolicyDelete" + ] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" + else: + LOGGER.info(f"{rule_name} IAM policy for account {acct} in {region} does not exist.") + + # Delete IAM execution role for custom config rule lambda + role_search = iam.check_iam_role_exists(rule_name) + if role_search[0] is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {rule_name} IAM role for account {acct} in {region}") + iam.delete_role(rule_name) + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM role" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + remove_state_table_record(role_search[1]) + else: + LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}") + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_RoleDelete"] = f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}" + else: + LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") + def delete_event(event, context): # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. + # TODO(liamschn): move re-used delete event operation code to separate functions global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA From 01456199ff75edd70cc619fa1b7227dceb67f18c Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 2 Dec 2024 14:50:27 -0700 Subject: [PATCH 226/395] ensure mgmt acct client for sns config topic --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 3e7819f14..8190e9041 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -595,7 +595,8 @@ def deploy_sns_configuration_topics(context): global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA - + + sns.SNS_CLIENT = sts.assume_role(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "sns", sts.HOME_REGION) topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is None: # if DRY_RUN is False: From a91d13ecd6f0ead5f2f90afdbdcb9d8a11624078 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 2 Dec 2024 15:03:43 -0700 Subject: [PATCH 227/395] moved config rule delete operation to functions --- .../genai/bedrock_org/lambda/src/app.py | 109 +----------------- 1 file changed, 3 insertions(+), 106 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 8190e9041..b6880cf43 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1523,114 +1523,11 @@ def delete_event(event, context): for acct in accounts: for region in regions: - # 4a) Delete the config rule - config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) - config_rule_search = config.find_config_rule(rule_name) - if config_rule_search[0] is True: - if DRY_RUN is False: - LOGGER.info(f"Deleting {rule_name} config rule for account {acct} in {region}") - config.delete_config_rule(rule_name) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} custom config rule" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - remove_state_table_record(config_rule_search[1]["ConfigRules"][0]["ConfigRuleArn"]) - else: - LOGGER.info(f"DRY_RUN: Deleting {rule_name} config rule for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} custom config rule" - else: - LOGGER.info(f"{rule_name} config rule for account {acct} in {region} does not exist.") - - # 4b) Delete lambda for custom config rule - lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) - lambda_search = lambdas.find_lambda_function(rule_name) - # TODO(liamschn): this will be a mypy error - need to have lambda_search return string, not None - if lambda_search is not None: - if DRY_RUN is False: - LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") - lambdas.delete_lambda_function(rule_name) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - remove_state_table_record(lambda_search["Configuration"]["FunctionArn"]) - else: - LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} lambda function" - else: - LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") - - # 5) Detach IAM policies - # TODO(liamschn): handle case where policy is not found attached_policies = None - iam.IAM_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "iam", REGION) - attached_policies = iam.list_attached_iam_policies(rule_name) - if attached_policies is not None: - if DRY_RUN is False: - for policy in attached_policies: - LOGGER.info(f"Detaching {policy['PolicyName']} IAM policy from account {acct} in {region}") - iam.detach_policy(rule_name, policy["PolicyArn"]) - LIVE_RUN_DATA[ - f"{rule_name}_{acct}_{region}_PolicyDetach" - ] = f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - else: - LOGGER.info(f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}") - DRY_RUN_DATA[ - f"{rule_name}_{acct}_{region}_Delete" - ] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" - - # 6) Delete IAM policy - policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" - LOGGER.info(f"Policy ARN: {policy_arn}") - policy_search = iam.check_iam_policy_exists(policy_arn) - if policy_search is True: - if DRY_RUN is False: - LOGGER.info(f"Deleting {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") - iam.delete_policy(policy_arn) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - remove_state_table_record(policy_arn) - else: - LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") - DRY_RUN_DATA[ - f"{rule_name}_{acct}_{region}_PolicyDelete" - ] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" - else: - LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region} does not exist.") + delete_custom_config_rule(rule_name, acct, region) - policy_arn2 = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}" - LOGGER.info(f"Policy ARN: {policy_arn2}") - policy_search = iam.check_iam_policy_exists(policy_arn2) - if policy_search is True: - if DRY_RUN is False: - LOGGER.info(f"Deleting {rule_name} IAM policy for account {acct} in {region}") - iam.delete_policy(policy_arn2) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM policy" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - remove_state_table_record(policy_arn2) - else: - LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}") - DRY_RUN_DATA[ - f"{rule_name}_{acct}_{region}_PolicyDelete" - ] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" - else: - LOGGER.info(f"{rule_name} IAM policy for account {acct} in {region} does not exist.") + # 5, 6, & 7) Detach IAM policies, delete IAM policy, delete IAM execution role for custom config rule lambda + delete_custom_config_iam_role(rule_name, acct) - # 7) Delete IAM execution role for custom config rule lambda - role_search = iam.check_iam_role_exists(rule_name) - if role_search[0] is True: - if DRY_RUN is False: - LOGGER.info(f"Deleting {rule_name} IAM role for account {acct} in {region}") - iam.delete_role(rule_name) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} IAM role" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - remove_state_table_record(role_search[1]) - else: - LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_RoleDelete"] = f"DRY_RUN: Delete {rule_name} IAM role for account {acct} in {region}" - else: - LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) From ed998e0baf6a1220ac715203c6b2f0bffc3ca53a Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 2 Dec 2024 15:36:52 -0700 Subject: [PATCH 228/395] moving metric filters and alarms deletes to separate function (testing) --- .../genai/bedrock_org/lambda/src/app.py | 198 +++++++++--------- 1 file changed, 101 insertions(+), 97 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b6880cf43..b4791c2d0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -722,26 +722,28 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): global LIVE_RUN_DATA global CFN_RESPONSE_DATA LOGGER.info(f"CloudWatch Metric Filters: {CLOUDWATCH_METRIC_FILTERS}") - for filter in CLOUDWATCH_METRIC_FILTERS: - filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, resource_properties) - LOGGER.info(f"{filter} parameters: {filter_params}") + for filter_name in CLOUDWATCH_METRIC_FILTERS: + filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter_name, resource_properties) + LOGGER.info(f"{filter_name} parameters: {filter_params}") if filter_deploy is False: - LOGGER.info(f"{filter} filter not requested (deploy set to false). Skipping...") + LOGGER.info(f"{filter_name} filter not requested (deploy set to false). Checking to see if any need to be removed...") + delete_metric_filter_alarm_topic_and_key(filter_name, acct, region, filter_params) + continue if filter_regions: - LOGGER.info(f"{filter} filter regions: {filter_regions}") + LOGGER.info(f"{filter_name} filter regions: {filter_regions}") if region not in filter_regions: - LOGGER.info(f"{filter} filter not requested for {region}. Skipping...") + LOGGER.info(f"{filter_name} filter not requested for {region}. Skipping...") continue LOGGER.info(f"Raw filter pattern: {CLOUDWATCH_METRIC_FILTERS[filter]}") if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: - LOGGER.info(f"{filter} filter parameter: 'BUCKET_NAME_PLACEHOLDER' found. Updating with bucket info...") + LOGGER.info(f"{filter_name} filter parameter: 'BUCKET_NAME_PLACEHOLDER' found. Updating with bucket info...") filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter]) elif "INPUT_PATH" in CLOUDWATCH_METRIC_FILTERS[filter]: filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter].replace("", filter_params["input_path"]) else: filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter] - LOGGER.info(f"{filter} filter pattern: {filter_pattern}") + LOGGER.info(f"{filter_name} filter pattern: {filter_pattern}") for acct in accounts: # for region in regions: @@ -750,7 +752,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): if filter_accounts: LOGGER.info(f"filter_accounts: {filter_accounts}") if acct not in filter_accounts: - LOGGER.info(f"{filter} filter not requested for {acct}. Skipping...") + LOGGER.info(f"{filter_name} filter not requested for {acct}. Skipping...") continue kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") @@ -1294,6 +1296,93 @@ def delete_custom_config_iam_role(rule_name: str, acct: str): else: LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") +def delete_metric_filter_alarm_topic_and_key(filter_name: str, acct: str, region: str, filter_params: str): + # Delete KMS key (schedule deletion) and delete kms alias + kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) + search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + if search_alarm_kms_key is True: + if DRY_RUN is False: + LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key") + kms.delete_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + remove_state_table_record(alarm_key_arn) + + kms.schedule_key_deletion(kms.KMS_CLIENT, alarm_key_id) + LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Scheduled deletion of {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + kms_key_arn = f"arn:{sts.PARTITION}:kms:{region}:{acct}:key/{alarm_key_id}" + remove_state_table_record(kms_key_arn) + + else: + LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key") + DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key" + LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") + DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" + else: + LOGGER.info(f"{ALARM_SNS_KEY_ALIAS} KMS key does not exist.") + + cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) + cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) + if DRY_RUN is False: + # Delete the CloudWatch metric alarm + LOGGER.info(f"Deleting {filter_name}-alarm CloudWatch metric alarm") + LIVE_RUN_DATA[f"{filter_name}-alarm_CloudWatchDelete"] = f"Deleted {filter_name}-alarm CloudWatch metric alarm" + search_metric_alarm = cloudwatch.find_metric_alarm(f"{filter_name}-alarm") + if search_metric_alarm is True: + cloudwatch.delete_metric_alarm(f"{filter_name}-alarm") + LIVE_RUN_DATA[f"{filter_name}-alarm_CloudWatchDelete"] = f"Deleted {filter_name}-alarm CloudWatch metric alarm" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleted {filter_name}-alarm CloudWatch metric alarm") + metric_alarm_arn = f"arn:{sts.PARTITION}:cloudwatch:{region}:{acct}:alarm:{filter_name}-alarm" + remove_state_table_record(metric_alarm_arn) + else: + LOGGER.info(f"{filter_name}-alarm CloudWatch metric alarm does not exist.") + + # Delete the CloudWatch metric filter + LOGGER.info(f"Deleting {filter_name} CloudWatch metric filter") + LIVE_RUN_DATA[f"{filter_name}_CloudWatchDelete"] = f"Deleted {filter_name} CloudWatch metric filter" + search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter_name) + if search_metric_filter is True: + cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter_name) + LIVE_RUN_DATA[f"{filter_name}_CloudWatchDelete"] = f"Deleted {filter_name} CloudWatch metric filter" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleted {filter_name} CloudWatch metric filter") + metric_filter_arn = f"arn:{sts.PARTITION}:logs:{region}:{acct}:metric-filter:{filter_name}" + remove_state_table_record(metric_filter_arn) + + else: + LOGGER.info(f"{filter_name} CloudWatch metric filter does not exist.") + + else: + LOGGER.info(f"DRY_RUN: Delete {filter_name} CloudWatch metric filter") + DRY_RUN_DATA[f"{filter_name}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter_name} CloudWatch metric filter" + + # Delete the alarm topic + sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) + # TODO(liamschn): this will be a mypy error - need to have alarm_topic_search (sns.find_sns_topic) return string, not None + alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) + if alarm_topic_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {SOLUTION_NAME}-alarms SNS topic") + LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarms SNS topic" + sns.delete_sns_topic(alarm_topic_search) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleted {SOLUTION_NAME}-alarms SNS topic") + remove_state_table_record(alarm_topic_search) + else: + LOGGER.info(f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic" + else: + LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") + def delete_event(event, context): # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. @@ -1419,96 +1508,11 @@ def delete_event(event, context): LOGGER.info("CloudWatch observability access manager sink not found") # 3) Delete metric alarms and filters - # accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) - for filter in CLOUDWATCH_METRIC_FILTERS: - filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter, event["ResourceProperties"]) + for filter_name in CLOUDWATCH_METRIC_FILTERS: + filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter_name, event["ResourceProperties"]) for acct in filter_accounts: for region in filter_regions: - # 3a) Delete KMS key (schedule deletion) and delete kms alias - kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) - search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") - if search_alarm_kms_key is True: - if DRY_RUN is False: - LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key") - kms.delete_alias(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") - LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - LOGGER.info(f"Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") - remove_state_table_record(alarm_key_arn) - - kms.schedule_key_deletion(kms.KMS_CLIENT, alarm_key_id) - LIVE_RUN_DATA["KMSDelete"] = f"Deleted {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - LOGGER.info(f"Scheduled deletion of {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") - kms_key_arn = f"arn:{sts.PARTITION}:kms:{region}:{acct}:key/{alarm_key_id}" - remove_state_table_record(kms_key_arn) - - else: - LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key") - DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key" - LOGGER.info(f"DRY_RUN: Deleting {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})") - DRY_RUN_DATA["KMSDelete"] = f"DRY_RUN: Delete {ALARM_SNS_KEY_ALIAS} KMS key ({alarm_key_id})" - else: - LOGGER.info(f"{ALARM_SNS_KEY_ALIAS} KMS key does not exist.") - - cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) - cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) - if DRY_RUN is False: - # 3b) Delete the CloudWatch metric alarm - LOGGER.info(f"Deleting {filter}-alarm CloudWatch metric alarm") - LIVE_RUN_DATA[f"{filter}-alarm_CloudWatchDelete"] = f"Deleted {filter}-alarm CloudWatch metric alarm" - search_metric_alarm = cloudwatch.find_metric_alarm(f"{filter}-alarm") - if search_metric_alarm is True: - cloudwatch.delete_metric_alarm(f"{filter}-alarm") - LIVE_RUN_DATA[f"{filter}-alarm_CloudWatchDelete"] = f"Deleted {filter}-alarm CloudWatch metric alarm" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - LOGGER.info(f"Deleted {filter}-alarm CloudWatch metric alarm") - metric_alarm_arn = f"arn:{sts.PARTITION}:cloudwatch:{region}:{acct}:alarm:{filter}-alarm" - remove_state_table_record(metric_alarm_arn) - else: - LOGGER.info(f"{filter}-alarm CloudWatch metric alarm does not exist.") - - # 3c) Delete the CloudWatch metric filter - LOGGER.info(f"Deleting {filter} CloudWatch metric filter") - LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" - search_metric_filter = cloudwatch.find_metric_filter(filter_params["log_group_name"], filter) - if search_metric_filter is True: - cloudwatch.delete_metric_filter(filter_params["log_group_name"], filter) - LIVE_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"Deleted {filter} CloudWatch metric filter" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - LOGGER.info(f"Deleted {filter} CloudWatch metric filter") - metric_filter_arn = f"arn:{sts.PARTITION}:logs:{region}:{acct}:metric-filter:{filter}" - remove_state_table_record(metric_filter_arn) - - else: - LOGGER.info(f"{filter} CloudWatch metric filter does not exist.") - - else: - LOGGER.info(f"DRY_RUN: Delete {filter} CloudWatch metric filter") - DRY_RUN_DATA[f"{filter}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter} CloudWatch metric filter" - - # 3d) Delete the alarm topic - sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) - # TODO(liamschn): this will be a mypy error - need to have alarm_topic_search (sns.find_sns_topic) return string, not None - alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) - if alarm_topic_search is not None: - if DRY_RUN is False: - LOGGER.info(f"Deleting {SOLUTION_NAME}-alarms SNS topic") - LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarms SNS topic" - sns.delete_sns_topic(alarm_topic_search) - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - LOGGER.info(f"Deleted {SOLUTION_NAME}-alarms SNS topic") - remove_state_table_record(alarm_topic_search) - else: - LOGGER.info(f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic") - DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic" - else: - LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") + delete_metric_filter_alarm_topic_and_key(filter_name, acct, region, filter_params) # 4) Delete config rules # TODO(liamschn): deal with invalid rule names? From 3de645736659fab37873ee45caabedaf75d1513c Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 2 Dec 2024 16:02:00 -0700 Subject: [PATCH 229/395] update filter to filter_name --- .../genai/bedrock_org/lambda/src/app.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b4791c2d0..a69cff09e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -735,14 +735,14 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): if region not in filter_regions: LOGGER.info(f"{filter_name} filter not requested for {region}. Skipping...") continue - LOGGER.info(f"Raw filter pattern: {CLOUDWATCH_METRIC_FILTERS[filter]}") - if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter]: + LOGGER.info(f"Raw filter pattern: {CLOUDWATCH_METRIC_FILTERS[filter_name]}") + if "BUCKET_NAME_PLACEHOLDER" in CLOUDWATCH_METRIC_FILTERS[filter_name]: LOGGER.info(f"{filter_name} filter parameter: 'BUCKET_NAME_PLACEHOLDER' found. Updating with bucket info...") - filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter]) - elif "INPUT_PATH" in CLOUDWATCH_METRIC_FILTERS[filter]: - filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter].replace("", filter_params["input_path"]) + filter_pattern = build_s3_metric_filter_pattern(filter_params["bucket_names"], CLOUDWATCH_METRIC_FILTERS[filter_name]) + elif "INPUT_PATH" in CLOUDWATCH_METRIC_FILTERS[filter_name]: + filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter_name].replace("", filter_params["input_path"]) else: - filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter] + filter_pattern = CLOUDWATCH_METRIC_FILTERS[filter_name] LOGGER.info(f"{filter_name} filter pattern: {filter_pattern}") for acct in accounts: @@ -901,7 +901,6 @@ def deploy_central_cloudwatch_observability(event): oam_sink_arn = cloudwatch.create_oam_sink(cloudwatch.SINK_NAME) LOGGER.info(f"CloudWatch observability access manager sink created: {oam_sink_arn}") CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_oam_sink: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LIVE_RUN_DATA["OAMSinkCreate"] = "Created CloudWatch observability access manager sink" # add OAM sink state table record @@ -977,8 +976,6 @@ def deploy_central_cloudwatch_observability(event): xacct_role_arn = xacct_role["Role"]["Arn"] LIVE_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_role: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") # add cross account role state table record @@ -1007,7 +1004,6 @@ def deploy_central_cloudwatch_observability(event): f"OamXacctRolePolicyAttach_{policy_arn.split("/")[1]}_{bedrock_account}" ] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - attach_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}") @@ -1025,7 +1021,6 @@ def deploy_central_cloudwatch_observability(event): oam_link_arn = cloudwatch.create_oam_link(oam_sink_arn) LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}_{bedrock_region}"] = f"Created CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - create_oam_link: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info("Created CloudWatch observability access manager link") @@ -1051,8 +1046,6 @@ def deploy_cloudwatch_dashboard(event): cloudwatch_dashboard = build_cloudwatch_dashboard(CLOUDWATCH_DASHBOARD, SOLUTION_NAME, central_observability_params["bedrock_accounts"], central_observability_params["regions"]) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) - # sra-bedrock-filter-prompt-injection-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][2] - # sra-bedrock-filter-sensitive-info-metric template ["sra-bedrock-org"]["widgets"][0]["properties"]["metrics"][3] search_dashboard = cloudwatch.find_dashboard(SOLUTION_NAME) if search_dashboard[0] is False: From c685d283dbbddeb1071e663a2bd9645891b373a6 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 2 Dec 2024 16:17:02 -0700 Subject: [PATCH 230/395] still updating filter to filter_name --- .../genai/bedrock_org/lambda/src/app.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a69cff09e..35e42bfef 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -842,23 +842,23 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms") # 4c) Cloudwatch metric filters and alarms - # metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter}" + # metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter_name}" if DRY_RUN is False: if filter_deploy is True: cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) - LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter} CloudWatch metric filter...") - deploy_metric_filter(region, acct, filter_params["log_group_name"], filter, filter_pattern, f"{filter}-metric", "sra-bedrock", "1") - LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Deployed CloudWatch metric filter" + LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter_name} CloudWatch metric filter...") + deploy_metric_filter(region, acct, filter_params["log_group_name"], filter_name, filter_pattern, f"{filter_name}-metric", "sra-bedrock", "1") + LIVE_RUN_DATA[f"{filter_name}_CloudWatch"] = "Deployed CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"DEBUG: Alarm topic ARN: {SRA_ALARM_TOPIC_ARN}") deploy_metric_alarm( region, acct, - f"{filter}-alarm", - f"{filter}-metric alarm", - f"{filter}-metric", + f"{filter_name}-alarm", + f"{filter_name}-metric alarm", + f"{filter_name}-metric", "sra-bedrock", "Sum", 10, @@ -868,22 +868,22 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): "missing", [SRA_ALARM_TOPIC_ARN], ) - LIVE_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" + LIVE_RUN_DATA[f"{filter_name}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 else: - LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter} CloudWatch metric filter deployment") - LIVE_RUN_DATA[f"{filter}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" + LOGGER.info(f"Filter deploy parameter is 'false'; skipping {filter_name} CloudWatch metric filter deployment") + LIVE_RUN_DATA[f"{filter_name}_CloudWatch"] = "Filter deploy parameter is 'false'; Skipped CloudWatch metric filter deployment" else: if filter_deploy is True: - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric filter...") - DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'true'; Deploy CloudWatch metric filter" - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter} CloudWatch metric alarm...") - DRY_RUN_DATA[f"{filter}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter_name} CloudWatch metric filter...") + DRY_RUN_DATA[f"{filter_name}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'true'; Deploy CloudWatch metric filter" + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'true'; Deploy {filter_name} CloudWatch metric alarm...") + DRY_RUN_DATA[f"{filter_name}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" else: - LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter} CloudWatch metric filter deployment") - DRY_RUN_DATA[f"{filter}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" + LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter_name} CloudWatch metric filter deployment") + DRY_RUN_DATA[f"{filter_name}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" def deploy_central_cloudwatch_observability(event): global DRY_RUN_DATA From eb7465acce1c24cdfa99130fef6fb348d0b30e2f Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 2 Dec 2024 22:04:32 -0700 Subject: [PATCH 231/395] updating delete logic; separating delete filter/alarn from kms/sns topic --- .../genai/bedrock_org/lambda/src/app.py | 61 +++++++++++-------- .../templates/sra-bedrock-org-main.yaml | 30 ++++++--- 2 files changed, 60 insertions(+), 31 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 35e42bfef..3598121dd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -727,8 +727,19 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): LOGGER.info(f"{filter_name} parameters: {filter_params}") if filter_deploy is False: LOGGER.info(f"{filter_name} filter not requested (deploy set to false). Checking to see if any need to be removed...") - delete_metric_filter_alarm_topic_and_key(filter_name, acct, region, filter_params) - + if filter_regions: + LOGGER.info(f"Checking {filter_name} filter in regions: {filter_regions}...") + if region not in filter_regions: + LOGGER.info(f"Check found that {filter_name} filter was not requested for {region}. Skipping region...") + else: + for acct in accounts: + if filter_accounts: + LOGGER.info(f"Checking filter_accounts: {filter_accounts}") + if acct not in filter_accounts: + LOGGER.info(f"Check found that {filter_name} filter not requested for {acct}. Skipping account...") + else: + LOGGER.info(f"Check found that {filter_name} filter was defined for {acct} in {region}; Checking for need to be removed...") + delete_metric_filter_and_alarm(filter_name, acct, region, filter_params) continue if filter_regions: LOGGER.info(f"{filter_name} filter regions: {filter_regions}") @@ -1289,7 +1300,26 @@ def delete_custom_config_iam_role(rule_name: str, acct: str): else: LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") -def delete_metric_filter_alarm_topic_and_key(filter_name: str, acct: str, region: str, filter_params: str): +def delete_sns_topic_and_key(acct: str, region: str): + # Delete the alarm topic + sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) + # TODO(liamschn): this will be a mypy error - need to have alarm_topic_search (sns.find_sns_topic) return string, not None + alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) + if alarm_topic_search is not None: + if DRY_RUN is False: + LOGGER.info(f"Deleting {SOLUTION_NAME}-alarms SNS topic") + LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarms SNS topic" + sns.delete_sns_topic(alarm_topic_search) + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 + LOGGER.info(f"Deleted {SOLUTION_NAME}-alarms SNS topic") + remove_state_table_record(alarm_topic_search) + else: + LOGGER.info(f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic") + DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic" + else: + LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") + # Delete KMS key (schedule deletion) and delete kms alias kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") @@ -1319,6 +1349,8 @@ def delete_metric_filter_alarm_topic_and_key(filter_name: str, acct: str, region else: LOGGER.info(f"{ALARM_SNS_KEY_ALIAS} KMS key does not exist.") + +def delete_metric_filter_and_alarm(filter_name: str, acct: str, region: str, filter_params: dict): cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) if DRY_RUN is False: @@ -1357,26 +1389,6 @@ def delete_metric_filter_alarm_topic_and_key(filter_name: str, acct: str, region LOGGER.info(f"DRY_RUN: Delete {filter_name} CloudWatch metric filter") DRY_RUN_DATA[f"{filter_name}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter_name} CloudWatch metric filter" - # Delete the alarm topic - sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) - # TODO(liamschn): this will be a mypy error - need to have alarm_topic_search (sns.find_sns_topic) return string, not None - alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) - if alarm_topic_search is not None: - if DRY_RUN is False: - LOGGER.info(f"Deleting {SOLUTION_NAME}-alarms SNS topic") - LIVE_RUN_DATA["SNSDelete"] = f"Deleted {SOLUTION_NAME}-alarms SNS topic" - sns.delete_sns_topic(alarm_topic_search) - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - LOGGER.info(f"Deleted {SOLUTION_NAME}-alarms SNS topic") - remove_state_table_record(alarm_topic_search) - else: - LOGGER.info(f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic") - DRY_RUN_DATA["SNSDelete"] = f"DRY_RUN: Delete {SOLUTION_NAME}-alarms SNS topic" - else: - LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic does not exist.") - - def delete_event(event, context): # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. # TODO(liamschn): move re-used delete event operation code to separate functions @@ -1505,7 +1517,8 @@ def delete_event(event, context): filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter_name, event["ResourceProperties"]) for acct in filter_accounts: for region in filter_regions: - delete_metric_filter_alarm_topic_and_key(filter_name, acct, region, filter_params) + delete_metric_filter_and_alarm(filter_name, acct, region, filter_params) + delete_sns_topic_and_key(acct, region) # 4) Delete config rules # TODO(liamschn): deal with invalid rule names? diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index a9b0bf39d..f2f099ef8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -235,8 +235,8 @@ Parameters: 'log_group_name' (non-empty string) and 'bucket_names' (non-empty array of non-empty strings). Example: {"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}} - pBedrockInvocationLogFilterParams: - # TODO(liamschn): update default value of pBedrockInvocationLogFilterParams prior to production + pBedrockPromptInjectionFilterParams: + # TODO(liamschn): update default value of pBedrockPromptInjectionFilterParams prior to production Type: String Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' Description: Bedrock Prompt Injection and Sensitive Info Filter Parameters @@ -247,6 +247,19 @@ Parameters: or for titan: {"deploy": "true", "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.inputText"}} NOTE: input_path is based on the base model used such as clause or titan; check the invocation log InvokeModel messages for details + pBedrockSensitiveInfoFilterParams: + # TODO(liamschn): update default value of pBedrockSensitiveInfoFilterParams prior to production + Type: String + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' + Description: Bedrock Prompt Injection and Sensitive Info Filter Parameters + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$ + ConstraintDescription: > + Must be a valid JSON string containing: 'deploy' (true/false), and 'filter_params' object with + 'log_group_name' (non-empty string). Examples - for claude: {"deploy": "true", "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}} + or for titan: {"deploy": "true", "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.inputText"}} + NOTE: input_path is based on the base model used such as clause or titan; check the invocation log InvokeModel messages for details + + pBedrockCentralObservabilityParams: # TODO(liamschn): update default value of pBedrockCentralObservabilityParams prior to production Type: String @@ -320,7 +333,8 @@ Metadata: Parameters: - pBedrockServiceChangesFilterParams - pBedrockBucketChangesFilterParams - - pBedrockInvocationLogFilterParams + - pBedrockPromptInjectionFilterParams + - pBedrockSensitiveInfoFilterParams - Label: default: Bedrock Central Observability Parameters: @@ -371,8 +385,10 @@ Metadata: default: Bedrock Service Changes Filter Parameters pBedrockBucketChangesFilterParams: default: Bedrock S3 Bucket Changes Filter Parameters - pBedrockInvocationLogFilterParams: - default: Bedrock Prompt Injection and Sensitive Info Filter Parameters + pBedrockPromptInjectionFilterParams: + default: Bedrock Prompt Injection Filter Parameters + pBedrockSensitiveInfoFilterParams: + default: Bedrock Sensitive Info Filter Parameters pBedrockCentralObservabilityParams: default: Bedrock Central Observability Parameters pBedrockAccounts: @@ -444,8 +460,8 @@ Resources: SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION: !Ref pBedrockGuardrailEncryptionRuleParams SRA-BEDROCK-FILTER-SERVICE-CHANGES: !Ref pBedrockServiceChangesFilterParams SRA-BEDROCK-FILTER-BUCKET-CHANGES: !Ref pBedrockBucketChangesFilterParams - SRA-BEDROCK-FILTER-PROMPT-INJECTION: !Ref pBedrockInvocationLogFilterParams - SRA-BEDROCK-FILTER-SENSITIVE-INFO: !Ref pBedrockInvocationLogFilterParams + SRA-BEDROCK-FILTER-PROMPT-INJECTION: !Ref pBedrockPromptInjectionFilterParams + SRA-BEDROCK-FILTER-SENSITIVE-INFO: !Ref pBedrockSensitiveInfoFilterParams SRA-BEDROCK-CENTRAL-OBSERVABILITY: !Ref pBedrockCentralObservabilityParams rBedrockOrgLambdaInvokePermission: From fb14c2df48b69bef4e995e1aab9120a896890282 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 3 Dec 2024 08:52:16 -0700 Subject: [PATCH 232/395] add lambda function record to state table --- .../genai/bedrock_org/lambda/src/app.py | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 3598121dd..b3090a99e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -293,8 +293,6 @@ def get_rule_params(rule_name, resource_properties): rule_regions (list): list of regions to deploy the rule to rule_input_params (dict): dictionary of rule input parameters """ - # rule_accounts (list): list of accounts to deploy the rule to - # rule_regions (list): list of regions to deploy the rule to if rule_name.upper() in resource_properties: LOGGER.info(f"{rule_name} parameter found in event ResourceProperties") @@ -501,7 +499,6 @@ def add_state_table_record(aws_service: str, component_state: str, description: """ LOGGER.info(f"Add a record to the state table for {component_name}") # TODO(liamschn): check to ensure we got a 200 back from the service API call before inserting the dynamodb records - dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) item_found, find_result = dynamodb.find_item( @@ -533,6 +530,7 @@ def add_state_table_record(aws_service: str, component_state: str, description: "date_time": dynamodb.get_date_time(), }, ) + return sra_resource_record_id def remove_state_table_record(resource_arn): @@ -544,7 +542,6 @@ def remove_state_table_record(resource_arn): Returns: response: response from dynamodb delete_item """ - # TODO(liamschn): move dynamodb resource to the dynamo class object/module dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) LOGGER.info(f"Searching for {resource_arn} in {STATE_TABLE} dynamodb table...") try: @@ -568,6 +565,21 @@ def remove_state_table_record(resource_arn): response = {} return response +def update_state_table_record(record_id: str, update_data: dict): + dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) + + try: + dynamodb.update_item( + STATE_TABLE, + SOLUTION_NAME, + record_id, + update_data, + ) + except Exception as error: + LOGGER.error(f"Error updating {record_id} record in {STATE_TABLE} dynamodb table: {error}") + response = {} + return + def deploy_stage_config_rule_lambda_code(): global DRY_RUN_DATA @@ -624,21 +636,7 @@ def deploy_sns_configuration_topics(context): else: DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Created {SOLUTION_NAME}-configuration SNS topic" DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Added lambda sns-invoke permissions for SNS topic" - DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" - - # else: - # LOGGER.info(f"DRY_RUN: Creating {SOLUTION_NAME}-configuration SNS topic") - # DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-configuration SNS topic" - - # LOGGER.info( - # f"DRY_RUN: Creating SNS topic policy permissions for {SOLUTION_NAME}-configuration SNS topic on {context.function_name} lambda function" - # ) - # DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Add lambda sns-invoke permissions for SNS topic" - - # LOGGER.info(f"DRY_RUN: Subscribing {context.invoked_function_arn} to {SOLUTION_NAME}-configuration SNS topic") - # DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribe {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" - # topic_arn = f"arn:aws:sns:{sts.HOME_REGION}:{ACCOUNT}:{SOLUTION_NAME}-configuration" - + DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search @@ -1976,6 +1974,13 @@ def lambda_handler(event, context): "dry_run_data": DRY_RUN_DATA, } LAMBDA_FINISH = dynamodb.get_date_time() + record_id = add_state_table_record("lambda", "implemented", "bedrock solution function", "lambda", context.invoked_function_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, context.function_name) + lambda_data = { + "start_time": LAMBDA_START, + "end_time": LAMBDA_FINISH, + "lambda_result": "SUCCESS", + } + update_state_table_record(record_id, lambda_data) return { "statusCode": 200, "lambda_start": LAMBDA_START, From 3b974b5a4e176507f813d6b9e6628cf7c41158e5 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 3 Dec 2024 09:38:17 -0700 Subject: [PATCH 233/395] add delete operations for lambda function and iam execution role state records --- .../genai/bedrock_org/lambda/src/app.py | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b3090a99e..b0694781e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -82,6 +82,7 @@ def load_sra_cloudwatch_dashboard() -> dict: SRA_ALARM_TOPIC_ARN: str = "" STATE_TABLE: str = "sra_state" # for saving resource info +LAMBDA_RECORD_ID: str = "" LAMBDA_START: str = "" LAMBDA_FINISH: str = "" @@ -1112,7 +1113,7 @@ def create_event(event, context): global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA - + global LAMBDA_RECORD_ID global SRA_ALARM_TOPIC_ARN DRY_RUN_DATA = {} LIVE_RUN_DATA = {} @@ -1124,6 +1125,15 @@ def create_event(event, context): # TODO(liamschn): need to ensure the solution name for the state table record is sra-common-prerequisites (if it is created here), not bedrock deploy_state_table() LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_state_table: {CFN_RESPONSE_DATA}") + # add IAM state table record for the lambda execution role + execution_role_name = os.environ["AWS_LAMBDA_FUNCTION_NAME"] + execution_role_arn = f"arn:aws:iam::{sts.MANAGEMENT_ACCOUNT}:role/{execution_role_name}" + LOGGER.info(f"Adding state table record for lambda IAM execution role: {execution_role_arn}") + add_state_table_record("iam", "implemented", "lambda execution role", "role", execution_role_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, execution_role_name) + # add lambda function state table record + LOGGER.info(f"Adding state table record for lambda function: {context.invoked_function_arn}") + LAMBDA_RECORD_ID = add_state_table_record("lambda", "implemented", "bedrock solution function", "lambda", context.invoked_function_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, context.function_name) + # 1) Stage config rule lambda code (global/home region) deploy_stage_config_rule_lambda_code() @@ -1535,6 +1545,13 @@ def delete_event(event, context): # 5, 6, & 7) Detach IAM policies, delete IAM policy, delete IAM execution role for custom config rule lambda delete_custom_config_iam_role(rule_name, acct) + + execution_role_name = os.environ["AWS_LAMBDA_FUNCTION_NAME"] + execution_role_arn = f"arn:aws:iam::{sts.MANAGEMENT_ACCOUNT}:role/{execution_role_name}" + LOGGER.info(f"Removing state table record for lambda IAM execution role: {execution_role_arn}") + remove_state_table_record(execution_role_arn) + LOGGER.info(f"Removing state table record for lambda function: {context.invoked_function_arn}") + remove_state_table_record(context.invoked_function_arn) # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: @@ -1926,6 +1943,7 @@ def lambda_handler(event, context): global RESOURCE_TYPE global LAMBDA_START global LAMBDA_FINISH + global LAMBDA_RECORD_ID LAMBDA_START = dynamodb.get_date_time() LOGGER.info(event) LOGGER.info({"boto3 version": boto3.__version__}) @@ -1974,13 +1992,12 @@ def lambda_handler(event, context): "dry_run_data": DRY_RUN_DATA, } LAMBDA_FINISH = dynamodb.get_date_time() - record_id = add_state_table_record("lambda", "implemented", "bedrock solution function", "lambda", context.invoked_function_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, context.function_name) lambda_data = { "start_time": LAMBDA_START, "end_time": LAMBDA_FINISH, "lambda_result": "SUCCESS", } - update_state_table_record(record_id, lambda_data) + update_state_table_record(LAMBDA_RECORD_ID, lambda_data) return { "statusCode": 200, "lambda_start": LAMBDA_START, From 2a282917e82ff17d46054530f092a4f05b584eb1 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 3 Dec 2024 11:47:15 -0700 Subject: [PATCH 234/395] update execution role arn for state record --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 ++---- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 8 +++++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index b0694781e..5208ac9ae 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1126,8 +1126,7 @@ def create_event(event, context): deploy_state_table() LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_state_table: {CFN_RESPONSE_DATA}") # add IAM state table record for the lambda execution role - execution_role_name = os.environ["AWS_LAMBDA_FUNCTION_NAME"] - execution_role_arn = f"arn:aws:iam::{sts.MANAGEMENT_ACCOUNT}:role/{execution_role_name}" + execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) LOGGER.info(f"Adding state table record for lambda IAM execution role: {execution_role_arn}") add_state_table_record("iam", "implemented", "lambda execution role", "role", execution_role_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, execution_role_name) # add lambda function state table record @@ -1546,8 +1545,7 @@ def delete_event(event, context): # 5, 6, & 7) Detach IAM policies, delete IAM policy, delete IAM execution role for custom config rule lambda delete_custom_config_iam_role(rule_name, acct) - execution_role_name = os.environ["AWS_LAMBDA_FUNCTION_NAME"] - execution_role_arn = f"arn:aws:iam::{sts.MANAGEMENT_ACCOUNT}:role/{execution_role_name}" + execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) LOGGER.info(f"Removing state table record for lambda IAM execution role: {execution_role_arn}") remove_state_table_record(execution_role_arn) LOGGER.info(f"Removing state table record for lambda function: {context.invoked_function_arn}") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 382b21363..295937620 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -188,4 +188,10 @@ def delete_lambda_function(self, function_name): return response except ClientError as e: self.LOGGER.error(e) - return None \ No newline at end of file + return None + + def get_lambda_execution_role(self, function_name) -> str: + response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) + execution_role_arn = response['Configuration']['Role'] + self.LOGGER.info(f"Execution Role ARN: {execution_role_arn}") + return execution_role_arn From 652e602dd4125ac695d979bce3112346c5cf3ba1 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 3 Dec 2024 11:50:55 -0700 Subject: [PATCH 235/395] update get execution role function --- .../bedrock_org/lambda/src/sra_lambda.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 295937620..1eea860de 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -190,8 +190,21 @@ def delete_lambda_function(self, function_name): self.LOGGER.error(e) return None - def get_lambda_execution_role(self, function_name) -> str: - response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) - execution_role_arn = response['Configuration']['Role'] - self.LOGGER.info(f"Execution Role ARN: {execution_role_arn}") - return execution_role_arn + def get_lambda_execution_role(self, function_name) -> str: + """Get Lambda Function Execution Role. + + Args: + function_name (str): Lambda Function Name + + Returns: + str: Execution Role ARN + """ + self.LOGGER.info(f"Getting execution role for Lambda function: {function_name}") + try: + response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) + execution_role_arn = response['Configuration']['Role'] + self.LOGGER.info(f"Execution Role ARN: {execution_role_arn}") + return execution_role_arn + except ClientError as e: + self.LOGGER.error(e) + return "Error" \ No newline at end of file From 6dbc72b2d62f43cc88654dd980f8434e67862b9b Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 3 Dec 2024 12:04:35 -0700 Subject: [PATCH 236/395] updating execution role name for state record --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 5208ac9ae..bf5c47f05 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1127,6 +1127,7 @@ def create_event(event, context): LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_state_table: {CFN_RESPONSE_DATA}") # add IAM state table record for the lambda execution role execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) + execution_role_name = execution_role_arn.split("/")[-1] LOGGER.info(f"Adding state table record for lambda IAM execution role: {execution_role_arn}") add_state_table_record("iam", "implemented", "lambda execution role", "role", execution_role_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, execution_role_name) # add lambda function state table record From 74d205743246b444fa55074f63bb217bb141aab8 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 3 Dec 2024 13:40:44 -0700 Subject: [PATCH 237/395] add/remove cw dashboard state table record --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index bf5c47f05..6206489be 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1062,15 +1062,19 @@ def deploy_cloudwatch_dashboard(event): if DRY_RUN is False: LOGGER.info("CloudWatch observability dashboard not found, creating...") cloudwatch.create_dashboard(cloudwatch.SOLUTION_NAME, cloudwatch_dashboard) + search_dashboard = cloudwatch.find_dashboard(SOLUTION_NAME) LIVE_RUN_DATA["CloudWatchDashboardCreate"] = "Created CloudWatch observability dashboard" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info("Created CloudWatch observability dashboard") + # add dashboard state table record + add_state_table_record("cloudwatch", "implemented", "cloudwatch dashboard", "dashboard", search_dashboard[1], ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, SOLUTION_NAME) else: LOGGER.info("DRY_RUN: CloudWatch observability dashboard not found, creating...") DRY_RUN_DATA["CloudWatchDashboardCreate"] = "DRY_RUN: Create CloudWatch observability dashboard" else: LOGGER.info(f"Cloudwatch dashboard already exists: {search_dashboard[1]}") + add_state_table_record("cloudwatch", "implemented", "cloudwatch dashboard", "dashboard", search_dashboard[1], ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, SOLUTION_NAME) # check_dashboard = cloudwatch.compare_dashboard(search_dashboard[1], cloudwatch_dashboard) # if check_dashboard is False: # if DRY_RUN is False: @@ -1102,11 +1106,13 @@ def remove_cloudwatch_dashboard(): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 LOGGER.info("Deleted CloudWatch observability dashboard") + remove_state_table_record(search_dashboard[1]) else: LOGGER.info("DRY_RUN: CloudWatch observability dashboard found, needs to be deleted...") DRY_RUN_DATA["CloudWatchDashboardDelete"] = "DRY_RUN: Delete CloudWatch observability dashboard" else: LOGGER.info(f"Cloudwatch dashboard not found...") + remove_state_table_record(f"arn:aws:cloudwatch::{ssm_params.SRA_SECURITY_ACCT}:dashboard/{SOLUTION_NAME}") def create_event(event, context): From 7560580efaa47f451c7aa215a38992714b96db39 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 3 Dec 2024 14:04:20 -0700 Subject: [PATCH 238/395] removed hardcoded aws partition --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index f2f099ef8..cd87f5766 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -470,7 +470,7 @@ Resources: FunctionName: !Ref rBedrockOrgLambdaFunction Action: lambda:InvokeFunction Principal: cloudformation.amazonaws.com - SourceArn: !Sub 'arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stackSet/${AWS::StackName}/*' + SourceArn: !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stackSet/${AWS::StackName}/*' Outputs: BedrockOrgLambdaFunctionArn: From aabdf4634f3b8347a2e7f5b47b59b1cc4203ac9d Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 3 Dec 2024 14:43:10 -0700 Subject: [PATCH 239/395] check for permissions on lambda first --- .../solutions/genai/bedrock_org/lambda/src/app.py | 9 ++++++--- .../genai/bedrock_org/lambda/src/sra_lambda.py | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 6206489be..e1a862e3d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -612,7 +612,6 @@ def deploy_sns_configuration_topics(context): sns.SNS_CLIENT = sts.assume_role(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "sns", sts.HOME_REGION) topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is None: - # if DRY_RUN is False: LOGGER.info(f"Creating {SOLUTION_NAME}-configuration SNS topic") topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-configuration SNS topic" @@ -620,8 +619,12 @@ def deploy_sns_configuration_topics(context): CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") - # TODO(liamschn): search for permissions on lambda before adding the policy - lambdas.put_permissions(context.function_name, "sns-invoke", "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) + statement_name = "sra-sns-invoke" + if lambdas.find_permission(context.function_name, statement_name) is False: + LOGGER.info(f"Adding lambda {statement_name} permissions for SNS topic") + lambdas.put_permissions(context.function_name, statement_name, "sns.amazonaws.com", "lambda:InvokeFunction", topic_arn) + else: + LOGGER.info(f"Lambda {statement_name} permissions already exist for SNS topic") LIVE_RUN_DATA["SNSPermissions"] = "Added lambda sns-invoke permissions for SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 1eea860de..b2a022b77 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -207,4 +207,17 @@ def get_lambda_execution_role(self, function_name) -> str: return execution_role_arn except ClientError as e: self.LOGGER.error(e) - return "Error" \ No newline at end of file + return "Error" + + def find_permission(self, function_name, statement_id): + """Find Lambda Function Permissions.""" + try: + response = self.LAMBDA_CLIENT.get_policy(FunctionName=function_name) + policy = response["Policy"] + if statement_id in policy: + return True + else: + return False + except ClientError as e: + self.LOGGER.error(e) + return False \ No newline at end of file From fe81294c118eba0b852a67f96d2a3a38fb9d48f5 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 09:37:18 -0700 Subject: [PATCH 240/395] infer execution role arn on delete --- .../solutions/genai/bedrock_org/lambda/src/app.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index e1a862e3d..0ddd3bf73 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1554,8 +1554,8 @@ def delete_event(event, context): # 5, 6, & 7) Detach IAM policies, delete IAM policy, delete IAM execution role for custom config rule lambda delete_custom_config_iam_role(rule_name, acct) - - execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) + # Must infer the execution role arn because the function is being reported as non-existent at this point + execution_role_arn = f"arn:aws:iam::{sts.MANAGEMENT_ACCOUNT}:role/{SOLUTION_NAME}-lambda" LOGGER.info(f"Removing state table record for lambda IAM execution role: {execution_role_arn}") remove_state_table_record(execution_role_arn) LOGGER.info(f"Removing state table record for lambda function: {context.invoked_function_arn}") @@ -1828,8 +1828,12 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: if config_rule_search[0] is False: if DRY_RUN is False: LOGGER.info(f"Creating Config policy permissions for {rule_name} lambda function in {account_id} in {region}...") - # TODO(liamschn): search for permissions on lambda before adding the policy - lambdas.put_permissions_acct(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", account_id) + statement_id = "sra-config-invoke" + if lambdas.find_permission(rule_name, statement_id) is False: + LOGGER.info(f"Adding {statement_id} to {rule_name} lambda function in {account_id} in {region}...") + lambdas.put_permissions_acct(rule_name, "config-invoke", "config.amazonaws.com", "lambda:InvokeFunction", account_id) + else: + LOGGER.info(f"{statement_id} already exists on {rule_name} lambda function in {account_id} in {region}...") LOGGER.info(f"Creating {rule_name} config rule in {account_id} in {region}...") # TODO(liamschn): Determine if we need to add a description for the config rules config_response = config.create_config_rule( From 87fda8bc7044209f9af0a74bea335be5afd9ffae Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 12:10:50 -0700 Subject: [PATCH 241/395] fixing ResourceNotFoundException bug (in progress) --- .../bedrock_org/lambda/src/sra_lambda.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index b2a022b77..4e9306a99 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -55,7 +55,14 @@ class sra_lambda: raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def find_lambda_function(self, function_name): - """Find Lambda Function.""" + """Find Lambda Function. + + Args: + function_name: Lambda function name + + Returns: + Lambda function details if found, else None + """ try: response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) return response @@ -107,7 +114,6 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler else: self.LOGGER.info(f"Error deploying Lambda function: {error}") break - # txt_response.insert(tk.END, f"Error deploying Lambda: {e}\n") try: retries = 0 while retries < max_retries: @@ -118,11 +124,14 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler # TODO(liamschn): need to add a maximum retry mechanism here retries += 1 sleep(5) - except Exception as e: - self.LOGGER.info(f"Error getting Lambda function: {e}") - - # except ClientError as e: - # self.LOGGER.error(e) + # TODO(liamschn): fix bug for ResourceNotFoundException found while working on least privilege access on role (in progress) + except ClientError as e: + if e.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"Lambda function {function_name} not found. Retrying...") + sleep(5) + else: + self.LOGGER.info(f"Error getting Lambda function: {e}") + raise ValueError(f"Error getting Lambda function: {e}") from None return get_response def get_permissions(self, function_name): From ac2422536c6292aed885e77e76b04f04efe4e2b8 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 12:12:32 -0700 Subject: [PATCH 242/395] working on function not found bug --- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 4e9306a99..f79affb57 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -128,6 +128,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": self.LOGGER.info(f"Lambda function {function_name} not found. Retrying...") + retries += 1 sleep(5) else: self.LOGGER.info(f"Error getting Lambda function: {e}") From a4a628e377a0e748b67c49f01960bf974fb79059 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 12:29:54 -0700 Subject: [PATCH 243/395] add tracing for lambda bug --- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index f79affb57..d970fe5e2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -80,6 +80,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler retries = 0 self.LOGGER.info(f"Size of {code_zip_file} is {os.path.getsize(code_zip_file)} bytes") while retries < max_retries: + self.LOGGER.info(f"Create function attempt {retries+1} of {max_retries}...") try: create_response = self.LAMBDA_CLIENT.create_function( FunctionName=function_name, @@ -117,6 +118,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler try: retries = 0 while retries < max_retries: + self.LOGGER.info(f"Search for function attempt {retries+1} of {max_retries}...") get_response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) if get_response["Configuration"]["State"] == "Active": self.LOGGER.info(f"Lambda function {function_name} is now active") From 0e0a486601dc6c961eebfa79d856a048a84af01b Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 12:53:22 -0700 Subject: [PATCH 244/395] rearranging code for retries --- .../bedrock_org/lambda/src/sra_lambda.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index d970fe5e2..893f76c4a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -115,26 +115,28 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler else: self.LOGGER.info(f"Error deploying Lambda function: {error}") break - try: - retries = 0 - while retries < max_retries: + retries = 0 + while retries < max_retries: + try: self.LOGGER.info(f"Search for function attempt {retries+1} of {max_retries}...") get_response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) if get_response["Configuration"]["State"] == "Active": self.LOGGER.info(f"Lambda function {function_name} is now active") break # TODO(liamschn): need to add a maximum retry mechanism here + else: + self.LOGGER.info(f"{function_name} lambda function state is {get_response["Configuration"]["State"]}. Waiting to retry...") retries += 1 sleep(5) - # TODO(liamschn): fix bug for ResourceNotFoundException found while working on least privilege access on role (in progress) - except ClientError as e: - if e.response["Error"]["Code"] == "ResourceNotFoundException": - self.LOGGER.info(f"Lambda function {function_name} not found. Retrying...") - retries += 1 - sleep(5) - else: - self.LOGGER.info(f"Error getting Lambda function: {e}") - raise ValueError(f"Error getting Lambda function: {e}") from None + # TODO(liamschn): fix bug for ResourceNotFoundException found while working on least privilege access on role (in progress) + except ClientError as e: + if e.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"Lambda function {function_name} not found. Retrying...") + retries += 1 + sleep(5) + else: + self.LOGGER.info(f"Error getting Lambda function: {e}") + raise ValueError(f"Error getting Lambda function: {e}") from None return get_response def get_permissions(self, function_name): From a8a55f024651ae855008ee106ff5364dc962ddc2 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 15:24:41 -0700 Subject: [PATCH 245/395] update kms permissions (malformed) --- .../solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json index a000fe94d..7a0749438 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json @@ -16,7 +16,7 @@ "Sid": "Allow CloudWatch SNS CMK Access", "Effect": "Allow", "Principal": { - "Service": ["cloudwatch.amazonaws.com"] + "Service": "cloudwatch.amazonaws.com" }, "Action": ["kms:Decrypt", "kms:GenerateDataKey*"], "Resource": "*" From d5ddc07e8a3ed8cf989f64848a210b0b5e7e3597 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 15:54:58 -0700 Subject: [PATCH 246/395] updating kms key policy --- .../solutions/genai/bedrock_org/lambda/src/app.py | 3 +++ .../genai/bedrock_org/lambda/src/sra_kms_keys.json | 13 ++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0ddd3bf73..46fb147bf 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -780,6 +780,9 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"][ "AWS" ].replace("ACCOUNT_ID", acct) + + execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) + kms_key_policy["Statement"][2]["Principal"]["AWS"] = execution_role_arn LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption") LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json index 7a0749438..20677a266 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms_keys.json @@ -20,7 +20,18 @@ }, "Action": ["kms:Decrypt", "kms:GenerateDataKey*"], "Resource": "*" - } + }, + { + "Sid": "Allow IAM Role Full Access", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" + }, + "Action": [ + "kms:*" + ], + "Resource": "*" + } ] } } From 97ed3c6ae9524131ce84eacd0cefa4d546af13c5 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 16:08:47 -0700 Subject: [PATCH 247/395] update kms policy execution role statement --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 46fb147bf..19899e56e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -724,6 +724,9 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): global LIVE_RUN_DATA global CFN_RESPONSE_DATA LOGGER.info(f"CloudWatch Metric Filters: {CLOUDWATCH_METRIC_FILTERS}") + lambdas.LAMBDA_CLIENT = sts.assume_role_resource(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "lambda", sts.HOME_REGION) + execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) + for filter_name in CLOUDWATCH_METRIC_FILTERS: filter_deploy, filter_accounts, filter_regions, filter_params = get_filter_params(filter_name, resource_properties) LOGGER.info(f"{filter_name} parameters: {filter_params}") @@ -781,7 +784,6 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): "AWS" ].replace("ACCOUNT_ID", acct) - execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) kms_key_policy["Statement"][2]["Principal"]["AWS"] = execution_role_arn LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption") From e333e124c80c9309f3ba705f86d6ff4b7fa41fe5 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 16:19:26 -0700 Subject: [PATCH 248/395] update lambda client --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 19899e56e..1ea8a41ab 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -724,7 +724,7 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): global LIVE_RUN_DATA global CFN_RESPONSE_DATA LOGGER.info(f"CloudWatch Metric Filters: {CLOUDWATCH_METRIC_FILTERS}") - lambdas.LAMBDA_CLIENT = sts.assume_role_resource(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "lambda", sts.HOME_REGION) + lambdas.LAMBDA_CLIENT = sts.assume_role(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "lambda", sts.HOME_REGION) execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) for filter_name in CLOUDWATCH_METRIC_FILTERS: From cb8f50f97066b00b1b63892515130dda222d67bf Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 16:47:17 -0700 Subject: [PATCH 249/395] update for lambda data update in state table --- .../genai/bedrock_org/lambda/src/app.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 1ea8a41ab..c0d7187b3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -2009,12 +2009,27 @@ def lambda_handler(event, context): "dry_run_data": DRY_RUN_DATA, } LAMBDA_FINISH = dynamodb.get_date_time() + lambda_data = { "start_time": LAMBDA_START, "end_time": LAMBDA_FINISH, "lambda_result": "SUCCESS", } - update_state_table_record(LAMBDA_RECORD_ID, lambda_data) + + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + SOLUTION_NAME, + { + "arn": context.invoked_function_arn, + }, + ) + + if item_found is True: + sra_resource_record_id = find_result["record_id"] + update_state_table_record(sra_resource_record_id, lambda_data) + else: + LOGGER.info(f"Lambda record not found in {STATE_TABLE} table so unable to update it.") + return { "statusCode": 200, "lambda_start": LAMBDA_START, From de15f9ca75c9334b82e9ecfe29637575439d1004 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 4 Dec 2024 17:12:52 -0700 Subject: [PATCH 250/395] initial work for least privilege lambda execution role (still work to be done) --- .../templates/sra-bedrock-org-main.yaml | 184 +++++++++++++++++- 1 file changed, 182 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index cd87f5766..6981eae28 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -410,13 +410,193 @@ Resources: - lambda.amazonaws.com Action: - 'sts:AssumeRole' + Policies: + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'config:DescribeConfigRules' + - 'config:PutConfigRule' + - 'config:DeleteConfigRule' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-config-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'dynamodb:DescribeTable' + - 'dynamodb:CreateTable' + - 'dynamodb:TagResource' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-dynamodb-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'iam:ListAttachedRolePolicies' + - 'iam:GetRole' + - 'iam:AttachRolePolicy' + - 'iam:DetachRolePolicy' + - 'iam:CreateRole' + - 'iam:DeleteRole' + - 'iam:TagRole' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-iam-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'kms:CreateKey' + - 'kms:ListAliases' + - 'kms:DeleteAlias' + - 'kms:CreateAlias' + - 'kms:Decrypt' + - 'kms:GenerateDataKey' + - 'kms:ScheduleKeyDeletion' + - 'kms:TagResource' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-kms-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'lambda:AddPermission' + - 'lambda:GetFunction' + - 'lambda:GetPolicy' + - 'lambda:TagResource' + - 'lambda:CreateFunction' + - 'lambda:UpdateFunctionConfiguration' + - 'lambda:DeleteFunction' + - 'lambda:CreateAlias' + - 'lambda:UpdateFunctionCode' + - 'lambda:RemovePermission' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-lambda-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'logs:CreateLogStream' + - 'logs:PutMetricFilter' + - 'logs:DeleteMetricFilter' + - 'logs:DescribeMetricFilters' + - 'logs:TagResource' + - 'logs:Link' + Resource: '*' + # arn:aws:logs:::log-group:* + PolicyName: !Sub '${pSRASolutionName}-logs-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'cloudwatch:DescribeAlarms' + - 'cloudwatch:PutMetricAlarm' + - 'cloudwatch:DeleteAlarms' + - 'cloudwatch:TagResource' + - 'cloudwatch:Link' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-cloudwatch-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'oam:ListLinks' + - 'oam:CreateLink' + - 'oam:DeleteLink' + - 'oam:TagResource' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-oam-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'xray:Link' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-xray-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'organizations:DescribeOrganization' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-organizations-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'sns:GetTopicAttributes' + - 'sns:CreateTopic' + - 'sns:Subscribe' + - 'sns:DeleteTopic' + - 'sns:SetTopicAttributes' + - 'sns:TagResource' + - 'sns:Publish' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-sns-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'ssm:GetParameter' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-ssm-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'sts:AssumeRole' + - 'sts:GetCallerIdentity' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-sts-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 's3:GetObject' + - 's3:HeadObject' + - 's3:PutObject' + Resource: '*' + PolicyName: !Sub '${pSRASolutionName}-s3-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'applicationinsights:Link' + Resource: '*' + # applicationinsights:Link on resource: arn:aws:applicationinsights:::application/* + PolicyName: !Sub '${pSRASolutionName}-appinsights-policy' + - PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'internetmonitor:Link' + Resource: '*' + # internetmonitor:Link on resource: arn:aws:internetmonitor:::monitor/* + PolicyName: !Sub '${pSRASolutionName}-internetmonitor-policy' + Tags: - Key: sra-solution Value: !Ref pSRASolutionName ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - # TODO(liamschn): least privilege policies need to be created for this lambda role - - arn:aws:iam::aws:policy/AdministratorAccess + # TODO(liamschn): least privilege policies need to be created for this lambda role (in progress) + # - arn:aws:iam::aws:policy/AdministratorAccess rBedrockOrgLambdaFunction: Type: AWS::Lambda::Function From 3fec5a117e0b04ab2236074afdfc69b78c52b018 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 5 Dec 2024 15:25:21 -0700 Subject: [PATCH 251/395] add tracing; update permissions --- .../bedrock_org/lambda/src/sra_cloudwatch.py | 2 +- .../templates/sra-bedrock-org-main.yaml | 29 ++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 17b4d1e68..921913d21 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -68,7 +68,7 @@ def find_metric_filter(self, log_group_name: str, filter_name: str) -> bool: if error.response["Error"]["Code"] == "ResourceNotFoundException": return False else: - self.LOGGER.info(self.UNEXPECTED) + self.LOGGER.info(f"{self.UNEXPECTED} error finding metric filter: {error}") raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def create_metric_filter( diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 6981eae28..696f62a27 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -443,7 +443,9 @@ Resources: - 'iam:CreateRole' - 'iam:DeleteRole' - 'iam:TagRole' - Resource: '*' + Resource: + - !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/CloudWatch-CrossAccountSharingRole' + - !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${pSRASolutionName}-lambda' PolicyName: !Sub '${pSRASolutionName}-iam-policy' - PolicyDocument: Version: '2012-10-17' @@ -458,7 +460,7 @@ Resources: - 'kms:GenerateDataKey' - 'kms:ScheduleKeyDeletion' - 'kms:TagResource' - Resource: '*' + Resource: '*' # required because of CreateKey operation PolicyName: !Sub '${pSRASolutionName}-kms-policy' - PolicyDocument: Version: '2012-10-17' @@ -475,7 +477,8 @@ Resources: - 'lambda:CreateAlias' - 'lambda:UpdateFunctionCode' - 'lambda:RemovePermission' - Resource: '*' + Resource: + - !Sub 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${pSRASolutionName}' PolicyName: !Sub '${pSRASolutionName}-lambda-policy' - PolicyDocument: Version: '2012-10-17' @@ -488,8 +491,11 @@ Resources: - 'logs:DescribeMetricFilters' - 'logs:TagResource' - 'logs:Link' - Resource: '*' - # arn:aws:logs:::log-group:* + Resource: + - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:metric-filter:*' + - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*:log-stream:*' + # - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:metric-filter:sra-bedrock-filter-service-changes' + # - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:metric-filter:sra-bedrock-filter-bucket-changes' PolicyName: !Sub '${pSRASolutionName}-logs-policy' - PolicyDocument: Version: '2012-10-17' @@ -501,7 +507,10 @@ Resources: - 'cloudwatch:DeleteAlarms' - 'cloudwatch:TagResource' - 'cloudwatch:Link' - Resource: '*' + Resource: '*' + # - !Sub 'arn:${AWS::Partition}:cloudwatch:${AWS::Region}:${AWS::AccountId}:alarm:*' + # - !Sub 'arn:${AWS::Partition}:cloudwatch:${AWS::Region}:${AWS::AccountId}:alarm:sra-bedrock-filter-service-changes-alarm' + # - !Sub 'arn:${AWS::Partition}:cloudwatch:${AWS::Region}:${AWS::AccountId}:alarm:sra-bedrock-filter-bucket-changes-alarm' PolicyName: !Sub '${pSRASolutionName}-cloudwatch-policy' - PolicyDocument: Version: '2012-10-17' @@ -512,7 +521,9 @@ Resources: - 'oam:CreateLink' - 'oam:DeleteLink' - 'oam:TagResource' - Resource: '*' + Resource: + - !Sub 'arn:${AWS::Partition}:oam:${AWS::Region}:${AWS::AccountId}:link/*' + - !Sub 'arn:${AWS::Partition}:oam:${AWS::Region}:${AWS::AccountId}:/ListLinks*' PolicyName: !Sub '${pSRASolutionName}-oam-policy' - PolicyDocument: Version: '2012-10-17' @@ -542,7 +553,9 @@ Resources: - 'sns:SetTopicAttributes' - 'sns:TagResource' - 'sns:Publish' - Resource: '*' + Resource: + - !Sub 'arn:${AWS::Partition}:sns:${AWS::Region}:${AWS::AccountId}:${pSRASolutionName}-configuration' + - !Sub 'arn:${AWS::Partition}:sns:${AWS::Region}:${AWS::AccountId}:${pSRASolutionName}-alarms' PolicyName: !Sub '${pSRASolutionName}-sns-policy' - PolicyDocument: Version: '2012-10-17' From 6b015568f797574b79d793b3207ca18f941006b0 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 5 Dec 2024 18:14:35 -0700 Subject: [PATCH 252/395] least privilege lambda execution role --- .../templates/sra-bedrock-org-main.yaml | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 696f62a27..7f25c4c60 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -76,16 +76,17 @@ Parameters: Default: 'liamschn+bedrockalarm@amazon.com' pSRAStagingS3BucketName: - # AllowedPattern: '^(?=^.{3,63}$)(?!.*[.-]{2})(?!.*[--]{2})(?!^(?:(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(?!$)|$)){4}$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)' - ConstraintDescription: - SRA Staging S3 bucket name can include numbers, lowercase letters, uppercase letters, and hyphens (-). It cannot start or end with a hyphen (-). Description: - SRA Staging S3 bucket name for the artifacts relevant to solution. (e.g., lambda zips, CloudFormation templates) S3 bucket name can include - numbers, lowercase letters, uppercase letters, and hyphens (-). It cannot start or end with a hyphen (-). - # Type: String + SRA Staging S3 bucket name for the artifacts relevant to solution. Type: AWS::SSM::Parameter::Value Default: /sra/staging-s3-bucket-name + pSecurityAccount: + Description: + The security tooling account Id. + Type: AWS::SSM::Parameter::Value + Default: /sra/control-tower/audit-account-id + pBedrockOrgLambdaRoleName: AllowedPattern: '^[\w+=,.@-]{1,64}$' ConstraintDescription: Max 64 alphanumeric characters. Also special characters supported [+, =, ., @, -] @@ -429,7 +430,8 @@ Resources: - 'dynamodb:DescribeTable' - 'dynamodb:CreateTable' - 'dynamodb:TagResource' - Resource: '*' + Resource: + - !Sub 'arn:${AWS::Partition}:dynamodb:${AWS::Region}:${pSecurityAccount}:table/*' PolicyName: !Sub '${pSRASolutionName}-dynamodb-policy' - PolicyDocument: Version: '2012-10-17' @@ -493,9 +495,7 @@ Resources: - 'logs:Link' Resource: - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:metric-filter:*' - - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*:log-stream:*' - # - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:metric-filter:sra-bedrock-filter-service-changes' - # - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:metric-filter:sra-bedrock-filter-bucket-changes' + - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*' PolicyName: !Sub '${pSRASolutionName}-logs-policy' - PolicyDocument: Version: '2012-10-17' @@ -507,10 +507,7 @@ Resources: - 'cloudwatch:DeleteAlarms' - 'cloudwatch:TagResource' - 'cloudwatch:Link' - Resource: '*' - # - !Sub 'arn:${AWS::Partition}:cloudwatch:${AWS::Region}:${AWS::AccountId}:alarm:*' - # - !Sub 'arn:${AWS::Partition}:cloudwatch:${AWS::Region}:${AWS::AccountId}:alarm:sra-bedrock-filter-service-changes-alarm' - # - !Sub 'arn:${AWS::Partition}:cloudwatch:${AWS::Region}:${AWS::AccountId}:alarm:sra-bedrock-filter-bucket-changes-alarm' + Resource: '*' # required for cloudwatchLink action PolicyName: !Sub '${pSRASolutionName}-cloudwatch-policy' - PolicyDocument: Version: '2012-10-17' @@ -524,6 +521,7 @@ Resources: Resource: - !Sub 'arn:${AWS::Partition}:oam:${AWS::Region}:${AWS::AccountId}:link/*' - !Sub 'arn:${AWS::Partition}:oam:${AWS::Region}:${AWS::AccountId}:/ListLinks*' + - !Sub 'arn:${AWS::Partition}:oam:${AWS::Region}:*:sink/*' # sink on security account PolicyName: !Sub '${pSRASolutionName}-oam-policy' - PolicyDocument: Version: '2012-10-17' @@ -531,7 +529,7 @@ Resources: - Effect: Allow Action: - 'xray:Link' - Resource: '*' + Resource: '*' # required for xrayLink action PolicyName: !Sub '${pSRASolutionName}-xray-policy' - PolicyDocument: Version: '2012-10-17' @@ -539,7 +537,7 @@ Resources: - Effect: Allow Action: - 'organizations:DescribeOrganization' - Resource: '*' + Resource: '*' # required for organizationsDescribeOrganization action PolicyName: !Sub '${pSRASolutionName}-organizations-policy' - PolicyDocument: Version: '2012-10-17' @@ -563,7 +561,8 @@ Resources: - Effect: Allow Action: - 'ssm:GetParameter' - Resource: '*' + Resource: + - !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/sra/*' PolicyName: !Sub '${pSRASolutionName}-ssm-policy' - PolicyDocument: Version: '2012-10-17' @@ -572,7 +571,7 @@ Resources: Action: - 'sts:AssumeRole' - 'sts:GetCallerIdentity' - Resource: '*' + Resource: '*' # required for stsGetCallerIdentity action PolicyName: !Sub '${pSRASolutionName}-sts-policy' - PolicyDocument: Version: '2012-10-17' @@ -582,7 +581,8 @@ Resources: - 's3:GetObject' - 's3:HeadObject' - 's3:PutObject' - Resource: '*' + Resource: + - !Sub 'arn:${AWS::Partition}:s3:::${pSRAStagingS3BucketName}/*' PolicyName: !Sub '${pSRASolutionName}-s3-policy' - PolicyDocument: Version: '2012-10-17' @@ -590,8 +590,8 @@ Resources: - Effect: Allow Action: - 'applicationinsights:Link' - Resource: '*' - # applicationinsights:Link on resource: arn:aws:applicationinsights:::application/* + Resource: + - !Sub 'arn:${AWS::Partition}:applicationinsights:${AWS::Region}:${AWS::AccountId}:application/*' PolicyName: !Sub '${pSRASolutionName}-appinsights-policy' - PolicyDocument: Version: '2012-10-17' @@ -599,8 +599,8 @@ Resources: - Effect: Allow Action: - 'internetmonitor:Link' - Resource: '*' - # internetmonitor:Link on resource: arn:aws:internetmonitor:::monitor/* + Resource: + - !Sub 'arn:${AWS::Partition}:internetmonitor:${AWS::Region}:${AWS::AccountId}:monitor/*' PolicyName: !Sub '${pSRASolutionName}-internetmonitor-policy' Tags: @@ -608,8 +608,6 @@ Resources: Value: !Ref pSRASolutionName ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - # TODO(liamschn): least privilege policies need to be created for this lambda role (in progress) - # - arn:aws:iam::aws:policy/AdministratorAccess rBedrockOrgLambdaFunction: Type: AWS::Lambda::Function From 2b166cf7d4601e7cfe1c3f8f487f33b6037376ff Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 5 Dec 2024 18:19:20 -0700 Subject: [PATCH 253/395] remove comments and completed todos --- .../genai/bedrock_org/lambda/src/sra_lambda.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 893f76c4a..b1be54bdb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -14,29 +14,15 @@ import os from time import sleep -# import re -# from time import sleep from typing import TYPE_CHECKING -# , Literal, Optional, Sequence, Union - import boto3 from botocore.config import Config from botocore.exceptions import ClientError -# import urllib.parse -# import json - -# import cfnresponse - if TYPE_CHECKING: - # from mypy_boto3_cloudformation import CloudFormationClient - # from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_lambda.client import LambdaClient - # from mypy_boto3_iam.client import IAMClient - # from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef - class sra_lambda: # Setup Default Logger @@ -109,7 +95,6 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler break elif error.response["Error"]["Code"] == "InvalidParameterValueException": self.LOGGER.info(f"Lambda not ready to deploy yet. {error}; Retrying...") - # TODO(liamschn): need to add a maximum retry mechanism here retries += 1 sleep(5) else: @@ -123,12 +108,10 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler if get_response["Configuration"]["State"] == "Active": self.LOGGER.info(f"Lambda function {function_name} is now active") break - # TODO(liamschn): need to add a maximum retry mechanism here else: self.LOGGER.info(f"{function_name} lambda function state is {get_response["Configuration"]["State"]}. Waiting to retry...") retries += 1 sleep(5) - # TODO(liamschn): fix bug for ResourceNotFoundException found while working on least privilege access on role (in progress) except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": self.LOGGER.info(f"Lambda function {function_name} not found. Retrying...") From c4d2279594f6eb7f3328234f4a1579b7eb486728 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 14:22:26 -0700 Subject: [PATCH 254/395] type checking fixes --- .../genai/bedrock_org/lambda/src/sra_kms.py | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index f610e4b0c..ddeda3562 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -14,12 +14,20 @@ import os from typing import TYPE_CHECKING +from typing import cast +from typing import Any, Dict +from typing import Literal + if TYPE_CHECKING: from mypy_boto3_kms.client import KMSClient - + from mypy_boto3_kms.type_defs import CreateKeyResponseTypeDef, DescribeKeyResponseTypeDef + from boto3 import Session + from mypy_boto3_sts.client import STSClient + from mypy_boto3_sts.type_defs import AssumeRoleResponseTypeDef import boto3 from botocore.config import Config +from botocore.client import BaseClient import urllib.parse import json @@ -43,14 +51,14 @@ class sra_kms: TARGET_ACCOUNT_ID: str = "" ORG_ID: str = "" - KEY_ALIAS: str = "alias/sra-secrets-key" # todo(liamschn): parameterize this alias name - KEY_DESCRIPTION: str = "SRA Secrets Key" # todo(liamschn): parameterize this description - EXECUTION_ROLE: str = "sra-execution" # todo(liamschn): parameterize this role name - SECRETS_PREFIX: str = "sra" # todo(liamschn): parameterize this? + KEY_ALIAS: str = "alias/sra-secrets-key" # TODO(liamschn): parameterize this alias name + KEY_DESCRIPTION: str = "SRA Secrets Key" # TODO(liamschn): parameterize this description + EXECUTION_ROLE: str = "sra-execution" # TODO(liamschn): parameterize this role name + SECRETS_PREFIX: str = "sra" # TODO(liamschn): parameterize this? SECRETS_KEY_POLICY: str = "" try: - MANAGEMENT_ACCOUNT_SESSION = boto3.Session() + MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() STS_CLIENT = boto3.client("sts") HOME_REGION = MANAGEMENT_ACCOUNT_SESSION.region_name LOGGER.info(f"Detected home region: {HOME_REGION}") @@ -63,7 +71,7 @@ class sra_kms: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - def define_key_policy(self, target_account_id, partition, home_region, org_id, management_account): + def define_key_policy(self, target_account_id: str, partition: str, home_region: str, org_id: str, management_account: str) -> str: policy_template = { # noqa ECE001 "Version": "2012-10-17", "Id": "sra-secrets-key", @@ -112,7 +120,7 @@ def define_key_policy(self, target_account_id, partition, home_region, org_id, m self.SECRETS_KEY_POLICY = json.dumps(policy_template) return json.dumps(policy_template) - def assume_role(self, account, role_name, service, region_name): + def assume_role(self, account: str, role_name: str, service: str, region_name: str) -> BaseClient: """Get boto3 client assumed into an account for a specified service. Args: @@ -123,23 +131,23 @@ def assume_role(self, account, role_name, service, region_name): Returns: client: boto3 client """ - client = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") - sts_response = client.assume_role( - RoleArn="arn:" + self.PARTITION + ":iam::" + account + ":role/" + role_name, + sts_client: STSClient = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") + sts_response: AssumeRoleResponseTypeDef = sts_client.assume_role( + RoleArn=f"arn:{self.PARTITION}:iam::{account}:role/{role_name}", RoleSessionName="SRA-AssumeCrossAccountRole", DurationSeconds=900, ) - - return self.MANAGEMENT_ACCOUNT_SESSION.client( - service, + client: BaseClient = self.MANAGEMENT_ACCOUNT_SESSION.client( + service, # type: ignore region_name=region_name, aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], aws_session_token=sts_response["Credentials"]["SessionToken"], ) + return client - def create_kms_key(self, kms_client, key_policy, description="Key description"): - """_summary_ + def create_kms_key(self, kms_client: KMSClient, key_policy: str, description: str = "Key description") -> str: + """Create KMS key Args: kms_client (KMSClient): KMS boto3 client @@ -158,22 +166,19 @@ def create_kms_key(self, kms_client, key_policy, description="Key description"): ) return key_response["KeyMetadata"]["KeyId"] - # def apply_key_policy(kms_client, key_id, key_policy): - # kms_client.put_key_policy(KeyId=key_id, PolicyName="default", Policy=json.dumps(key_policy), BypassPolicyLockoutSafetyCheck=False) - - def create_alias(self, kms_client, alias_name, target_key_id): + def create_alias(self, kms_client: KMSClient, alias_name: str, target_key_id: str) -> None: self.LOGGER.info(f"Create KMS alias: {alias_name}") kms_client.create_alias(AliasName=alias_name, TargetKeyId=target_key_id) - def delete_alias(self, kms_client, alias_name): + def delete_alias(self, kms_client: KMSClient, alias_name: str) -> None: self.LOGGER.info(f"Delete KMS alias: {alias_name}") kms_client.delete_alias(AliasName=alias_name) - def schedule_key_deletion(self, kms_client, key_id, pending_window_in_days=30): + def schedule_key_deletion(self, kms_client: KMSClient, key_id: str, pending_window_in_days: int = 30) -> None: self.LOGGER.info(f"Schedule deletion of key: {key_id} in {pending_window_in_days} days") kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=pending_window_in_days) - def search_key_policies(self, kms_client): + def search_key_policies(self, kms_client: KMSClient) -> tuple[bool, str]: for key in self.list_all_keys(kms_client): for policy in self.list_key_policies(kms_client, key["KeyId"]): policy_body = kms_client.get_key_policy(KeyId=key["KeyId"], PolicyName=policy)["Policy"] @@ -190,22 +195,22 @@ def search_key_policies(self, kms_client): self.LOGGER.info(f"Attempted to match to: {secrets_key_policy}") return False, "None" - def list_key_policies(self, kms_client, key_id): + def list_key_policies(self, kms_client: KMSClient, key_id: str) -> list: response = kms_client.list_key_policies(KeyId=key_id) return response["PolicyNames"] - def list_all_keys(self, kms_client): + def list_all_keys(self, kms_client: KMSClient) -> list: response = kms_client.list_keys() return response["Keys"] - def check_key_exists(self, kms_client, key_id): + def check_key_exists(self, kms_client: KMSClient, key_id: str) -> tuple[bool, DescribeKeyResponseTypeDef]: try: - response = kms_client.describe_key(KeyId=key_id) + response: DescribeKeyResponseTypeDef = kms_client.describe_key(KeyId=key_id) return True, response except kms_client.exceptions.NotFoundException: - return False, None + return False, cast(DescribeKeyResponseTypeDef, None) - def check_alias_exists(self, kms_client, alias_name): + def check_alias_exists(self, kms_client: KMSClient, alias_name: str) -> tuple[bool, str, str, str]: """Check if an alias exists in KMS. Args: From 5bb3ff8c95da37ae443bb07b8be8d6e4cc15ee95 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 14:24:53 -0700 Subject: [PATCH 255/395] kms assume_role not accessed (used in sts module) --- .../genai/bedrock_org/lambda/src/sra_kms.py | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index ddeda3562..346600926 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -15,8 +15,6 @@ from typing import TYPE_CHECKING from typing import cast -from typing import Any, Dict -from typing import Literal if TYPE_CHECKING: from mypy_boto3_kms.client import KMSClient @@ -120,31 +118,31 @@ def define_key_policy(self, target_account_id: str, partition: str, home_region: self.SECRETS_KEY_POLICY = json.dumps(policy_template) return json.dumps(policy_template) - def assume_role(self, account: str, role_name: str, service: str, region_name: str) -> BaseClient: - """Get boto3 client assumed into an account for a specified service. - - Args: - account: aws account id - service: aws service - region_name: aws region - - Returns: - client: boto3 client - """ - sts_client: STSClient = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") - sts_response: AssumeRoleResponseTypeDef = sts_client.assume_role( - RoleArn=f"arn:{self.PARTITION}:iam::{account}:role/{role_name}", - RoleSessionName="SRA-AssumeCrossAccountRole", - DurationSeconds=900, - ) - client: BaseClient = self.MANAGEMENT_ACCOUNT_SESSION.client( - service, # type: ignore - region_name=region_name, - aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], - aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], - aws_session_token=sts_response["Credentials"]["SessionToken"], - ) - return client + # def assume_role(self, account: str, role_name: str, service: str, region_name: str) -> BaseClient: + # """Get boto3 client assumed into an account for a specified service. + + # Args: + # account: aws account id + # service: aws service + # region_name: aws region + + # Returns: + # client: boto3 client + # """ + # sts_client: STSClient = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") + # sts_response: AssumeRoleResponseTypeDef = sts_client.assume_role( + # RoleArn=f"arn:{self.PARTITION}:iam::{account}:role/{role_name}", + # RoleSessionName="SRA-AssumeCrossAccountRole", + # DurationSeconds=900, + # ) + # client: BaseClient = self.MANAGEMENT_ACCOUNT_SESSION.client( + # service, # type: ignore + # region_name=region_name, + # aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], + # aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], + # aws_session_token=sts_response["Credentials"]["SessionToken"], + # ) + # return client def create_kms_key(self, kms_client: KMSClient, key_policy: str, description: str = "Key description") -> str: """Create KMS key From 26aa9ac6344a28f3840936978ee2f5a930318634 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 14:47:50 -0700 Subject: [PATCH 256/395] removing unused params from kms module --- .../genai/bedrock_org/lambda/src/sra_kms.py | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index 346600926..2586e7fea 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -38,21 +38,21 @@ class sra_kms: LOGGER.setLevel(log_level) # Global Variables - RESOURCE_TYPE: str = "" + # RESOURCE_TYPE: str = "" UNEXPECTED = "Unexpected!" BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) - SRA_SOLUTION_NAME = "sra-common-prerequisites" - CFN_RESOURCE_ID: str = "sra-iam-function" - CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" - - CONFIGURATION_ROLE: str = "" - TARGET_ACCOUNT_ID: str = "" - ORG_ID: str = "" - - KEY_ALIAS: str = "alias/sra-secrets-key" # TODO(liamschn): parameterize this alias name - KEY_DESCRIPTION: str = "SRA Secrets Key" # TODO(liamschn): parameterize this description - EXECUTION_ROLE: str = "sra-execution" # TODO(liamschn): parameterize this role name - SECRETS_PREFIX: str = "sra" # TODO(liamschn): parameterize this? + # SRA_SOLUTION_NAME = "sra-common-prerequisites" + # CFN_RESOURCE_ID: str = "sra-iam-function" + # CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" + + # CONFIGURATION_ROLE: str = "" + # TARGET_ACCOUNT_ID: str = "" + # ORG_ID: str = "" + + # KEY_ALIAS: str = "alias/sra-secrets-key" # TODO(liamschn): parameterize this alias name + # KEY_DESCRIPTION: str = "SRA Secrets Key" # TODO(liamschn): parameterize this description + # EXECUTION_ROLE: str = "sra-execution" # TODO(liamschn): parameterize this role name + # SECRETS_PREFIX: str = "sra" # TODO(liamschn): parameterize this? SECRETS_KEY_POLICY: str = "" try: @@ -176,21 +176,22 @@ def schedule_key_deletion(self, kms_client: KMSClient, key_id: str, pending_wind self.LOGGER.info(f"Schedule deletion of key: {key_id} in {pending_window_in_days} days") kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=pending_window_in_days) - def search_key_policies(self, kms_client: KMSClient) -> tuple[bool, str]: + def search_key_policies(self, kms_client: KMSClient, key_policy: str) -> tuple[bool, str]: for key in self.list_all_keys(kms_client): + self.LOGGER.info(f"Examinining policies in {key} kms key...") for policy in self.list_key_policies(kms_client, key["KeyId"]): policy_body = kms_client.get_key_policy(KeyId=key["KeyId"], PolicyName=policy)["Policy"] policy_body = json.loads(policy_body) - self.LOGGER.info(f"Key policy: {policy_body}") - self.LOGGER.info(f"SECRETS_KEY_POLICY: {self.SECRETS_KEY_POLICY}") - secrets_key_policy = json.loads(self.SECRETS_KEY_POLICY) - if policy_body == secrets_key_policy: + self.LOGGER.info(f"Examining policy: {policy_body}") + self.LOGGER.info(f"Comparing policy to provided policy: {key_policy}") + expected_key_policy = json.loads(key_policy) + if policy_body == expected_key_policy: self.LOGGER.info(f"Key policy match found for key {key['KeyId']} policy {policy}: {policy_body}") - self.LOGGER.info(f"Attempted to match to: {secrets_key_policy}") + self.LOGGER.info(f"Attempted to match to: {expected_key_policy}") return True, key["KeyId"] else: self.LOGGER.info(f"No key policy match found for key {key['KeyId']} policy {policy}: {policy_body}") - self.LOGGER.info(f"Attempted to match to: {secrets_key_policy}") + self.LOGGER.info(f"Attempted to match to: {expected_key_policy}") return False, "None" def list_key_policies(self, kms_client: KMSClient, key_id: str) -> list: From 04edf024211c06b3519576b3a04f67716c70e00d Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 16:25:55 -0700 Subject: [PATCH 257/395] search for kms key before creating; remove comments/cleanup --- .../genai/bedrock_org/lambda/src/app.py | 26 ++-- .../genai/bedrock_org/lambda/src/sra_kms.py | 141 +++++++----------- 2 files changed, 69 insertions(+), 98 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index c0d7187b3..4510aa036 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -719,7 +719,7 @@ def deploy_config_rules(region, accounts, resource_properties): LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" -def deploy_metric_filters_and_alarms(region, accounts, resource_properties): +def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_properties: dict) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -770,11 +770,10 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): if acct not in filter_accounts: LOGGER.info(f"{filter_name} filter not requested for {acct}. Skipping...") continue - kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) + kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region, config=kms.BOTO3_CONFIG) search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is False: LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") - # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section if DRY_RUN is False: LOGGER.info("Creating SRA alarm KMS key") LOGGER.info("Customizing key policy...") @@ -786,11 +785,21 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): kms_key_policy["Statement"][2]["Principal"]["AWS"] = execution_role_arn LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") - alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption") - LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") - LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" - CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section (in progress) + LOGGER.info(f"Searching for existing keys with proper policy...") + kms_search_result, kms_found_id = kms.search_key_policies(kms.KMS_CLIENT, kms_key_policy) + if kms_search_result is True: + LOGGER.info(f"Found existing key with proper policy: {kms_found_id}") + alarm_key_id = kms_found_id + else: + LOGGER.info("No existing key found with proper policy. Creating new key...") + alarm_key_id = kms.create_kms_key(kms.KMS_CLIENT, json.dumps(kms_key_policy), "Key for CloudWatch Alarm SNS Topic Encryption") + LOGGER.info(f"Created SRA alarm KMS key: {alarm_key_id}") + LIVE_RUN_DATA["KMSKeyCreate"] = "Created SRA alarm KMS key" + CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + # Add KMS resource records to sra state table + add_state_table_record("kms", "implemented", "alarms sns kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) # 4aii KMS alias for SNS topic used by CloudWatch alarms LOGGER.info("Creating SRA alarm KMS key alias") @@ -799,7 +808,6 @@ def deploy_metric_filters_and_alarms(region, accounts, resource_properties): CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 # Add KMS resource records to sra state table - add_state_table_record("kms", "implemented", "alarms sns kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) else: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index 2586e7fea..387cfcb84 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING from typing import cast +from typing import Literal if TYPE_CHECKING: from mypy_boto3_kms.client import KMSClient @@ -38,22 +39,10 @@ class sra_kms: LOGGER.setLevel(log_level) # Global Variables - # RESOURCE_TYPE: str = "" UNEXPECTED = "Unexpected!" BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) - # SRA_SOLUTION_NAME = "sra-common-prerequisites" - # CFN_RESOURCE_ID: str = "sra-iam-function" - # CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" - - # CONFIGURATION_ROLE: str = "" - # TARGET_ACCOUNT_ID: str = "" - # ORG_ID: str = "" - - # KEY_ALIAS: str = "alias/sra-secrets-key" # TODO(liamschn): parameterize this alias name - # KEY_DESCRIPTION: str = "SRA Secrets Key" # TODO(liamschn): parameterize this description - # EXECUTION_ROLE: str = "sra-execution" # TODO(liamschn): parameterize this role name - # SECRETS_PREFIX: str = "sra" # TODO(liamschn): parameterize this? - SECRETS_KEY_POLICY: str = "" + SERVICE_NAME: Literal["kms"] = "kms" + # SECRETS_KEY_POLICY: str = "" try: MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() @@ -64,85 +53,59 @@ class sra_kms: MANAGEMENT_ACCOUNT = STS_CLIENT.get_caller_identity().get("Account") PARTITION: str = boto3.session.Session().get_partition_for_region(HOME_REGION) LOGGER.info(f"Detected management account (current account): {MANAGEMENT_ACCOUNT}") - KMS_CLIENT: KMSClient = MANAGEMENT_ACCOUNT_SESSION.client("kms", config=BOTO3_CONFIG) + KMS_CLIENT: KMSClient = MANAGEMENT_ACCOUNT_SESSION.client(SERVICE_NAME, config=BOTO3_CONFIG) except Exception: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - def define_key_policy(self, target_account_id: str, partition: str, home_region: str, org_id: str, management_account: str) -> str: - policy_template = { # noqa ECE001 - "Version": "2012-10-17", - "Id": "sra-secrets-key", - "Statement": [ - { - "Sid": "Enable IAM User Permissions", - "Effect": "Allow", - "Principal": {"AWS": "arn:" + partition + ":iam::" + target_account_id + ":root"}, - "Action": "kms:*", - "Resource": "*", - }, - { - "Sid": "Allow access through AWS Secrets Manager for all principals in the account that are authorized to use AWS Secrets Manager", - "Effect": "Allow", - "Principal": {"AWS": "*"}, - "Action": ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*", "kms:ReEncrypt*", "kms:CreateGrant", "kms:DescribeKey"], - "Resource": "*", - "Condition": { - "StringEquals": {"kms:ViaService": "secretsmanager." + home_region + ".amazonaws.com", "aws:PrincipalOrgId": org_id}, - "StringLike": { - "kms:EncryptionContext:SecretARN": "arn:aws:secretsmanager:" + home_region + ":*:secret:sra/*", - "aws:PrincipalArn": "arn:" + partition + ":iam::*:role/sra-execution", - }, - }, - }, - { - "Sid": "Allow direct access to key metadata", - "Effect": "Allow", - "Principal": {"AWS": "arn:" + partition + ":iam::" + management_account + ":root"}, - "Action": ["kms:Decrypt", "kms:Describe*", "kms:Get*", "kms:List*"], - "Resource": "*", - }, - { - "Sid": "Allow alias creation during setup", - "Effect": "Allow", - "Principal": {"AWS": "arn:" + partition + ":iam::" + target_account_id + ":root"}, - "Action": "kms:CreateAlias", - "Resource": "*", - "Condition": { - "StringEquals": {"kms:ViaService": "cloudformation." + home_region + ".amazonaws.com", "kms:CallerAccount": target_account_id} - }, - }, - ], - } - self.LOGGER.info(f"Key Policy:\n{json.dumps(policy_template)}") - self.SECRETS_KEY_POLICY = json.dumps(policy_template) - return json.dumps(policy_template) - - # def assume_role(self, account: str, role_name: str, service: str, region_name: str) -> BaseClient: - # """Get boto3 client assumed into an account for a specified service. - - # Args: - # account: aws account id - # service: aws service - # region_name: aws region - - # Returns: - # client: boto3 client - # """ - # sts_client: STSClient = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") - # sts_response: AssumeRoleResponseTypeDef = sts_client.assume_role( - # RoleArn=f"arn:{self.PARTITION}:iam::{account}:role/{role_name}", - # RoleSessionName="SRA-AssumeCrossAccountRole", - # DurationSeconds=900, - # ) - # client: BaseClient = self.MANAGEMENT_ACCOUNT_SESSION.client( - # service, # type: ignore - # region_name=region_name, - # aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], - # aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], - # aws_session_token=sts_response["Credentials"]["SessionToken"], - # ) - # return client + # def define_key_policy(self, target_account_id: str, partition: str, home_region: str, org_id: str, management_account: str) -> str: + # policy_template = { # noqa ECE001 + # "Version": "2012-10-17", + # "Id": "sra-secrets-key", + # "Statement": [ + # { + # "Sid": "Enable IAM User Permissions", + # "Effect": "Allow", + # "Principal": {"AWS": "arn:" + partition + ":iam::" + target_account_id + ":root"}, + # "Action": "kms:*", + # "Resource": "*", + # }, + # { + # "Sid": "Allow access through AWS Secrets Manager for all principals in the account that are authorized to use AWS Secrets Manager", + # "Effect": "Allow", + # "Principal": {"AWS": "*"}, + # "Action": ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*", "kms:ReEncrypt*", "kms:CreateGrant", "kms:DescribeKey"], + # "Resource": "*", + # "Condition": { + # "StringEquals": {"kms:ViaService": "secretsmanager." + home_region + ".amazonaws.com", "aws:PrincipalOrgId": org_id}, + # "StringLike": { + # "kms:EncryptionContext:SecretARN": "arn:aws:secretsmanager:" + home_region + ":*:secret:sra/*", + # "aws:PrincipalArn": "arn:" + partition + ":iam::*:role/sra-execution", + # }, + # }, + # }, + # { + # "Sid": "Allow direct access to key metadata", + # "Effect": "Allow", + # "Principal": {"AWS": "arn:" + partition + ":iam::" + management_account + ":root"}, + # "Action": ["kms:Decrypt", "kms:Describe*", "kms:Get*", "kms:List*"], + # "Resource": "*", + # }, + # { + # "Sid": "Allow alias creation during setup", + # "Effect": "Allow", + # "Principal": {"AWS": "arn:" + partition + ":iam::" + target_account_id + ":root"}, + # "Action": "kms:CreateAlias", + # "Resource": "*", + # "Condition": { + # "StringEquals": {"kms:ViaService": "cloudformation." + home_region + ".amazonaws.com", "kms:CallerAccount": target_account_id} + # }, + # }, + # ], + # } + # self.LOGGER.info(f"Key Policy:\n{json.dumps(policy_template)}") + # self.SECRETS_KEY_POLICY = json.dumps(policy_template) + # return json.dumps(policy_template) def create_kms_key(self, kms_client: KMSClient, key_policy: str, description: str = "Key description") -> str: """Create KMS key From f95a2db20899d44d3b8a29af0a259dbe27394c3f Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 16:46:22 -0700 Subject: [PATCH 258/395] update to include boto3 config --- .../solutions/genai/bedrock_org/lambda/src/app.py | 2 +- .../solutions/genai/bedrock_org/lambda/src/sra_sts.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 4510aa036..6aa38b7c6 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -770,7 +770,7 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope if acct not in filter_accounts: LOGGER.info(f"{filter_name} filter not requested for {acct}. Skipping...") continue - kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region, config=kms.BOTO3_CONFIG) + kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") if search_alarm_kms_key is False: LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index bcbcb42f0..4b6449cec 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -3,7 +3,7 @@ import boto3 import botocore - +from botocore.config import Config class sra_sts: PROFILE = "default" @@ -11,6 +11,7 @@ class sra_sts: UNEXPECTED = "Unexpected!" # TODO(liamschn): this needs to be made into an SSM parameter CONFIGURATION_ROLE: str = "" + BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -87,7 +88,7 @@ def assume_role(self, account, role_name, service, region_name): aws_session_token=sts_response["Credentials"]["SessionToken"], ) else: - return self.MANAGEMENT_ACCOUNT_SESSION.client(service, region_name=region_name) + return self.MANAGEMENT_ACCOUNT_SESSION.client(service, region_name=region_name, config=self.BOTO3_CONFIG) def assume_role_resource(self, account, role_name, service, region_name): From cbb3fdf1f913ffc11a2289bb68f77db96eb98080 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 18:01:04 -0700 Subject: [PATCH 259/395] permissions update; fix type error for kms policy --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 6aa38b7c6..684c3ae29 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -787,7 +787,7 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section (in progress) LOGGER.info(f"Searching for existing keys with proper policy...") - kms_search_result, kms_found_id = kms.search_key_policies(kms.KMS_CLIENT, kms_key_policy) + kms_search_result, kms_found_id = kms.search_key_policies(kms.KMS_CLIENT, json.dumps(kms_key_policy)) if kms_search_result is True: LOGGER.info(f"Found existing key with proper policy: {kms_found_id}") alarm_key_id = kms_found_id diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 7f25c4c60..43dbc4c8b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -462,6 +462,9 @@ Resources: - 'kms:GenerateDataKey' - 'kms:ScheduleKeyDeletion' - 'kms:TagResource' + - 'kms:ListKeys' + - 'kms:ListKeyPolicies' + - 'kms:GetKeyPolicy' Resource: '*' # required because of CreateKey operation PolicyName: !Sub '${pSRASolutionName}-kms-policy' - PolicyDocument: From ed463617c2536ea450b56a5d29569043940faca9 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 19:25:49 -0700 Subject: [PATCH 260/395] update perms; filter out pending deletion keys --- .../solutions/genai/bedrock_org/lambda/src/sra_kms.py | 3 +++ .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 1 + 2 files changed, 4 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index 387cfcb84..bb7b495bb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -141,6 +141,9 @@ def schedule_key_deletion(self, kms_client: KMSClient, key_id: str, pending_wind def search_key_policies(self, kms_client: KMSClient, key_policy: str) -> tuple[bool, str]: for key in self.list_all_keys(kms_client): + if kms_client.describe_key(KeyId=key["KeyId"])["KeyMetadata"]["KeyState"] == "PendingDeletion": + self.LOGGER.info(f"Skipping pending deletion key: {key['KeyId']}") + continue self.LOGGER.info(f"Examinining policies in {key} kms key...") for policy in self.list_key_policies(kms_client, key["KeyId"]): policy_body = kms_client.get_key_policy(KeyId=key["KeyId"], PolicyName=policy)["Policy"] diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 43dbc4c8b..b0bc5d29e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -465,6 +465,7 @@ Resources: - 'kms:ListKeys' - 'kms:ListKeyPolicies' - 'kms:GetKeyPolicy' + - 'kms:DescribeKey' Resource: '*' # required because of CreateKey operation PolicyName: !Sub '${pSRASolutionName}-kms-policy' - PolicyDocument: From 702bba69929c20e4b8b3ae6eb8b5e52431fd5385 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 19:53:09 -0700 Subject: [PATCH 261/395] updating key examination --- .../solutions/genai/bedrock_org/lambda/src/sra_kms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index bb7b495bb..dafca6bd1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -141,7 +141,8 @@ def schedule_key_deletion(self, kms_client: KMSClient, key_id: str, pending_wind def search_key_policies(self, kms_client: KMSClient, key_policy: str) -> tuple[bool, str]: for key in self.list_all_keys(kms_client): - if kms_client.describe_key(KeyId=key["KeyId"])["KeyMetadata"]["KeyState"] == "PendingDeletion": + self.LOGGER.info(f"Examining state of key: {key['KeyId']}") + if kms_client.describe_key(KeyId=key["KeyId"])["KeyMetadata"]["KeyState"] != "Enabled": self.LOGGER.info(f"Skipping pending deletion key: {key['KeyId']}") continue self.LOGGER.info(f"Examinining policies in {key} kms key...") From 2325ce482a8b3dea29d4c89919b93e1bb0543b47 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 6 Dec 2024 20:17:58 -0700 Subject: [PATCH 262/395] updating log message --- .../solutions/genai/bedrock_org/lambda/src/sra_kms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index dafca6bd1..156206431 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -143,7 +143,7 @@ def search_key_policies(self, kms_client: KMSClient, key_policy: str) -> tuple[b for key in self.list_all_keys(kms_client): self.LOGGER.info(f"Examining state of key: {key['KeyId']}") if kms_client.describe_key(KeyId=key["KeyId"])["KeyMetadata"]["KeyState"] != "Enabled": - self.LOGGER.info(f"Skipping pending deletion key: {key['KeyId']}") + self.LOGGER.info(f"Skipping non-enabled key: {key['KeyId']}") continue self.LOGGER.info(f"Examinining policies in {key} kms key...") for policy in self.list_key_policies(kms_client, key["KeyId"]): From e72bb1bb9e5ac3dab12678d8f996af1b3e9f60e4 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 7 Dec 2024 08:01:53 -0700 Subject: [PATCH 263/395] fix linting issues --- .../genai/bedrock_org/lambda/src/app.py | 95 +++++++------------ .../bedrock_org/lambda/src/sra_dynamodb.py | 2 +- .../genai/bedrock_org/lambda/src/sra_kms.py | 55 +---------- .../genai/bedrock_org/lambda/src/sra_repo.py | 2 +- 4 files changed, 37 insertions(+), 117 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 684c3ae29..ed49d122e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -27,9 +27,8 @@ # TODO(liamschn): deploy example bedrock guardrail # TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority/not necessary? # TODO(liamschn): deploy example bucket policy(ies) - lower priority/not necessary? -# TODO(liamschn): deal with linting failures in pipeline -# TODO(liamschn): deal with typechecking/mypy -# TODO(liamschn): check for unused parameters +# TODO(liamschn): deal with linting failures in pipeline (and deal with typechecking/mypy) +# TODO(liamschn): check for unused parameters (in progress) # TODO(liamschn): make sure things don't fail (create or delete) if the dynamodb table is deleted/doesn't exist (use case, maybe someone deletes it) from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional @@ -74,7 +73,6 @@ def load_sra_cloudwatch_dashboard() -> dict: # Global vars RESOURCE_TYPE: str = "" -STATE_TABLE: str = "sra_state" SOLUTION_NAME: str = "sra-bedrock-org" GOVERNED_REGIONS = [] ORGANIZATION_ID = "" @@ -86,8 +84,10 @@ def load_sra_cloudwatch_dashboard() -> dict: LAMBDA_START: str = "" LAMBDA_FINISH: str = "" -ACCOUNT: str = boto3.client("sts").get_caller_identity().get("Account") -REGION: str = os.environ.get("AWS_REGION") +ACCOUNT: str | None = boto3.client("sts").get_caller_identity().get("Account") +LOGGER.info(f"Account: {ACCOUNT}") +REGION: str | None = os.environ.get("AWS_REGION") +LOGGER.info(f"Region: {REGION}") CFN_RESOURCE_ID: str = "sra-bedrock-org-function" ALARM_SNS_KEY_ALIAS = "sra-alarm-sns-key" @@ -158,7 +158,7 @@ def load_sra_cloudwatch_dashboard() -> dict: # propagate solution name to class objects cloudwatch.SOLUTION_NAME = SOLUTION_NAME -def get_resource_parameters(event): +def get_resource_parameters(event: dict) -> None: global DRY_RUN global GOVERNED_REGIONS global CFN_RESPONSE_DATA @@ -251,7 +251,7 @@ def validate_parameters(parameters: Dict[str, str], rules: Dict[str, str]) -> Di } -def get_accounts_and_regions(resource_properties): +def get_accounts_and_regions(resource_properties: dict) -> tuple[list, list]: """Get accounts and regions from event and return them in a tuple Args: @@ -280,7 +280,7 @@ def get_accounts_and_regions(resource_properties): regions = [] return accounts, regions -def get_rule_params(rule_name, resource_properties): +def get_rule_params(rule_name: str, resource_properties: dict) -> tuple[bool, list, list, dict]: """Get rule parameters from event and return them in a tuple Args: @@ -339,7 +339,7 @@ def get_rule_params(rule_name, resource_properties): return False, [], [], {} -def get_filter_params(filter_name, resource_properties): +def get_filter_params(filter_name: str, resource_properties: dict) -> tuple[bool, list, list, dict]: """Get filter parameters from event resource_properties and return them in a tuple Args: @@ -410,7 +410,7 @@ def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: s3_filter = s3_filter.replace('&& ($.requestParameters.bucketName = "")', "") return s3_filter -def build_cloudwatch_dashboard(dashboard_template, solution, bedrock_accounts, regions): +def build_cloudwatch_dashboard(dashboard_template: dict, solution: str, bedrock_accounts: list, regions: list) -> dict: i = 0 for bedrock_account in bedrock_accounts: for region in regions: @@ -433,7 +433,7 @@ def build_cloudwatch_dashboard(dashboard_template, solution, bedrock_accounts, r return dashboard_template[solution] -def deploy_state_table(): +def deploy_state_table() -> None: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -482,7 +482,7 @@ def deploy_state_table(): DRY_RUN_DATA["StateTableCreate"] = f"DRY_RUN: Create the {STATE_TABLE} state table" -def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str, account_id: str, region: str, component_name: str, key_id: str = ""): +def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str, account_id: str, region: str, component_name: str, key_id: str = "") -> str: """Add a record to the state table Args: aws_service (str): aws service @@ -534,7 +534,7 @@ def add_state_table_record(aws_service: str, component_state: str, description: return sra_resource_record_id -def remove_state_table_record(resource_arn): +def remove_state_table_record(resource_arn: str) -> dict: """Remove a record from the state table Args: @@ -566,7 +566,7 @@ def remove_state_table_record(resource_arn): response = {} return response -def update_state_table_record(record_id: str, update_data: dict): +def update_state_table_record(record_id: str, update_data: dict) -> None: dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) try: @@ -578,11 +578,10 @@ def update_state_table_record(record_id: str, update_data: dict): ) except Exception as error: LOGGER.error(f"Error updating {record_id} record in {STATE_TABLE} dynamodb table: {error}") - response = {} return -def deploy_stage_config_rule_lambda_code(): +def deploy_stage_config_rule_lambda_code() -> None: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -604,7 +603,7 @@ def deploy_stage_config_rule_lambda_code(): LOGGER.info(f"DRY_RUN: Staging config rule code to the {s3.STAGING_BUCKET} staging bucket") -def deploy_sns_configuration_topics(context): +def deploy_sns_configuration_topics(context: Any) -> str: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -652,7 +651,7 @@ def deploy_sns_configuration_topics(context): return topic_arn -def deploy_config_rules(region, accounts, resource_properties): +def deploy_config_rules(region: str, accounts: list, resource_properties: dict) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -711,7 +710,7 @@ def deploy_config_rules(region, accounts, resource_properties): # 3c) Deploy the config rule (requires config_org [non-CT] or config_mgmt [CT] solution) if DRY_RUN is False: - config_rule_arn = deploy_config_rule(acct, rule_name, lambda_arn, region, rule_input_params) + deploy_config_rule(acct, rule_name, lambda_arn, region, rule_input_params) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "Deployed custom config rule" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 @@ -911,7 +910,7 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter_name} CloudWatch metric filter deployment") DRY_RUN_DATA[f"{filter_name}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" -def deploy_central_cloudwatch_observability(event): +def deploy_central_cloudwatch_observability(event: dict) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1063,7 +1062,7 @@ def deploy_central_cloudwatch_observability(event): # add OAM link state table record add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") -def deploy_cloudwatch_dashboard(event): +def deploy_cloudwatch_dashboard(event: dict) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1091,22 +1090,8 @@ def deploy_cloudwatch_dashboard(event): else: LOGGER.info(f"Cloudwatch dashboard already exists: {search_dashboard[1]}") add_state_table_record("cloudwatch", "implemented", "cloudwatch dashboard", "dashboard", search_dashboard[1], ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, SOLUTION_NAME) - # check_dashboard = cloudwatch.compare_dashboard(search_dashboard[1], cloudwatch_dashboard) - # if check_dashboard is False: - # if DRY_RUN is False: - # LOGGER.info("CloudWatch observability dashboard needs updating...") - # cloudwatch.create_dashboard(cloudwatch.SOLUTION_NAME, cloudwatch_dashboard) - # LIVE_RUN_DATA["OAMDashboardUpdate"] = "Updated CloudWatch observability dashboard" - # CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - # CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - # LOGGER.info("Updated CloudWatch observability dashboard") - # else: - # LOGGER.info("DRY_RUN: CloudWatch observability dashboard needs updating...") - # DRY_RUN_DATA["OAMDashboardUpdate"] = "DRY_RUN: Update CloudWatch observability dashboard" - # else: - # LOGGER.info("CloudWatch observability dashboard is correct") - -def remove_cloudwatch_dashboard(): + +def remove_cloudwatch_dashboard() -> None: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1131,7 +1116,7 @@ def remove_cloudwatch_dashboard(): remove_state_table_record(f"arn:aws:cloudwatch::{ssm_params.SRA_SECURITY_ACCT}:dashboard/{SOLUTION_NAME}") -def create_event(event, context): +def create_event(event: dict, context: Any) -> str: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1144,7 +1129,6 @@ def create_event(event, context): LOGGER.info(event_info) LOGGER.info(f"CFN_RESPONSE_DATA START: {CFN_RESPONSE_DATA}") # Deploy state table - # TODO(liamschn): need to ensure the solution name for the state table record is sra-common-prerequisites (if it is created here), not bedrock deploy_state_table() LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_state_table: {CFN_RESPONSE_DATA}") # add IAM state table record for the lambda execution role @@ -1197,23 +1181,15 @@ def create_event(event, context): return CFN_RESOURCE_ID -def update_event(event, context): - # TODO(liamschn): handle CFN update events; use case: change from DRY_RUN = False to DRY_RUN = True or vice versa +def update_event(event: dict, context: Any) -> str: # TODO(liamschn): handle CFN update events; use case: add additional config rules via new rules in code (i.e. ...\rules\new_rule\app.py) # TODO(liamschn): handle CFN update events; use case: changing config rule parameters (i.e. deploy, accounts, regions, input_params) - # TODO(liamschn): handle CFN update events; use case: setting deploy = false should remove the config rule global DRY_RUN_DATA LOGGER.info("update event function") - # Temp calling create_event so that an update will actually do something; need to determine if this is the best way or not. create_event(event, context) - # data = sra_s3.s3_resource_check() - # TODO(liamschn): update data dictionary - # data = {"data": "no info"} - # if RESOURCE_TYPE != "Other": - # cfnresponse.send(event, context, cfnresponse.SUCCESS, data, CFN_RESOURCE_ID) return CFN_RESOURCE_ID -def delete_custom_config_rule(rule_name: str, acct: str, region: str): +def delete_custom_config_rule(rule_name: str, acct: str, region: str) -> None: # Delete the config rule config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) @@ -1234,7 +1210,6 @@ def delete_custom_config_rule(rule_name: str, acct: str, region: str): # Delete lambda for custom config rule lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) lambda_search = lambdas.find_lambda_function(rule_name) - # TODO(liamschn): this will be a mypy error - need to have lambda_search return string, not None if lambda_search is not None: if DRY_RUN is False: LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") @@ -1249,7 +1224,7 @@ def delete_custom_config_rule(rule_name: str, acct: str, region: str): else: LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") -def delete_custom_config_iam_role(rule_name: str, acct: str): +def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1330,10 +1305,9 @@ def delete_custom_config_iam_role(rule_name: str, acct: str): else: LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") -def delete_sns_topic_and_key(acct: str, region: str): +def delete_sns_topic_and_key(acct: str, region: str) -> None: # Delete the alarm topic sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) - # TODO(liamschn): this will be a mypy error - need to have alarm_topic_search (sns.find_sns_topic) return string, not None alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) if alarm_topic_search is not None: if DRY_RUN is False: @@ -1380,7 +1354,7 @@ def delete_sns_topic_and_key(acct: str, region: str): LOGGER.info(f"{ALARM_SNS_KEY_ALIAS} KMS key does not exist.") -def delete_metric_filter_and_alarm(filter_name: str, acct: str, region: str, filter_params: dict): +def delete_metric_filter_and_alarm(filter_name: str, acct: str, region: str, filter_params: dict) -> None: cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) if DRY_RUN is False: @@ -1419,7 +1393,7 @@ def delete_metric_filter_and_alarm(filter_name: str, acct: str, region: str, fil LOGGER.info(f"DRY_RUN: Delete {filter_name} CloudWatch metric filter") DRY_RUN_DATA[f"{filter_name}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter_name} CloudWatch metric filter" -def delete_event(event, context): +def delete_event(event: dict, context: Any) -> None: # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. # TODO(liamschn): move re-used delete event operation code to separate functions global DRY_RUN_DATA @@ -1436,7 +1410,6 @@ def delete_event(event, context): # 1a) Delete configuration topic sns.SNS_CLIENT = sts.assume_role(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "sns", sts.HOME_REGION) topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") - # TODO(liamschn): this will be a mypy error: need to have topic_search (sns.find_sns_topic) return a str, not None if topic_search is not None: if DRY_RUN is False: LOGGER.info(f"Deleting {SOLUTION_NAME}-configuration SNS topic") @@ -1618,7 +1591,7 @@ def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, resou DRY_RUN_DATA["SNSFanout"] = "DRY_RUN: Published SNS messages for regional fanout configuration. More dry run data in subsequent log streams." -def process_sns_records(event) -> None: +def process_sns_records(event: dict) -> None: """Process SNS records. Args: @@ -1875,7 +1848,7 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: add_state_table_record("config", "implemented", "config rule", "rule", config_rule_arn, account_id, region, rule_name) -def deploy_metric_filter(region: str, acct: str, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str): +def deploy_metric_filter(region: str, acct: str, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str) -> None: """Deploy metric filter. Args: @@ -1918,7 +1891,7 @@ def deploy_metric_alarm( metric_comparison_operator: str, metric_treat_missing_data: str, alarm_actions: list, -): +) -> None: """Deploy metric alarm. Args: @@ -1964,7 +1937,7 @@ def deploy_metric_alarm( add_state_table_record("cloudwatch", "implemented", "cloudwatch metric alarm", "alarm", alarm_arn, acct, region, alarm_name) -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> dict: global RESOURCE_TYPE global LAMBDA_START global LAMBDA_FINISH diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 42574b1e4..780dc8494 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -209,7 +209,7 @@ def get_resources_for_solutions_by_account(self, table_name, solutions, account) query_results[solution] = response return query_results - def delete_item(self, table_name, solution_name, record_id): + def delete_item(self, table_name: str, solution_name: str, record_id: str) -> dict: """Delete an item from the dynamodb table Args: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index 156206431..f8b6fa0a1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -19,14 +19,11 @@ if TYPE_CHECKING: from mypy_boto3_kms.client import KMSClient - from mypy_boto3_kms.type_defs import CreateKeyResponseTypeDef, DescribeKeyResponseTypeDef + from mypy_boto3_kms.type_defs import DescribeKeyResponseTypeDef from boto3 import Session - from mypy_boto3_sts.client import STSClient - from mypy_boto3_sts.type_defs import AssumeRoleResponseTypeDef import boto3 from botocore.config import Config -from botocore.client import BaseClient import urllib.parse import json @@ -42,7 +39,6 @@ class sra_kms: UNEXPECTED = "Unexpected!" BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) SERVICE_NAME: Literal["kms"] = "kms" - # SECRETS_KEY_POLICY: str = "" try: MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() @@ -58,55 +54,6 @@ class sra_kms: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - # def define_key_policy(self, target_account_id: str, partition: str, home_region: str, org_id: str, management_account: str) -> str: - # policy_template = { # noqa ECE001 - # "Version": "2012-10-17", - # "Id": "sra-secrets-key", - # "Statement": [ - # { - # "Sid": "Enable IAM User Permissions", - # "Effect": "Allow", - # "Principal": {"AWS": "arn:" + partition + ":iam::" + target_account_id + ":root"}, - # "Action": "kms:*", - # "Resource": "*", - # }, - # { - # "Sid": "Allow access through AWS Secrets Manager for all principals in the account that are authorized to use AWS Secrets Manager", - # "Effect": "Allow", - # "Principal": {"AWS": "*"}, - # "Action": ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*", "kms:ReEncrypt*", "kms:CreateGrant", "kms:DescribeKey"], - # "Resource": "*", - # "Condition": { - # "StringEquals": {"kms:ViaService": "secretsmanager." + home_region + ".amazonaws.com", "aws:PrincipalOrgId": org_id}, - # "StringLike": { - # "kms:EncryptionContext:SecretARN": "arn:aws:secretsmanager:" + home_region + ":*:secret:sra/*", - # "aws:PrincipalArn": "arn:" + partition + ":iam::*:role/sra-execution", - # }, - # }, - # }, - # { - # "Sid": "Allow direct access to key metadata", - # "Effect": "Allow", - # "Principal": {"AWS": "arn:" + partition + ":iam::" + management_account + ":root"}, - # "Action": ["kms:Decrypt", "kms:Describe*", "kms:Get*", "kms:List*"], - # "Resource": "*", - # }, - # { - # "Sid": "Allow alias creation during setup", - # "Effect": "Allow", - # "Principal": {"AWS": "arn:" + partition + ":iam::" + target_account_id + ":root"}, - # "Action": "kms:CreateAlias", - # "Resource": "*", - # "Condition": { - # "StringEquals": {"kms:ViaService": "cloudformation." + home_region + ".amazonaws.com", "kms:CallerAccount": target_account_id} - # }, - # }, - # ], - # } - # self.LOGGER.info(f"Key Policy:\n{json.dumps(policy_template)}") - # self.SECRETS_KEY_POLICY = json.dumps(policy_template) - # return json.dumps(policy_template) - def create_kms_key(self, kms_client: KMSClient, key_policy: str, description: str = "Key description") -> str: """Create KMS key diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index dd91f1973..ad935210b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -34,7 +34,7 @@ class sra_repo: REPO_ZIP_URL = "https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip" REPO_BRANCH = REPO_ZIP_URL.split(".")[1].split("/")[len(REPO_ZIP_URL.split(".")[1].split("/")) - 1] - SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" + SOLUTIONS_DIR: str = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" STAGING_UPLOAD_FOLDER = "/tmp/sra_staging_upload" STAGING_TEMP_FOLDER = "/tmp/sra_temp" From d5cbb35b0947193eb781213700a73b775cbc4063 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 7 Dec 2024 09:29:03 -0700 Subject: [PATCH 264/395] mypy fixes --- .../genai/bedrock_org/lambda/src/app.py | 5 +-- .../bedrock_org/lambda/src/sra_config.py | 44 +++++++++---------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ed49d122e..15c8f41f8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1821,13 +1821,12 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: else: LOGGER.info(f"{statement_id} already exists on {rule_name} lambda function in {account_id} in {region}...") LOGGER.info(f"Creating {rule_name} config rule in {account_id} in {region}...") - # TODO(liamschn): Determine if we need to add a description for the config rules - config_response = config.create_config_rule( + config.create_config_rule( rule_name, lambda_arn, "One_Hour", "CUSTOM_LAMBDA", - rule_name, + f"{rule_name} custom config rule for the {SOLUTION_NAME} solution.", input_params, "DETECTIVE", SOLUTION_NAME, diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index b0cd62764..804bb58bd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -1,6 +1,6 @@ """Custom Resource to setup SRA Config resources in the organization. -Version: 0.1 +Version: 1.0 Config module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples @@ -14,11 +14,8 @@ import os from time import sleep -# import re -# from time import sleep -from typing import TYPE_CHECKING - -# , Literal, Optional, Sequence, Union +from typing import TYPE_CHECKING, Literal, Optional +from typing import cast import boto3 from botocore.config import Config @@ -33,6 +30,7 @@ from mypy_boto3_cloudformation import CloudFormationClient from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_config import ConfigServiceClient + from mypy_boto3_config.type_defs import DescribeConfigRulesResponseTypeDef, ConfigRuleTypeDef, ScopeTypeDef from mypy_boto3_iam.client import IAMClient from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef @@ -54,13 +52,13 @@ class sra_config: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - def get_organization_config_rules(self): + def get_organization_config_rules(self) -> dict: """Get Organization Config Rules.""" # Get the Organization ID org_id: str = self.ORG_CLIENT.describe_organization()["Organization"]["Id"] # Get the Organization Config Rules - response = self.ORG_CLIENT.describe_organization_config_rules( + response = self.ORG_CLIENT.describe_organization_config_rules( # type: ignore OrganizationConfigRuleNames=["sra_config_rule"], OrganizationId=org_id, ) @@ -71,13 +69,13 @@ def get_organization_config_rules(self): # Return the response return response - def put_organization_config_rule(self): + def put_organization_config_rule(self) -> dict: """Put Organization Config Rule.""" # Get the Organization ID org_id: str = self.ORG_CLIENT.describe_organization()["Organization"]["Id"] # Put the Organization Config Rule - response = self.ORG_CLIENT.put_organization_config_rule( + response = self.ORG_CLIENT.put_organization_config_rule( # type: ignore OrganizationConfigRuleName="sra_config_rule", OrganizationId=org_id, ConfigRuleName="sra_config_rule", @@ -89,7 +87,7 @@ def put_organization_config_rule(self): # Return the response return response - def find_config_rule(self, rule_name): + def find_config_rule(self, rule_name: str) -> tuple[bool, DescribeConfigRulesResponseTypeDef]: """Get config rule Args: @@ -111,7 +109,7 @@ def find_config_rule(self, rule_name): except ClientError as e: if e.response["Error"]["Code"] == "NoSuchConfigRuleException": self.LOGGER.info(f"No such config rule: {rule_name}") - return False, {} + return False, cast(DescribeConfigRulesResponseTypeDef, {}) else: self.LOGGER.info(f"Unexpected error: {e}") raise e @@ -120,28 +118,29 @@ def find_config_rule(self, rule_name): return True, response - def create_config_rule(self, rule_name, lambda_arn, max_frequency, owner, description, input_params, eval_mode, solution_name, scope={}): + def create_config_rule(self, rule_name: str, lambda_arn: str, + max_frequency: Literal["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"], + owner: Literal["CUSTOM_LAMBDA", "AWS"], description: str, input_params: dict, + eval_mode: Literal["DETECTIVE", "PROACTIVE"], solution_name: str, scope: dict={}) -> None: """Create Config Rule.""" - # Create the Config Rule - response = self.CONFIG_CLIENT.put_config_rule( + self.CONFIG_CLIENT.put_config_rule( ConfigRule={ "ConfigRuleName": rule_name, "Description": description, - "Scope": scope, + "Scope": cast(ScopeTypeDef, scope), "Source": { "Owner": owner, "SourceIdentifier": lambda_arn, "SourceDetails": [ { "EventSource": "aws.config", - # TODO(liamschn): does messagetype need to be a parameter + # TODO(liamschn): does messagetype need to be a parameter? "MessageType": "ScheduledNotification", "MaximumExecutionFrequency": max_frequency, } ], }, "InputParameters": json.dumps(input_params), - # "MaximumExecutionFrequency": max_frequency, "EvaluationModes": [ { 'Mode': eval_mode @@ -152,12 +151,9 @@ def create_config_rule(self, rule_name, lambda_arn, max_frequency, owner, descri ) # Log the response - sra_config.LOGGER.info(response) - - # Return the response - return response + self.LOGGER.info(f"{rule_name} config rule created...") - def delete_config_rule(self, rule_name): + def delete_config_rule(self, rule_name: str) -> None: """Delete Config Rule.""" # Delete the Config Rule try: @@ -166,7 +162,7 @@ def delete_config_rule(self, rule_name): ) # Log the response - sra_config.LOGGER.info(f"Deleted {rule_name} config rule succeeded.") + self.LOGGER.info(f"Deleted {rule_name} config rule succeeded.") except ClientError as e: if e.response["Error"]["Code"] == "NoSuchConfigRuleException": self.LOGGER.info(f"No such config rule: {rule_name}") From fe03b6f712a23846b7b09ab8ad3e53be67e03e3b Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 7 Dec 2024 09:51:28 -0700 Subject: [PATCH 265/395] minor update to fix return response bug --- .../solutions/genai/bedrock_org/lambda/src/sra_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 804bb58bd..40f99d597 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -87,7 +87,7 @@ def put_organization_config_rule(self) -> dict: # Return the response return response - def find_config_rule(self, rule_name: str) -> tuple[bool, DescribeConfigRulesResponseTypeDef]: + def find_config_rule(self, rule_name: str) -> tuple[bool, dict | DescribeConfigRulesResponseTypeDef]: """Get config rule Args: @@ -97,7 +97,7 @@ def find_config_rule(self, rule_name: str) -> tuple[bool, DescribeConfigRulesRes ValueError: If the config rule is not found Returns: - tuple[bool, dict]: True if the config rule is found, False if not, and the response + tuple[bool, dict | DescribeConfigRulesResponseTypeDef]: True if the config rule is found, False if not, and the response """ try: @@ -109,7 +109,7 @@ def find_config_rule(self, rule_name: str) -> tuple[bool, DescribeConfigRulesRes except ClientError as e: if e.response["Error"]["Code"] == "NoSuchConfigRuleException": self.LOGGER.info(f"No such config rule: {rule_name}") - return False, cast(DescribeConfigRulesResponseTypeDef, {}) + return False, {} else: self.LOGGER.info(f"Unexpected error: {e}") raise e From 1c92eaec6b547b1fbff3623e4f75f5ae13f9daf5 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 7 Dec 2024 10:01:32 -0700 Subject: [PATCH 266/395] remove scope from create_config_rule --- .../solutions/genai/bedrock_org/lambda/src/sra_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 40f99d597..4b497697f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -121,13 +121,12 @@ def find_config_rule(self, rule_name: str) -> tuple[bool, dict | DescribeConfigR def create_config_rule(self, rule_name: str, lambda_arn: str, max_frequency: Literal["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"], owner: Literal["CUSTOM_LAMBDA", "AWS"], description: str, input_params: dict, - eval_mode: Literal["DETECTIVE", "PROACTIVE"], solution_name: str, scope: dict={}) -> None: + eval_mode: Literal["DETECTIVE", "PROACTIVE"], solution_name: str) -> None: """Create Config Rule.""" self.CONFIG_CLIENT.put_config_rule( ConfigRule={ "ConfigRuleName": rule_name, "Description": description, - "Scope": cast(ScopeTypeDef, scope), "Source": { "Owner": owner, "SourceIdentifier": lambda_arn, From 7b35ee01d96a7f336e066b0b3ccc46e872b2ab62 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 7 Dec 2024 10:30:33 -0700 Subject: [PATCH 267/395] change config rule found log message --- .../solutions/genai/bedrock_org/lambda/src/sra_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 4b497697f..8f22fb1d1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -114,7 +114,7 @@ def find_config_rule(self, rule_name: str) -> tuple[bool, dict | DescribeConfigR self.LOGGER.info(f"Unexpected error: {e}") raise e # Log the response - self.LOGGER.info(f"Config rule {rule_name} already exists: {response}") + self.LOGGER.info(f"Config rule {rule_name} exists: {response}") return True, response From bc75ee8a237bf0a1b5191c70323369561011603e Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 10:55:43 -0700 Subject: [PATCH 268/395] fix mypy errors --- .../bedrock_org/lambda/src/sra_dynamodb.py | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 780dc8494..bcbcd8f9f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -8,10 +8,11 @@ from time import sleep import botocore from boto3.session import Session -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Sequence if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource + from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef, DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, AttributeDefinitionTypeDef, ProvisionedThroughputTypeDef class sra_dynamodb: PROFILE = "default" @@ -36,7 +37,7 @@ class sra_dynamodb: raise ValueError(f"Error creating boto3 dymanodb resource and/or client: {error}") from None - def __init__(self, profile="default") -> None: + def __init__(self, profile: str="default") -> None: self.PROFILE = profile try: if self.PROFILE != "default": @@ -50,17 +51,17 @@ def __init__(self, profile="default") -> None: self.LOGGER.exception(self.UNEXPECTED) raise ValueError("Unexpected error!") from None - def create_table(self, table_name): + def create_table(self, table_name: str) -> None: # Define table schema - key_schema = [ + key_schema: Sequence[KeySchemaElementTypeDef] = [ {"AttributeName": "solution_name", "KeyType": "HASH"}, {"AttributeName": "record_id", "KeyType": "RANGE"}, ] # Hash key # Range key - attribute_definitions = [ + attribute_definitions: Sequence[AttributeDefinitionTypeDef] = [ {"AttributeName": "solution_name", "AttributeType": "S"}, # String type {"AttributeName": "record_id", "AttributeType": "S"}, # String type ] - provisioned_throughput = {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} + provisioned_throughput: ProvisionedThroughputTypeDef = {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} # Create table try: @@ -81,7 +82,7 @@ def create_table(self, table_name): # TODO(liamschn): need to add a maximum retry mechanism here sleep(5) - def table_exists(self, table_name): + def table_exists(self, table_name: str) -> bool: # Check if table exists try: self.DYNAMODB_CLIENT.describe_table(TableName=table_name) @@ -91,15 +92,15 @@ def table_exists(self, table_name): self.LOGGER.info(f"{table_name} dynamodb table does not exist...") return False - def generate_id(self): + def generate_id(self) -> str: new_record_id = str("".join(random.choice(string.ascii_letters + string.digits + "-_") for ch in range(8))) return new_record_id - def get_date_time(self): + def get_date_time(self) -> str: now = datetime.now() return now.strftime("%Y%m%d%H%M%S") - def insert_item(self, table_name, solution_name): + def insert_item(self, table_name: str, solution_name: str) -> tuple[str, str]: table = self.DYNAMODB_RESOURCE.Table(table_name) record_id = self.generate_id() date_time = self.get_date_time() @@ -110,10 +111,9 @@ def insert_item(self, table_name, solution_name): "date_time": date_time, } ) - # self.LOGGER.info({"insert_record_response": response}) return record_id, date_time - def update_item(self, table_name, solution_name, record_id, attributes_and_values): + def update_item(self, table_name: str, solution_name: str, record_id: str, attributes_and_values: dict) -> UpdateItemOutputTableTypeDef: self.LOGGER.info(f"Updating {table_name} dynamodb table with {attributes_and_values}") table = self.DYNAMODB_RESOURCE.Table(table_name) update_expression = "" @@ -136,7 +136,7 @@ def update_item(self, table_name, solution_name, record_id, attributes_and_value ) return response - def find_item(self, table_name, solution_name, additional_attributes) -> tuple[bool, dict]: + def find_item(self, table_name: str, solution_name: str, additional_attributes: dict) -> tuple[bool, dict]: """Find an item in the dynamodb table based on the solution name and additional attributes. Args: @@ -164,25 +164,25 @@ def find_item(self, table_name, solution_name, additional_attributes) -> tuple[b "FilterExpression": filter_expression, } - response = table.query(**query_params) + response = table.query(**query_params) # type: ignore if len(response["Items"]) > 1: self.LOGGER.info( - f"Found more than one record that matched record id {response['Items'][0]['record_id']}. Review {table_name} dynamodb table to determine cause." + f"Found more than one record that matched solution name {solution_name}: {additional_attributes} Review {table_name} dynamodb table to determine cause." ) elif len(response["Items"]) < 1: return False, {} self.LOGGER.info(f"Found record id {response['Items'][0]}") return True, response["Items"][0] - def get_unique_values_from_list(self, list_of_values): + def get_unique_values_from_list(self, list_of_values: list) -> list: unique_values = [] for value in list_of_values: if value not in unique_values: unique_values.append(value) return unique_values - def get_distinct_solutions_and_accounts(self, table_name): + def get_distinct_solutions_and_accounts(self, table_name: str) -> tuple[list, list]: table = self.DYNAMODB_RESOURCE.Table(table_name) response = table.scan() solution_names = [item["solution_name"] for item in response["Items"]] @@ -191,25 +191,21 @@ def get_distinct_solutions_and_accounts(self, table_name): accounts = self.get_unique_values_from_list(accounts) return solution_names, accounts - def get_resources_for_solutions_by_account(self, table_name, solutions, account): + def get_resources_for_solutions_by_account(self, table_name: str, solutions: list, account: str) -> dict: table = self.DYNAMODB_RESOURCE.Table(table_name) query_results = {} for solution in solutions: - # expression_attribute_values = {":solution_name": solution} - # filter_expression = {":account": account} - query_params = { "KeyConditionExpression": "solution_name = :solution_name", "ExpressionAttributeValues": {":solution_name": solution, ":account": account}, "FilterExpression": "account = :account", } - - response = table.query(**query_params) + response = table.query(**query_params) # type: ignore self.LOGGER.info(f"response: {response}") query_results[solution] = response return query_results - def delete_item(self, table_name: str, solution_name: str, record_id: str) -> dict: + def delete_item(self, table_name: str, solution_name: str, record_id: str) -> DeleteItemOutputTableTypeDef: """Delete an item from the dynamodb table Args: From a3448f42796e56b19d2a82945bd94ffd360d1bbf Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 11:42:20 -0700 Subject: [PATCH 269/395] fixing mypy issues --- .../solutions/genai/bedrock_org/lambda/src/app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 15c8f41f8..227a8cd5a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -482,7 +482,7 @@ def deploy_state_table() -> None: DRY_RUN_DATA["StateTableCreate"] = f"DRY_RUN: Create the {STATE_TABLE} state table" -def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str, account_id: str, region: str, component_name: str, key_id: str = "") -> str: +def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str, account_id: str | None, region: str, component_name: str, key_id: str = "") -> str: """Add a record to the state table Args: aws_service (str): aws service @@ -499,6 +499,8 @@ def add_state_table_record(aws_service: str, component_state: str, description: None """ LOGGER.info(f"Add a record to the state table for {component_name}") + if account_id == None: + account_id = "Unknown" # TODO(liamschn): check to ensure we got a 200 back from the service API call before inserting the dynamodb records dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) @@ -534,7 +536,7 @@ def add_state_table_record(aws_service: str, component_state: str, description: return sra_resource_record_id -def remove_state_table_record(resource_arn: str) -> dict: +def remove_state_table_record(resource_arn: str) -> Any: """Remove a record from the state table Args: @@ -946,7 +948,7 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: cloudwatch.SINK_POLICY["Statement"][0]["Condition"]["ForAnyValue:StringEquals"]["aws:PrincipalOrgID"] = ORGANIZATION_ID if search_oam_sink[0] is False and DRY_RUN is True: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink doesn't exist; skip search for sink policy...") - search_oam_sink_policy = False, {} + search_oam_sink_policy: tuple[bool, dict] = False, {} else: search_oam_sink_policy = cloudwatch.find_oam_sink_policy(oam_sink_arn) if search_oam_sink_policy[0] is False: From 72ec80125ee983a841294647e98dd23e02ebc7e8 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 11:52:33 -0700 Subject: [PATCH 270/395] fix mypy issues --- .../bedrock_org/lambda/src/cfnresponse.py | 1 + .../bedrock_org/lambda/src/sra_cloudwatch.py | 36 ++++++------------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py index 4efff0a0c..485f2e31e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py @@ -1,3 +1,4 @@ +# mypy: ignore-errors # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: MIT-0 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 921913d21..217f03a67 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -1,6 +1,6 @@ """Custom Resource to setup SRA Config resources in the organization. -Version: 0.1 +Version: 1.0 CloudWatch module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples @@ -14,7 +14,7 @@ import os from time import sleep -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import boto3 from botocore.config import Config @@ -29,7 +29,7 @@ from mypy_boto3_logs import CloudWatchLogsClient from mypy_boto3_oam import CloudWatchObservabilityAccessManagerClient from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef - from mypy_boto3_cloudwatch.type_defs import MetricFilterTypeDef, GetMetricDataResponseTypeDef + # from mypy_boto3_cloudwatch.type_defs import StatisticType # , MetricFilterTypeDef, GetMetricDataResponseTypeDef, from mypy_boto3_logs.type_defs import FilteredLogEventTypeDef, GetLogEventsResponseTypeDef @@ -44,9 +44,9 @@ class sra_cloudwatch: SINK_NAME = "sra-oam-sink" SOLUTION_NAME: str = "sra-set-solution-name" - SINK_POLICY = {} - CROSS_ACCOUNT_ROLE_NAME = "CloudWatch-CrossAccountSharingRole" - CROSS_ACCOUNT_TRUST_POLICY = {} + SINK_POLICY: dict = {} + CROSS_ACCOUNT_ROLE_NAME: str = "CloudWatch-CrossAccountSharingRole" + CROSS_ACCOUNT_TRUST_POLICY: dict = {} try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() @@ -132,10 +132,10 @@ def create_metric_alarm( alarm_description: str, metric_name: str, metric_namespace: str, - metric_statistic: str, + metric_statistic: Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum'], metric_period: int, metric_threshold: float, - metric_comparison_operator: str, + metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', 'LessThanThreshold'], metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list, @@ -172,10 +172,10 @@ def update_metric_alarm( alarm_description: str, metric_name: str, metric_namespace: str, - metric_statistic: str, + metric_statistic: Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum'], metric_period: int, metric_threshold: float, - metric_comparison_operator: str, + metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', 'LessThanThreshold'], metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list, @@ -246,22 +246,6 @@ def create_oam_sink(self, sink_name: str) -> str: self.LOGGER.error(f"{self.UNEXPECTED} error: {e}") raise ValueError(f"Unexpected error executing Lambda function. {e}") from None - # def delete_oam_sink(self, sink_arn: str) -> None: - # """Delete the Observability Access Manager sink for SRA in the organization. - - # Args: - # sink_arn (str): ARN of the sink - - # Returns: - # None - # """ - # try: - # self.CWOAM_CLIENT.delete_sink(Identifier=sink_arn) - # self.LOGGER.info(f"Observability access manager sink {sink_arn} deleted") - # except ClientError as e: - # self.LOGGER.info(self.UNEXPECTED) - # raise ValueError(f"Unexpected error executing Lambda function. {e}") from None - def find_oam_sink_policy(self, sink_arn: str) -> tuple[bool, dict]: """Check if the Observability Access Manager sink policy for SRA in the organization exists. From 251cdfaeb9e15ae58544c74ab784610534b19228 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 12:09:43 -0700 Subject: [PATCH 271/395] fix mypy issues; remove unused code and parameters (commented out for now) --- .../genai/bedrock_org/lambda/src/sra_iam.py | 322 +++++++++--------- 1 file changed, 152 insertions(+), 170 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index afeba2d9c..1697ba579 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -14,12 +14,8 @@ import os from time import sleep -# import re -# from time import sleep from typing import TYPE_CHECKING -# , Literal, Optional, Sequence, Union - import boto3 from botocore.config import Config from botocore.exceptions import ClientError @@ -27,7 +23,7 @@ import urllib.parse import json -import cfnresponse +# import cfnresponse if TYPE_CHECKING: from mypy_boto3_cloudformation import CloudFormationClient @@ -36,7 +32,6 @@ from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef -# TODO(liamschn): build execution role in management account class sra_iam: # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -44,23 +39,11 @@ class sra_iam: LOGGER.setLevel(log_level) # Global Variables - STACKSET_NAME: str = "sra-stackset-execution-role" - STACKSET2_NAME: str = "sra-stackset-admin-role" - - RESOURCE_TYPE: str = "" - # CLOUDFORMATION_THROTTLE_PERIOD = 0.2 - # CLOUDFORMATION_PAGE_SIZE = 100 - SRA_STAGING_BUCKET: str = "" UNEXPECTED = "Unexpected!" - # EMPTY_VALUE = "NONE" BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) - SRA_SOLUTION_NAME = "sra-common-prerequisites" # todo(liamschn): solution name should be in the main/app module - CFN_RESOURCE_ID: str = "sra-iam-function" CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" SRA_EXECUTION_ROLE: str = "sra-execution" # todo(liamschn): parameterize this role name - SRA_STACKSET_ROLE: str = "sra-stackset" # todo(liamschn): parameterize this role name - SRA_EXECUTION_ROLE_STACKSET_ID: str = "" - SRA_STACKSET_POLICY_NAME: str = "sra-assume-role-access" + # SRA_EXECUTION_ROLE_STACKSET_ID: str = "" try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() @@ -78,19 +61,19 @@ class sra_iam: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - SRA_EXECUTION_TRUST: dict = { - "Version": "2012-10-17", - "Statement": [ - {"Effect": "Allow", "Principal": {"AWS": "arn:" + PARTITION + ":iam::" + MANAGEMENT_ACCOUNT + ":root"}, "Action": "sts:AssumeRole"} - ], - } + # SRA_EXECUTION_TRUST: dict = { + # "Version": "2012-10-17", + # "Statement": [ + # {"Effect": "Allow", "Principal": {"AWS": "arn:" + PARTITION + ":iam::" + MANAGEMENT_ACCOUNT + ":root"}, "Action": "sts:AssumeRole"} + # ], + # } - SRA_STACKSET_POLICY: dict = { - "Version": "2012-10-17", - "Statement": [ - {"Action": "sts:AssumeRole", "Resource": "arn:aws:iam::*:role/" + SRA_EXECUTION_ROLE, "Effect": "Allow", "Sid": "AssumeExecutionRole"} - ], - } + # SRA_STACKSET_POLICY: dict = { + # "Version": "2012-10-17", + # "Statement": [ + # {"Action": "sts:AssumeRole", "Resource": "arn:aws:iam::*:role/" + SRA_EXECUTION_ROLE, "Effect": "Allow", "Sid": "AssumeExecutionRole"} + # ], + # } SRA_POLICY_DOCUMENTS: dict = { "sra-lambda-basic-execution": { @@ -106,11 +89,11 @@ class sra_iam: }, } - # TODO(liamschn): move stackset trust document to SRA_TRUST_DOCUMENTS variable - SRA_STACKSET_TRUST: dict = { - "Version": "2012-10-17", - "Statement": [{"Effect": "Allow", "Principal": {"Service": "cloudformation.amazonaws.com"}, "Action": "sts:AssumeRole"}], - } + # # TODO(liamschn): move stackset trust document to SRA_TRUST_DOCUMENTS variable + # SRA_STACKSET_TRUST: dict = { + # "Version": "2012-10-17", + # "Statement": [{"Effect": "Allow", "Principal": {"Service": "cloudformation.amazonaws.com"}, "Action": "sts:AssumeRole"}], + # } SRA_TRUST_DOCUMENTS: dict = { "sra-config-rule": { @@ -131,136 +114,136 @@ class sra_iam: # Configuration # TODO(liamschn): move CFN params to cfn module - CFN_CAPABILITIES = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"] - CFN_PARAMETERS = [ - {"ParameterKey": "pManagementAccountId", "ParameterValue": MANAGEMENT_ACCOUNT}, - # Add more parameters as needed - ] - ACCOUNT_IDS = [] # Will be filled with accounts in the root OU - ROOT_OU: str = "" - REGION_NAMES = ["us-east-1"] # only global region for iam - - # Organization service functions - def get_accounts_in_root_ou(self): - self.ACCOUNT_IDS = [] - self.ROOT_OU = self.ORG_CLIENT.list_roots()["Roots"][0]["Id"] - # root_ous = self.ORG_CLIENT.list_roots()["Roots"] - # for root_ou in root_ous: - # paginator = self.ORG_CLIENT.get_paginator("list_accounts_for_parent") - # for page in paginator.paginate(ParentId=root_ou["Id"]): - # for account in page["Accounts"]: - # self.ACCOUNT_IDS.append(account["Id"]) - for account in self.ORG_CLIENT.list_accounts()["Accounts"]: - if account["Status"] == "ACTIVE": - self.ACCOUNT_IDS.append(account["Id"]) + # CFN_CAPABILITIES = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"] + # CFN_PARAMETERS = [ + # {"ParameterKey": "pManagementAccountId", "ParameterValue": MANAGEMENT_ACCOUNT}, + # # Add more parameters as needed + # ] + # ACCOUNT_IDS: list = [] # Will be filled with accounts in the root OU + # ROOT_OU: str = "" + # REGION_NAMES = ["us-east-1"] # only global region for iam + + # # Organization service functions + # def get_accounts_in_root_ou(self): + # self.ACCOUNT_IDS = [] + # self.ROOT_OU = self.ORG_CLIENT.list_roots()["Roots"][0]["Id"] + # # root_ous = self.ORG_CLIENT.list_roots()["Roots"] + # # for root_ou in root_ous: + # # paginator = self.ORG_CLIENT.get_paginator("list_accounts_for_parent") + # # for page in paginator.paginate(ParentId=root_ou["Id"]): + # # for account in page["Accounts"]: + # # self.ACCOUNT_IDS.append(account["Id"]) + # for account in self.ORG_CLIENT.list_accounts()["Accounts"]: + # if account["Status"] == "ACTIVE": + # self.ACCOUNT_IDS.append(account["Id"]) # CloudFormation service functions # TODO(liamschn): Move cloudformation functions into its own class module - def create_stack(self, parameters, capabilities, template_url, stack_name): - # todo(liamschn): instead of building via stack, build in python boto3 (both admin and execution roles) - response = self.CFN_CLIENT.create_stack( - StackName=stack_name, - TemplateURL=template_url, - Parameters=parameters, - Capabilities=capabilities, - ) - self.LOGGER.info(f"Stack {stack_name} creation initiated.") - return response - - def create_stack_set(self, parameters, capabilities, template_url, stack_set_name): - response = self.CFN_CLIENT.create_stack_set( - StackSetName=stack_set_name, - TemplateURL=template_url, - Parameters=parameters, - Capabilities=capabilities, - PermissionModel="SERVICE_MANAGED", - AutoDeployment={"Enabled": True, "RetainStacksOnAccountRemoval": False}, - ) - self.LOGGER.info(f"StackSet {stack_set_name} creation initiated.") - return response - - def create_stack_instances(self, root_ou_id, stack_set_name): - response = self.CFN_CLIENT.create_stack_instances( - StackSetName=stack_set_name, - DeploymentTargets={"OrganizationalUnitIds": [root_ou_id]}, - Regions=self.REGION_NAMES, - OperationPreferences={ - "FailureToleranceCount": 0, - "MaxConcurrentCount": 1, - }, - ) - self.LOGGER.info(f"Stack instances creation initiated for regions: {self.REGION_NAMES}.") - return response - - def list_stack_instances(self, stack_set_name): - response = self.CFN_CLIENT.list_stack_instances( - StackSetName=stack_set_name, - ) - return response - - def check_for_stack_set(self, stack_set_name) -> bool: - try: - response = self.CFN_CLIENT.describe_stack_set(StackSetName=stack_set_name) - self.SRA_EXECUTION_ROLE_STACKSET_ID = response["StackSet"]["StackSetId"] - return True - except self.CFN_CLIENT.exceptions.StackSetNotFoundException as error: - self.LOGGER.info(f"CloudFormation StackSet: {stack_set_name} not found.") - return False - - def wait_for_stack_instances(self, stack_set_name, retries: int = 30): # todo(liamschn): parameterize retries - self.LOGGER.info(f"Waiting for stack instances to complete for {stack_set_name} stackset...") - self.LOGGER.info({"Accounts": self.ACCOUNT_IDS}) - found_accounts = [] - while True: - self.LOGGER.info("Getting stack instances...") - paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") - found_all_accounts = True - response_iterator = paginator.paginate( - StackSetName=stack_set_name, - ) - for page in response_iterator: - self.LOGGER.info("Iterating through stack instances...") - for instance in page["Summaries"]: - if instance["Account"] in found_accounts: - continue - else: - found_accounts.append(instance["Account"]) - for account in self.ACCOUNT_IDS: - self.LOGGER.info("Checking for stack instance for all member accounts...") - if account != self.MANAGEMENT_ACCOUNT: - self.LOGGER.info(f"Checking for stack instance for {account} account...") - if account in found_accounts: - self.LOGGER.info(f"Stack instance for {account} account found.") - else: - self.LOGGER.info(f"Stack instance for {account} account not found.") - found_all_accounts = False - if found_all_accounts is True: - break - else: - self.LOGGER.info("All accounts not found. Waiting 10 seconds before retrying...") - # TODO(liamschn): need to add a maximum retry mechanism here - sleep(10) - ready = False - i = 0 - while ready is False: - ready = True - paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") - response_iterator = paginator.paginate( - StackSetName=stack_set_name, - ) - for page in response_iterator: - for instance in page["Summaries"]: - if instance["StackInstanceStatus"]["DetailedStatus"] != "SUCCEEDED": - self.LOGGER.info(f"Stack instance in {instance['Account']} shows {instance['StackInstanceStatus']['DetailedStatus']}") - ready = False - i += 1 - if i > retries: - self.LOGGER.info("Timed out! Please check cloudformation stackset and try again.") - raise Exception("Timed out waiting for stackset!") - if ready is False: - self.LOGGER.info("Waiting 10 seconds before retrying...") - sleep(10) - return + # def create_stack(self, parameters, capabilities, template_url, stack_name): + # # todo(liamschn): instead of building via stack, build in python boto3 (both admin and execution roles) + # response = self.CFN_CLIENT.create_stack( + # StackName=stack_name, + # TemplateURL=template_url, + # Parameters=parameters, + # Capabilities=capabilities, + # ) + # self.LOGGER.info(f"Stack {stack_name} creation initiated.") + # return response + + # def create_stack_set(self, parameters, capabilities, template_url, stack_set_name): + # response = self.CFN_CLIENT.create_stack_set( + # StackSetName=stack_set_name, + # TemplateURL=template_url, + # Parameters=parameters, + # Capabilities=capabilities, + # PermissionModel="SERVICE_MANAGED", + # AutoDeployment={"Enabled": True, "RetainStacksOnAccountRemoval": False}, + # ) + # self.LOGGER.info(f"StackSet {stack_set_name} creation initiated.") + # return response + + # def create_stack_instances(self, root_ou_id, stack_set_name): + # response = self.CFN_CLIENT.create_stack_instances( + # StackSetName=stack_set_name, + # DeploymentTargets={"OrganizationalUnitIds": [root_ou_id]}, + # Regions=self.REGION_NAMES, + # OperationPreferences={ + # "FailureToleranceCount": 0, + # "MaxConcurrentCount": 1, + # }, + # ) + # self.LOGGER.info(f"Stack instances creation initiated for regions: {self.REGION_NAMES}.") + # return response + + # def list_stack_instances(self, stack_set_name): + # response = self.CFN_CLIENT.list_stack_instances( + # StackSetName=stack_set_name, + # ) + # return response + + # def check_for_stack_set(self, stack_set_name) -> bool: + # try: + # response = self.CFN_CLIENT.describe_stack_set(StackSetName=stack_set_name) + # self.SRA_EXECUTION_ROLE_STACKSET_ID = response["StackSet"]["StackSetId"] + # return True + # except self.CFN_CLIENT.exceptions.StackSetNotFoundException as error: + # self.LOGGER.info(f"CloudFormation StackSet: {stack_set_name} not found.") + # return False + + # def wait_for_stack_instances(self, stack_set_name, retries: int = 30): # todo(liamschn): parameterize retries + # self.LOGGER.info(f"Waiting for stack instances to complete for {stack_set_name} stackset...") + # self.LOGGER.info({"Accounts": self.ACCOUNT_IDS}) + # found_accounts = [] + # while True: + # self.LOGGER.info("Getting stack instances...") + # paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") + # found_all_accounts = True + # response_iterator = paginator.paginate( + # StackSetName=stack_set_name, + # ) + # for page in response_iterator: + # self.LOGGER.info("Iterating through stack instances...") + # for instance in page["Summaries"]: + # if instance["Account"] in found_accounts: + # continue + # else: + # found_accounts.append(instance["Account"]) + # for account in self.ACCOUNT_IDS: + # self.LOGGER.info("Checking for stack instance for all member accounts...") + # if account != self.MANAGEMENT_ACCOUNT: + # self.LOGGER.info(f"Checking for stack instance for {account} account...") + # if account in found_accounts: + # self.LOGGER.info(f"Stack instance for {account} account found.") + # else: + # self.LOGGER.info(f"Stack instance for {account} account not found.") + # found_all_accounts = False + # if found_all_accounts is True: + # break + # else: + # self.LOGGER.info("All accounts not found. Waiting 10 seconds before retrying...") + # # TODO(liamschn): need to add a maximum retry mechanism here + # sleep(10) + # ready = False + # i = 0 + # while ready is False: + # ready = True + # paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") + # response_iterator = paginator.paginate( + # StackSetName=stack_set_name, + # ) + # for page in response_iterator: + # for instance in page["Summaries"]: + # if instance["StackInstanceStatus"]["DetailedStatus"] != "SUCCEEDED": + # self.LOGGER.info(f"Stack instance in {instance['Account']} shows {instance['StackInstanceStatus']['DetailedStatus']}") + # ready = False + # i += 1 + # if i > retries: + # self.LOGGER.info("Timed out! Please check cloudformation stackset and try again.") + # raise Exception("Timed out waiting for stackset!") + # if ready is False: + # self.LOGGER.info("Waiting 10 seconds before retrying...") + # sleep(10) + # return # IAM service functions def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> CreateRoleResponseTypeDef: @@ -373,7 +356,7 @@ def delete_role(self, role_name: str) -> EmptyResponseMetadataTypeDef: self.LOGGER.info("Deleting role %s.", role_name) return self.IAM_CLIENT.delete_role(RoleName=role_name) - def check_iam_role_exists(self, role_name): + def check_iam_role_exists(self, role_name: str) -> tuple[bool, str | None]: """ Checks if an IAM role exists. @@ -392,10 +375,9 @@ def check_iam_role_exists(self, role_name): self.LOGGER.info(f"The role '{role_name}' does not exist.") return False, None else: - # Handle other possible exceptions (e.g., permission issues) raise ValueError(f"Error performing get_role operation: {error}") from None - def check_iam_policy_exists(self, policy_arn): + def check_iam_policy_exists(self, policy_arn: str) -> bool: """ Checks if an IAM policy exists. @@ -419,7 +401,7 @@ def check_iam_policy_exists(self, policy_arn): else: raise ValueError(f"Unexpected error: {error}") from None - def check_iam_policy_attached(self, role_name, policy_arn): + def check_iam_policy_attached(self, role_name: str, policy_arn: str) -> bool: """ Checks if an IAM policy is attached to an IAM role. @@ -447,7 +429,7 @@ def check_iam_policy_attached(self, role_name, policy_arn): self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") raise ValueError(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") from None - def list_attached_iam_policies(self, role_name): + def list_attached_iam_policies(self, role_name: str) -> list: """ Lists all IAM policies attached to an IAM role. @@ -465,11 +447,11 @@ def list_attached_iam_policies(self, role_name): except ClientError as error: if error.response["Error"]["Code"] == "NoSuchEntity": self.LOGGER.info(f"The role '{role_name}' does not exist.") - return + return [] self.LOGGER.error(f"Error listing attached policies for role '{role_name}': {error}") raise ValueError(f"Error listing attached policies for role '{role_name}': {error}") from None - def get_iam_global_region(self): + def get_iam_global_region(self) -> str: partition_to_region = { 'aws': 'us-east-1', 'aws-cn': 'cn-north-1', From 609370b6b006ca66adce4ccf9654e259290eb6f0 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 12:21:28 -0700 Subject: [PATCH 272/395] fix mypy issues --- .../solutions/genai/bedrock_org/lambda/src/app.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 227a8cd5a..9659ca584 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -20,7 +20,7 @@ import sra_cloudwatch import sra_kms -from typing import Dict, Any, List +from typing import Dict, Any, List, Literal # import sra_lambda @@ -482,7 +482,7 @@ def deploy_state_table() -> None: DRY_RUN_DATA["StateTableCreate"] = f"DRY_RUN: Create the {STATE_TABLE} state table" -def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str, account_id: str | None, region: str, component_name: str, key_id: str = "") -> str: +def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str | None, account_id: str | None, region: str, component_name: str, key_id: str = "") -> str: """Add a record to the state table Args: aws_service (str): aws service @@ -536,7 +536,7 @@ def add_state_table_record(aws_service: str, component_state: str, description: return sra_resource_record_id -def remove_state_table_record(resource_arn: str) -> Any: +def remove_state_table_record(resource_arn: str | None) -> Any: """Remove a record from the state table Args: @@ -1657,6 +1657,7 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: global CFN_RESPONSE_DATA iam.IAM_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "iam", REGION) LOGGER.info(f"Deploying IAM {rule_name} execution role for rule lambda in {account_id}...") + role_arn = "" iam_role_search = iam.check_iam_role_exists(rule_name) if iam_role_search[0] is False: if DRY_RUN is False: @@ -1672,6 +1673,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"{rule_name} IAM role already exists.") role_arn = iam_role_search[1] + if role_arn == None: + role_arn = "" # add IAM role state table record add_state_table_record("iam", "implemented", "role for config rule", "role", role_arn, account_id, "Global", rule_name) @@ -1885,11 +1888,11 @@ def deploy_metric_alarm( alarm_description: str, metric_name: str, metric_namespace: str, - metric_statistic: str, + metric_statistic: Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum'], metric_period: int, metric_evaluation_periods: int, metric_threshold: float, - metric_comparison_operator: str, + metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', 'LessThanThreshold'], metric_treat_missing_data: str, alarm_actions: list, ) -> None: From bcb3b437670f968fad49e76711df5e3205af8154 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 12:40:27 -0700 Subject: [PATCH 273/395] changing definition --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index bcbcd8f9f..3aab2ea24 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -9,10 +9,11 @@ import botocore from boto3.session import Session from typing import TYPE_CHECKING, Sequence +from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource - from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef, DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, AttributeDefinitionTypeDef, ProvisionedThroughputTypeDef + from mypy_boto3_dynamodb.type_defs import DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, AttributeDefinitionTypeDef, ProvisionedThroughputTypeDef class sra_dynamodb: PROFILE = "default" From 76bc14551fc57e5ff07584b6d52fd5454a7eaaf3 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 12:42:00 -0700 Subject: [PATCH 274/395] update imports --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 3aab2ea24..d558dfe3e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -9,11 +9,10 @@ import botocore from boto3.session import Session from typing import TYPE_CHECKING, Sequence -from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef +from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef, AttributeDefinitionTypeDef, DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, ProvisionedThroughputTypeDef if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource - from mypy_boto3_dynamodb.type_defs import DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, AttributeDefinitionTypeDef, ProvisionedThroughputTypeDef class sra_dynamodb: PROFILE = "default" From 58a2ce7963e4a7907500c13d8b30ee2f5ddcdedf Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 12:45:42 -0700 Subject: [PATCH 275/395] update imports --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index d558dfe3e..37affcaab 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -9,10 +9,12 @@ import botocore from boto3.session import Session from typing import TYPE_CHECKING, Sequence -from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef, AttributeDefinitionTypeDef, DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, ProvisionedThroughputTypeDef +from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource + from mypy_boto3_dynamodb.type_defs import AttributeDefinitionTypeDef, DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, ProvisionedThroughputTypeDef + class sra_dynamodb: PROFILE = "default" From 73ae2bad91b18a4f3bdc230a68d68661084ccf7e Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 12:50:38 -0700 Subject: [PATCH 276/395] add mypy_boto3_dynamodb to requirements --- .../solutions/genai/bedrock_org/lambda/src/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt index 9acb7c1db..0fa70dbcb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt @@ -1,3 +1,4 @@ #install latest # TODO(liamschn): not using crhelper -crhelper \ No newline at end of file +crhelper +mypy_boto3_dynamodb \ No newline at end of file From 5a3cfb53bac0303daa5d74ce6f6d66bbed5f6afc Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 12:55:57 -0700 Subject: [PATCH 277/395] change output types to Any; remove mypy dynamodb import --- .../genai/bedrock_org/lambda/src/requirements.txt | 3 +-- .../genai/bedrock_org/lambda/src/sra_dynamodb.py | 9 ++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt index 0fa70dbcb..9acb7c1db 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt @@ -1,4 +1,3 @@ #install latest # TODO(liamschn): not using crhelper -crhelper -mypy_boto3_dynamodb \ No newline at end of file +crhelper \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 37affcaab..258e4ba4e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -8,12 +8,11 @@ from time import sleep import botocore from boto3.session import Session -from typing import TYPE_CHECKING, Sequence -from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef +from typing import TYPE_CHECKING, Any, Sequence, cast if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource - from mypy_boto3_dynamodb.type_defs import AttributeDefinitionTypeDef, DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, ProvisionedThroughputTypeDef + from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef, AttributeDefinitionTypeDef, DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, ProvisionedThroughputTypeDef class sra_dynamodb: @@ -115,7 +114,7 @@ def insert_item(self, table_name: str, solution_name: str) -> tuple[str, str]: ) return record_id, date_time - def update_item(self, table_name: str, solution_name: str, record_id: str, attributes_and_values: dict) -> UpdateItemOutputTableTypeDef: + def update_item(self, table_name: str, solution_name: str, record_id: str, attributes_and_values: dict) -> Any: self.LOGGER.info(f"Updating {table_name} dynamodb table with {attributes_and_values}") table = self.DYNAMODB_RESOURCE.Table(table_name) update_expression = "" @@ -207,7 +206,7 @@ def get_resources_for_solutions_by_account(self, table_name: str, solutions: lis query_results[solution] = response return query_results - def delete_item(self, table_name: str, solution_name: str, record_id: str) -> DeleteItemOutputTableTypeDef: + def delete_item(self, table_name: str, solution_name: str, record_id: str) -> Any: """Delete an item from the dynamodb table Args: From 21326f739d59581ef2673ee430eb71fcdee8d9b1 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 13:22:54 -0700 Subject: [PATCH 278/395] fix mypy issues --- .../genai/bedrock_org/lambda/src/app.py | 10 +-- .../bedrock_org/lambda/src/sra_lambda.py | 76 ++++++++++--------- 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 9659ca584..8cfba87c9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1212,14 +1212,14 @@ def delete_custom_config_rule(rule_name: str, acct: str, region: str) -> None: # Delete lambda for custom config rule lambdas.LAMBDA_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "lambda", region) lambda_search = lambdas.find_lambda_function(rule_name) - if lambda_search is not None: + if lambda_search != "None": if DRY_RUN is False: LOGGER.info(f"Deleting {rule_name} lambda function for account {acct} in {region}") lambdas.delete_lambda_function(rule_name) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"Deleted {rule_name} lambda function" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] -= 1 - remove_state_table_record(lambda_search["Configuration"]["FunctionArn"]) + remove_state_table_record(lambda_search) else: LOGGER.info(f"DRY_RUN: Deleting {rule_name} lambda function for account {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = f"DRY_RUN: Delete {rule_name} lambda function" @@ -1777,7 +1777,7 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio lambdas.LAMBDA_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "lambda", region) LOGGER.info(f"Deploying lambda function for {rule_name} config rule to {account_id} in {region}...") lambda_function_search = lambdas.find_lambda_function(rule_name) - if lambda_function_search == None: + if lambda_function_search == "None": LOGGER.info(f"{rule_name} lambda function not found in {account_id}. Creating...") lambda_source_zip = f"/tmp/sra_staging_upload/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" LOGGER.info(f"Lambda zip file: {lambda_source_zip}") @@ -1791,12 +1791,12 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio 512, SOLUTION_NAME, ) - lambda_arn = lambda_create["Configuration"]["FunctionArn"] + lambda_arn = lambda_create # add Lambda state table record add_state_table_record("lambda", "implemented", "lambda for config rule", "lambda", lambda_arn, account_id, region, rule_name) else: LOGGER.info(f"{rule_name} already exists in {account_id}. Search result: {lambda_function_search}") - lambda_arn = lambda_function_search["Configuration"]["FunctionArn"] + lambda_arn = lambda_function_search # add Lambda state table record add_state_table_record("lambda", "implemented", "lambda for config rule", "lambda", lambda_arn, account_id, region, rule_name) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index b1be54bdb..19e50a51b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -1,6 +1,6 @@ """Custom Resource to setup SRA Lambda resources in the organization. -Version: 0.1 +Version: 1.0 LAMBDA module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples @@ -14,7 +14,7 @@ import os from time import sleep -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import boto3 from botocore.config import Config @@ -40,26 +40,26 @@ class sra_lambda: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - def find_lambda_function(self, function_name): + def find_lambda_function(self, function_name: str) -> str: """Find Lambda Function. Args: function_name: Lambda function name Returns: - Lambda function details if found, else None + Lambda function arn if found, else "None" """ try: response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) - return response + return response["Configuration"]["FunctionArn"] except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": - return None + return "None" else: - self.LOGGER.error(e) - return None + self.LOGGER.error(f"Error encountered searching for lambda function: {e}") + return "None" - def create_lambda_function(self, code_zip_file, role_arn, function_name, handler, runtime, timeout, memory_size, solution_name): + def create_lambda_function(self, code_zip_file: str, role_arn: str, function_name: str, handler: str, runtime: str, timeout: int, memory_size: int, solution_name: str) -> str: """Create Lambda Function.""" self.LOGGER.info(f"Role ARN passed to create_lambda_function: {role_arn}...") max_retries = 10 @@ -70,7 +70,7 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler try: create_response = self.LAMBDA_CLIENT.create_function( FunctionName=function_name, - Runtime=runtime, + Runtime=runtime, # type: ignore Handler=handler, Role=role_arn, Code={"ZipFile": open(code_zip_file, "rb").read()}, @@ -120,21 +120,21 @@ def create_lambda_function(self, code_zip_file, role_arn, function_name, handler else: self.LOGGER.info(f"Error getting Lambda function: {e}") raise ValueError(f"Error getting Lambda function: {e}") from None - return get_response + return get_response["Configuration"]["FunctionArn"] - def get_permissions(self, function_name): + def get_permissions(self, function_name: str) -> str: """Get Lambda Function Permissions.""" try: response = self.LAMBDA_CLIENT.get_policy(FunctionName=function_name) - return response + return response["Policy"] except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": - return None + return "None" else: self.LOGGER.error(e) - return None + return "None" - def put_permissions(self, function_name, statement_id, principal, action, source_arn): + def put_permissions(self, function_name: str, statement_id: str, principal: str, action: str, source_arn: str) -> str: """Put Lambda Function Permissions.""" try: response = self.LAMBDA_CLIENT.add_permission( @@ -144,17 +144,17 @@ def put_permissions(self, function_name, statement_id, principal, action, source Principal=principal, SourceArn=source_arn, ) - return response + return response["Statement"] except ClientError as e: if e.response["Error"]["Code"] == "ResourceConflictException": # TODO(liamschn): consider updating the permission here self.LOGGER.info(f"{function_name} permission already exists.") - return None + return "None" else: self.LOGGER.info(f"Error adding lambda permission: {e}") - return None + return "None" - def put_permissions_acct(self, function_name, statement_id, principal, action, source_acct): + def put_permissions_acct(self, function_name: str, statement_id: str, principal: str, action: str, source_acct: str) -> str: """Put Lambda Function Permissions.""" try: response = self.LAMBDA_CLIENT.add_permission( @@ -164,30 +164,38 @@ def put_permissions_acct(self, function_name, statement_id, principal, action, s Principal=principal, SourceAccount=source_acct, ) - return response + return response["Statement"] except ClientError as e: self.LOGGER.error(e) - return None + return "None" - def remove_permissions(self, function_name, statement_id): + def remove_permissions(self, function_name: str, statement_id: str) -> None: """Remove Lambda Function Permissions.""" try: - response = self.LAMBDA_CLIENT.remove_permission(FunctionName=function_name, StatementId=statement_id) - return response + self.LAMBDA_CLIENT.remove_permission(FunctionName=function_name, StatementId=statement_id) + return except ClientError as e: - self.LOGGER.error(e) - return None + if e.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"{function_name} permission not found.") + return + else: + self.LOGGER.info(f"Error removing lambda permission: {e}") + return - def delete_lambda_function(self, function_name): + def delete_lambda_function(self, function_name: str) -> None: """Delete Lambda Function.""" try: - response = self.LAMBDA_CLIENT.delete_function(FunctionName=function_name) - return response + self.LAMBDA_CLIENT.delete_function(FunctionName=function_name) + return except ClientError as e: - self.LOGGER.error(e) - return None + if e.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"{function_name} function not found.") + return + else: + self.LOGGER.info(f"Error deleting lambda function: {e}") + return - def get_lambda_execution_role(self, function_name) -> str: + def get_lambda_execution_role(self, function_name: str) -> str: """Get Lambda Function Execution Role. Args: @@ -206,7 +214,7 @@ def get_lambda_execution_role(self, function_name) -> str: self.LOGGER.error(e) return "Error" - def find_permission(self, function_name, statement_id): + def find_permission(self, function_name: str, statement_id: str) -> bool: """Find Lambda Function Permissions.""" try: response = self.LAMBDA_CLIENT.get_policy(FunctionName=function_name) From befada746dddb32c1c48c09541314e888c75e788 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 15:06:22 -0700 Subject: [PATCH 279/395] fixing mypy issues; closing other todos --- .../genai/bedrock_org/lambda/src/sra_repo.py | 18 ++++++------------ .../genai/bedrock_org/lambda/src/sra_sns.py | 4 +--- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index ad935210b..a44c40adf 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -26,12 +26,6 @@ class sra_repo: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - # class attributes # todo(liamschn): make these parameters - # REPO_RAW_FILE_URL_PREFIX = "https://raw.githubusercontent.com/liamschn/aws-security-reference-architecture-examples/sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/" - # RULE_LAMBDA_FILES = {} - # RULE_LAMBDA_FILES["sra_check_iam_users"] = "sra_check_iam_users.py" - # REPO_BRANCH = REPO_RAW_FILE_URL_PREFIX.split("/")[5] - REPO_ZIP_URL = "https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip" REPO_BRANCH = REPO_ZIP_URL.split(".")[1].split("/")[len(REPO_ZIP_URL.split(".")[1].split("/")) - 1] SOLUTIONS_DIR: str = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" @@ -40,9 +34,9 @@ class sra_repo: CONFIG_RULES: dict = {} - STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): get from SSM parameter - PIP_VERSION = pip.__version__ - URLLIB3_VERSION = urllib3.__version__ + # STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): get from SSM parameter + # PIP_VERSION = pip.__version__ + # URLLIB3_VERSION = urllib3.__version__ # class methods def pip_install(self, requirements: str, package_temp_directory: str, individual: bool = False) -> None: @@ -120,7 +114,7 @@ def zip_folder(self, path: str, zip_file: ZipFile, layer: bool = False) -> None: # shutil.copyfileobj(repo_code_file, out) # self.LOGGER.info(f"/tmp/{local_folder} directory listing: {os.listdir('/tmp/' + local_folder)}") - def download_code_library(self, repo_zip_url): + def download_code_library(self, repo_zip_url: str) -> None: self.LOGGER.info(f"Downloading code library from {repo_zip_url}") http = urllib3.PoolManager() repo_zip_file = http.request("GET", repo_zip_url) @@ -130,7 +124,7 @@ def download_code_library(self, repo_zip_url): self.LOGGER.info("Files extracted to /tmp") self.LOGGER.info(f"tmp directory listing: {os.listdir('/tmp')}") - def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_folder, solutions_dir): + def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_temp_folder: str, solutions_dir: str) -> None: # self.LOGGER.info(f"listing config rules for {solution}") if os.path.exists(staging_upload_folder): shutil.rmtree(staging_upload_folder) @@ -229,7 +223,7 @@ def prepare_config_rules_for_staging(self, staging_upload_folder, staging_temp_f # self.LOGGER.info(f"bedrock_org directory listing: {os.listdir('/tmp/aws-security-reference-architecture-examples-sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda')}") self.LOGGER.info(f"All config rules: {self.CONFIG_RULES}") - def prepare_code_for_staging(self, staging_upload_folder, staging_temp_folder, solutions_dir): + def prepare_code_for_staging(self, staging_upload_folder: str, staging_temp_folder: str, solutions_dir: str) -> None: if os.path.exists(staging_upload_folder): shutil.rmtree(staging_upload_folder) if os.path.exists(staging_temp_folder): diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 18ac24b27..2420aec97 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -29,7 +29,6 @@ from mypy_boto3_sns.type_defs import PublishBatchResponseTypeDef -# TODO(liamschn): kms key for sns topic class sra_sns: # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -51,9 +50,8 @@ class sra_sns: sts = sra_sts.sra_sts() - def find_sns_topic(self, topic_name: str, region: str = "default", account: str = "default") -> str: + def find_sns_topic(self, topic_name: str, region: str = "default", account: str = "default") -> str | None: """Find SNS Topic ARN.""" - # get region from SNS_CLIENT if region == "default": region = self.sts.HOME_REGION if account == "default": From 19db3a704ad23476587a59754d4a37e13a120a77 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 16:34:28 -0700 Subject: [PATCH 280/395] fix mypy errors --- .../genai/bedrock_org/lambda/src/sra_sts.py | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index 4b6449cec..470b1947f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -1,9 +1,11 @@ import logging import os +from typing import Any import boto3 import botocore from botocore.config import Config +import botocore.exceptions class sra_sts: PROFILE = "default" @@ -12,13 +14,15 @@ class sra_sts: # TODO(liamschn): this needs to be made into an SSM parameter CONFIGURATION_ROLE: str = "" BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) + PARTITION: str = "" + HOME_REGION: str = "" # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - def __init__(self, profile="default") -> None: + def __init__(self, profile: str="default") -> None: self.PROFILE = profile print(f"STS PROFILE INFO: {self.PROFILE}") @@ -33,16 +37,14 @@ def __init__(self, profile="default") -> None: self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") self.HOME_REGION = self.MANAGEMENT_ACCOUNT_SESSION.region_name self.LOGGER.info(f"STS detected home region: {self.HOME_REGION}") - # SM_HOST_NAME = urllib.parse.urlparse(boto3.client("secretsmanager", region_name=HOME_REGION).meta.endpoint_url).hostname - self.PARTITION: str = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) - # LOGGER.info(f"Detected management account (current account): {MANAGEMENT_ACCOUNT}") + self.PARTITION = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) except botocore.exceptions.ClientError as error: if error.response["Error"]["Code"] == "ExpiredToken": self.LOGGER.info("Token has expired, please re-run with proper credentials set.") self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") self.HOME_REGION = self.MANAGEMENT_ACCOUNT_SESSION.region_name - self.PARTITION: str = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) + self.PARTITION = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) else: self.LOGGER.info(f"Error: {error}") @@ -59,7 +61,7 @@ def __init__(self, profile="default") -> None: self.LOGGER.info(f"Error: {error}") raise error - def assume_role(self, account, role_name, service, region_name): + def assume_role(self, account: str, role_name: str, service: str, region_name: str) -> Any: """Get boto3 client assumed into an account for a specified service. Args: @@ -79,19 +81,23 @@ def assume_role(self, account, role_name, service, region_name): RoleSessionName="SRA-AssumeCrossAccountRole", DurationSeconds=900, ) - - return self.MANAGEMENT_ACCOUNT_SESSION.client( - service, + assumed_client = self.MANAGEMENT_ACCOUNT_SESSION.client( + service, # type: ignore region_name=region_name, aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], aws_session_token=sts_response["Credentials"]["SessionToken"], ) + return assumed_client else: - return self.MANAGEMENT_ACCOUNT_SESSION.client(service, region_name=region_name, config=self.BOTO3_CONFIG) + assumed_client = self.MANAGEMENT_ACCOUNT_SESSION.client( + service, # type: ignore + region_name=region_name, + config=self.BOTO3_CONFIG) + return assumed_client - def assume_role_resource(self, account, role_name, service, region_name): + def assume_role_resource(self, account: str, role_name: str, service: str, region_name: str) -> Any: """Get boto3 resource assumed into an account for a specified service. Args: @@ -110,20 +116,18 @@ def assume_role_resource(self, account, role_name, service, region_name): RoleSessionName="SRA-AssumeCrossAccountRole", DurationSeconds=900, ) - - return self.MANAGEMENT_ACCOUNT_SESSION.resource( - service, + assumed_resource = self.MANAGEMENT_ACCOUNT_SESSION.resource( + service, # type: ignore region_name=region_name, aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], aws_session_token=sts_response["Credentials"]["SessionToken"], ) + return assumed_resource - def get_lambda_execution_role(self): + def get_lambda_execution_role(self) -> str: try: response = self.STS_CLIENT.get_caller_identity() - # self.LOGGER.info({"get_caller_identity": response}) - # response["UserId"], response["Account"] return response["Arn"] except Exception: self.LOGGER.exception(self.UNEXPECTED) From d9af6005a6cf63b4f7e464d7f77156e9ef46044f Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 16:48:05 -0700 Subject: [PATCH 281/395] fixing mypy errors --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 8cfba87c9..af1454682 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1028,7 +1028,7 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: if DRY_RUN is False: iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) LIVE_RUN_DATA[ - f"OamXacctRolePolicyAttach_{policy_arn.split("/")[1]}_{bedrock_account}" + f"OamXacctRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}" ] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 @@ -1036,7 +1036,7 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}") else: DRY_RUN_DATA[ - f"OAMCrossAccountRolePolicyAttach_{policy_arn.split("/")[1]}_{bedrock_account}" + f"OAMCrossAccountRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}" ] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" # 5e) OAM link in bedrock account From d01f1036f09f00c004698fe3fea169d00835e4e6 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 17:34:18 -0700 Subject: [PATCH 282/395] fixing mypy errors --- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 19e50a51b..aa1d94db4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -109,7 +109,7 @@ def create_lambda_function(self, code_zip_file: str, role_arn: str, function_nam self.LOGGER.info(f"Lambda function {function_name} is now active") break else: - self.LOGGER.info(f"{function_name} lambda function state is {get_response["Configuration"]["State"]}. Waiting to retry...") + self.LOGGER.info(f"{function_name} lambda function state is {get_response['Configuration']['State']}. Waiting to retry...") retries += 1 sleep(5) except ClientError as e: From 2182acacb7173810a9c0a989911a266431cd5e2c Mon Sep 17 00:00:00 2001 From: liamschn Date: Sun, 8 Dec 2024 23:06:27 -0700 Subject: [PATCH 283/395] fix mypy errors in ssm param module --- .../bedrock_org/lambda/src/sra_ssm_params.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py index 27c4dc079..260ebb71d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -14,7 +14,7 @@ import os import re from time import sleep -from typing import TYPE_CHECKING, Literal, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence, Union import boto3 from botocore.config import Config @@ -468,7 +468,7 @@ def create_ssm_parameters_in_regions(self, ssm_parameters: list, tags: Sequence[ self.LOGGER.info(f"Completed the creation of SSM Parameters for '{region}' region.") self.LOGGER.info({"Created Parameters": list(parameters_created)}) - def update_ssm_parameter(self, ssm_client, name, value): + def update_ssm_parameter(self, ssm_client: Any, name: str, value: str) -> None: """Update SSM parameter. Args: @@ -543,7 +543,7 @@ def get_validated_parameters(self, event: CloudFormationCustomResourceEvent) -> return params - def get_ssm_parameter(self, session, region, parameter: str) -> tuple[bool, str]: + def get_ssm_parameter(self, session: Any, region: str, parameter: str) -> tuple[bool, str]: """Get SSM parameter value. Args: @@ -568,16 +568,3 @@ def get_ssm_parameter(self, session, region, parameter: str) -> tuple[bool, str] return False, "" self.LOGGER.info(f"SSM parameter '{parameter}' found.") return True, response["Parameter"]["Value"] - - # def get_parameter_values(self): - # try: - # self.SSM_SECURITY_ACCOUNT_ID = self.get_ssm_parameter( - # self.MANAGEMENT_ACCOUNT_SESSION, self.HOME_REGION, "/sra/control-tower/audit-account-id" - # ) - # self.SSM_LOG_ARCHIVE_ACCOUNT_ID = self.get_ssm_parameter( - # self.MANAGEMENT_ACCOUNT_SESSION, self.HOME_REGION, "/sra/control-tower/log-archive-account-id" - # ) - # return True - # except Exception as e: - # self.LOGGER.info(f"Error getting SSM parameter values: {e}") - # return False From a6f6df4d2f8c15d5c84fc8fd9780de594c41123f Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 07:29:06 -0700 Subject: [PATCH 284/395] update for mypy errors --- .../genai/bedrock_org/lambda/src/sra_ssm_params.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py index 260ebb71d..ceba96cc6 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -14,7 +14,7 @@ import os import re from time import sleep -from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, List, Literal, Optional, Sequence, Union, cast import boto3 from botocore.config import Config @@ -179,9 +179,11 @@ def get_enabled_regions(self) -> list: # noqa: CCR001 Returns: Enabled regions """ - default_available_regions = [] - for region in boto3.client("account").list_regions(RegionOptStatusContains=["ENABLED", "ENABLED_BY_DEFAULT"])["Regions"]: - default_available_regions.append(region["RegionName"]) + default_available_regions: List[str] = [ + region["RegionName"] for region in boto3.client("account").list_regions( + RegionOptStatusContains=["ENABLED", "ENABLED_BY_DEFAULT"] + )["Regions"] + ] self.LOGGER.info({"Default_Available_Regions": default_available_regions}) enabled_regions = [] From e2afe1e95039df731b0f6b24708ee760e748ba90 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 08:15:50 -0700 Subject: [PATCH 285/395] fix mypy errors in app --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index af1454682..aea7cd5c8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -20,7 +20,7 @@ import sra_cloudwatch import sra_kms -from typing import Dict, Any, List, Literal +from typing import Dict, Any, List, Literal, Optional # import sra_lambda @@ -84,9 +84,9 @@ def load_sra_cloudwatch_dashboard() -> dict: LAMBDA_START: str = "" LAMBDA_FINISH: str = "" -ACCOUNT: str | None = boto3.client("sts").get_caller_identity().get("Account") +ACCOUNT: Optional[str] = boto3.client("sts").get_caller_identity().get("Account") LOGGER.info(f"Account: {ACCOUNT}") -REGION: str | None = os.environ.get("AWS_REGION") +REGION: Optional[str] = os.environ.get("AWS_REGION") LOGGER.info(f"Region: {REGION}") CFN_RESOURCE_ID: str = "sra-bedrock-org-function" ALARM_SNS_KEY_ALIAS = "sra-alarm-sns-key" From 9aaaf820a758d2f4b66fa894fec27ff14c123409 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 08:28:03 -0700 Subject: [PATCH 286/395] fixing more mypy issues with app --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index aea7cd5c8..048614b0a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -482,7 +482,7 @@ def deploy_state_table() -> None: DRY_RUN_DATA["StateTableCreate"] = f"DRY_RUN: Create the {STATE_TABLE} state table" -def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str | None, account_id: str | None, region: str, component_name: str, key_id: str = "") -> str: +def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str, account_id: Optional[str], region: str, component_name: str, key_id: str = "") -> str: """Add a record to the state table Args: aws_service (str): aws service @@ -536,7 +536,7 @@ def add_state_table_record(aws_service: str, component_state: str, description: return sra_resource_record_id -def remove_state_table_record(resource_arn: str | None) -> Any: +def remove_state_table_record(resource_arn: str) -> Any: """Remove a record from the state table Args: From 9e1e42a137888245dfb61c08e1161936e2f08a85 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 09:20:33 -0700 Subject: [PATCH 287/395] fixing mypy errors in config rules --- .../sra_bedrock_check_cloudwatch_endpoints/app.py | 7 ++++--- .../sra_bedrock_check_eval_job_bucket/app.py | 15 ++++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py index a7dfa2ff3..9e4f468fb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py @@ -1,3 +1,4 @@ +from typing import Any import boto3 import json import os @@ -16,7 +17,7 @@ ec2_client = boto3.client('ec2', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) -def evaluate_compliance(vpc_id): +def evaluate_compliance(vpc_id: str) -> tuple[str, str]: """Evaluates if a CloudWatch gateway endpoint is in place for the given VPC""" try: response = ec2_client.describe_vpc_endpoints( @@ -38,7 +39,7 @@ def evaluate_compliance(vpc_id): LOGGER.error(f"Error evaluating CloudWatch gateway endpoint for VPC {vpc_id}: {str(e)}") return 'ERROR', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> None: LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -79,7 +80,7 @@ def lambda_handler(event, context): # Submit compliance evaluations if evaluations: config_client.put_evaluations( - Evaluations=evaluations, + Evaluations=evaluations, # type: ignore ResultToken=event['resultToken'] ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index 40be3167d..f65bcd260 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -1,3 +1,4 @@ +from typing import Any import boto3 import json from botocore.exceptions import ClientError @@ -21,7 +22,7 @@ SERVICE_NAME = "bedrock.amazonaws.com" -def evaluate_compliance(event, context): +def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: LOGGER.info(f"Evaluate Compliance Event: {event}") # Initialize AWS clients s3 = boto3.client('s3') @@ -97,7 +98,7 @@ def evaluate_compliance(event, context): annotation_str = '; '.join(annotation) if annotation else "All checked features are compliant" return build_evaluation(compliance_type, annotation_str) -def check_bucket_exists(bucket_name): +def check_bucket_exists(bucket_name: str) -> Any: s3 = boto3.client('s3') try: response = s3.list_buckets() @@ -107,7 +108,7 @@ def check_bucket_exists(bucket_name): print(f"An error occurred: {e}") return False -def build_evaluation(compliance_type, annotation): +def build_evaluation(compliance_type: str, annotation: str) -> Any: LOGGER.info(f"Build Evaluation Compliance Type: {compliance_type} Annotation: {annotation}") return { 'ComplianceType': compliance_type, @@ -115,7 +116,7 @@ def build_evaluation(compliance_type, annotation): 'OrderingTimestamp': datetime.now().isoformat() } -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> None: LOGGER.info(f"Lambda Handler Event: {event}") evaluation = evaluate_compliance(event, context) config = boto3.client('config') @@ -125,9 +126,9 @@ def lambda_handler(event, context): { 'ComplianceResourceType': 'AWS::S3::Bucket', 'ComplianceResourceId': params.get('BucketName'), - 'ComplianceType': evaluation['ComplianceType'], - 'Annotation': evaluation['Annotation'], - 'OrderingTimestamp': evaluation['OrderingTimestamp'] + 'ComplianceType': evaluation['ComplianceType'], # type: ignore + 'Annotation': evaluation['Annotation'], # type: ignore + 'OrderingTimestamp': evaluation['OrderingTimestamp'] # type: ignore } ], ResultToken=event['resultToken'] From 532eae0eac44f629848244eed7c6c7314507aa08 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 09:44:18 -0700 Subject: [PATCH 288/395] fixing mypy errors in config rules --- .../sra_bedrock_check_guardrail_encryption/app.py | 9 +++++---- .../lambda/rules/sra_bedrock_check_guardrails/app.py | 11 ++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index 61bd5a758..80bb6ba18 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -1,3 +1,4 @@ +from typing import Any import boto3 import json import os @@ -16,7 +17,7 @@ bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) -def evaluate_compliance(rule_parameters): +def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: """Evaluates if Bedrock guardrails are encrypted with a KMS key""" try: @@ -26,7 +27,7 @@ def evaluate_compliance(rule_parameters): if not guardrails: return 'NON_COMPLIANT', "No Bedrock guardrails found" - unencrypted_guardrails = [] + unencrypted_guardrails: list[str] = [] for guardrail in guardrails: guardrail_id = guardrail['id'] guardrail_name = guardrail['name'] @@ -44,7 +45,7 @@ def evaluate_compliance(rule_parameters): LOGGER.error(f"Error evaluating Bedrock guardrails encryption: {str(e)}") return 'ERROR', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> None: LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -65,7 +66,7 @@ def lambda_handler(event, context): LOGGER.info(f"Annotation: {annotation}") config_client.put_evaluations( - Evaluations=[evaluation], + Evaluations=[evaluation], # type: ignore ResultToken=event['resultToken'] ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 6bc6b0562..95246e05b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -1,3 +1,4 @@ +from typing import Any import boto3 import json from datetime import datetime @@ -19,10 +20,10 @@ 'contextual_grounding': True } -def evaluate_compliance(configuration_item, rule_parameters): - return 'NOT_APPLICABLE' +# def evaluate_compliance(configuration_item: str, rule_parameters: dict) -> str: +# return 'NOT_APPLICABLE' -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> dict: LOGGER.info("Starting lambda_handler function") bedrock = boto3.client('bedrock') @@ -88,7 +89,7 @@ def lambda_handler(event, context): else: compliance_type = 'NON_COMPLIANT' annotation = 'No Bedrock guardrails contain all required features. Missing features per guardrail:\n' - for guardrail, missing in non_compliant_guardrails.items(): + for guardrail, missing in non_compliant_guardrails.items(): # type: ignore annotation += f"- {guardrail}: missing {', '.join(missing)}\n" LOGGER.info(f"Account is NON_COMPLIANT. {annotation}") @@ -105,7 +106,7 @@ def lambda_handler(event, context): try: response = config.put_evaluations( - Evaluations=[evaluation], + Evaluations=[evaluation], # type: ignore ResultToken=event['resultToken'] ) LOGGER.info(f"Evaluation sent successfully: {response}") From c15eb3157430281e6a41e9582a9c06d0f4b31aac Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 09:54:15 -0700 Subject: [PATCH 289/395] fixing mypy issues in config rules --- .../sra_bedrock_check_iam_user_access/app.py | 17 ++++++++--------- .../app.py | 7 ++++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py index 85f04afd3..8953322f4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -1,3 +1,4 @@ +from typing import Any import botocore import boto3 import json @@ -24,7 +25,7 @@ iam_client = session.client("iam") -def evaluate_compliance(event, context): +def evaluate_compliance(event: dict, context: Any) -> dict: """ Evaluates compliance for the given AWS Config event. """ @@ -46,7 +47,7 @@ def evaluate_compliance(event, context): for policy in user_policies: LOGGER.info(f"policy: {policy}") policy_document = iam_client.get_user_policy(UserName=user_name, PolicyName=policy)["PolicyDocument"] - if check_policy_document(policy_document): + if check_policy_document(policy_document): # type: ignore LOGGER.info("User policy has access") has_access = True break @@ -55,7 +56,7 @@ def evaluate_compliance(event, context): group_policies = iam_client.list_group_policies(GroupName=group["GroupName"])["PolicyNames"] for policy in group_policies: policy_document = iam_client.get_group_policy(GroupName=group["GroupName"], PolicyName=policy)["PolicyDocument"] - if check_policy_document(policy_document): + if check_policy_document(policy_document): # type: ignore LOGGER.info("Group policy has access") has_access = True break @@ -64,7 +65,7 @@ def evaluate_compliance(event, context): LOGGER.info(f"managed policy: {managed_policy}") managed_policy_version = iam_client.get_policy(PolicyArn=managed_policy["PolicyArn"])["Policy"]["DefaultVersionId"] managed_policy_document = iam_client.get_policy_version(PolicyArn=managed_policy["PolicyArn"], VersionId=managed_policy_version)["PolicyVersion"]["Document"] - if check_policy_document(managed_policy_document): + if check_policy_document(managed_policy_document): # type: ignore LOGGER.info("Managed policy has access") has_access = True break @@ -90,7 +91,7 @@ def evaluate_compliance(event, context): return evaluation_result -def check_policy_document(policy_document): +def check_policy_document(policy_document: dict) -> bool: """ Checks if the given policy document allows access to the Bedrock service. """ @@ -100,8 +101,6 @@ def check_policy_document(policy_document): if statement["Effect"] == "Allow": resources = statement.get("Resource", []) LOGGER.info(f"resources: {resources}") - # if "*" in resources or SERVICE_NAME in resources: - # return True actions = statement.get("Action", []) LOGGER.info(f"actions: {actions}") if any(action.startswith("bedrock:") for action in actions): @@ -110,7 +109,7 @@ def check_policy_document(policy_document): return False -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> dict: """ AWS Lambda function entry point. """ @@ -134,7 +133,7 @@ def lambda_handler(event, context): "OrderingTimestamp": invoking_event["notificationCreationTime"], } ], - ResultToken=result_token, + ResultToken=result_token, # type: ignore ) # Return the evaluation result diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index cb487bb3e..e881a81aa 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -1,3 +1,4 @@ +from typing import Any import boto3 import json import os @@ -17,7 +18,7 @@ config_client = boto3.client('config', region_name=AWS_REGION) logs_client = boto3.client('logs', region_name=AWS_REGION) -def evaluate_compliance(rule_parameters): +def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: """Evaluates if Bedrock Model Invocation Logging is properly configured for CloudWatch""" # Parse rule parameters @@ -60,7 +61,7 @@ def evaluate_compliance(rule_parameters): LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") return 'INSUFFICIENT_DATA', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> None: LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -81,7 +82,7 @@ def lambda_handler(event, context): LOGGER.info(f"Annotation: {annotation}") config_client.put_evaluations( - Evaluations=[evaluation], + Evaluations=[evaluation], # type: ignore ResultToken=event['resultToken'] ) From 621552d95ed4048f0814c3d2cb25163d3544c006 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 11:15:49 -0700 Subject: [PATCH 290/395] fixing mypy errors for config rules --- .../rules/sra_bedrock_check_invocation_log_s3/app.py | 10 +++++----- .../lambda/rules/sra_bedrock_check_s3_endpoints/app.py | 7 ++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index 3de1573fe..bd0c0cd55 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -1,8 +1,10 @@ +from typing import Any import boto3 import json import os import logging import botocore +import botocore.exceptions # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -18,7 +20,7 @@ config_client = boto3.client('config', region_name=AWS_REGION) s3_client = boto3.client('s3', region_name=AWS_REGION) -def evaluate_compliance(rule_parameters): +def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: """Evaluates if Bedrock Model Invocation Logging is properly configured for S3""" # Parse rule parameters @@ -86,7 +88,7 @@ def evaluate_compliance(rule_parameters): LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") return 'INSUFFICIENT_DATA', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> None: LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -107,10 +109,8 @@ def lambda_handler(event, context): LOGGER.info(f"Annotation: {annotation}") config_client.put_evaluations( - Evaluations=[evaluation], + Evaluations=[evaluation], # type: ignore ResultToken=event['resultToken'] ) -# ^^^ [ERROR] ValidationException: An error occurred (ValidationException) when calling the PutEvaluations operation: -# 1 validation error detected: Value 'ERROR' at 'evaluations.1.member.complianceType' failed to satisfy constraint: Member must satisfy enum value set: [INSUFFICIENT_DATA, NON_COMPLIANT, NOT_APPLICABLE, COMPLIANT] LOGGER.info("Compliance evaluation complete.") \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py index 87abf7920..17e1326be 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py @@ -1,4 +1,5 @@ +from typing import Any import boto3 import json import os @@ -17,7 +18,7 @@ ec2_client = boto3.client('ec2', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) -def evaluate_compliance(configuration_item): +def evaluate_compliance(configuration_item: dict) -> tuple[str, str]: """Evaluates if an S3 Gateway Endpoint is in place for the VPC""" if configuration_item['resourceType'] != 'AWS::EC2::VPC': @@ -44,7 +45,7 @@ def evaluate_compliance(configuration_item): LOGGER.error(f"Error evaluating S3 Gateway Endpoint configuration: {str(e)}") return 'ERROR', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> None: LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -83,7 +84,7 @@ def lambda_handler(event, context): # Submit compliance evaluations if evaluations: config_client.put_evaluations( - Evaluations=evaluations, + Evaluations=evaluations, # type: ignore ResultToken=event['resultToken'] ) From c62cea498d21cff4638c3cd3777f8b0916388702 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 11:18:05 -0700 Subject: [PATCH 291/395] fixing mypy errors for config rules --- .../lambda/rules/sra_bedrock_check_vpc_endpoints/app.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py index 97c46214b..8fd187832 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -1,4 +1,5 @@ +from typing import Any import boto3 import json import os @@ -17,7 +18,7 @@ ec2_client = boto3.client('ec2', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) -def evaluate_compliance(vpc_id, rule_parameters): +def evaluate_compliance(vpc_id: str, rule_parameters: dict) -> tuple[str, str]: """Evaluates if the required VPC endpoints are in place""" # Parse rule parameters @@ -54,7 +55,7 @@ def evaluate_compliance(vpc_id, rule_parameters): LOGGER.info(f"All required endpoints are in place for VPC {vpc_id}") return 'COMPLIANT', f"VPC {vpc_id} has all required Bedrock endpoints: {', '.join(required_endpoints)}" -def lambda_handler(event, context): +def lambda_handler(event: dict, context: Any) -> None: LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -95,7 +96,7 @@ def lambda_handler(event, context): # Submit compliance evaluations if evaluations: config_client.put_evaluations( - Evaluations=evaluations, + Evaluations=evaluations, # type: ignore ResultToken=event['resultToken'] ) From 1b478b676f7eee12bc851ee36014bbf5b8f820fc Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 16:24:08 -0700 Subject: [PATCH 292/395] fixing mypy issues with config rules --- .../app.py | 2 +- .../sra_bedrock_check_iam_user_access/app.py | 2 +- .../app.py | 2 +- .../app.py | 2 +- .../sra_bedrock_check_s3_endpoints/app.py | 2 +- .../sra_bedrock_check_vpc_endpoints/app.py | 2 +- .../bedrock_org/lambda/src/sra_dynamodb.py | 104 ++++++++++++------ .../bedrock_org/lambda/src/sra_lambda.py | 2 +- 8 files changed, 79 insertions(+), 39 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py index 9e4f468fb..6e1603914 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py @@ -80,7 +80,7 @@ def lambda_handler(event: dict, context: Any) -> None: # Submit compliance evaluations if evaluations: config_client.put_evaluations( - Evaluations=evaluations, # type: ignore + Evaluations=evaluations, ResultToken=event['resultToken'] ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py index 8953322f4..452f7a0e4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -133,7 +133,7 @@ def lambda_handler(event: dict, context: Any) -> dict: "OrderingTimestamp": invoking_event["notificationCreationTime"], } ], - ResultToken=result_token, # type: ignore + ResultToken=result_token, ) # Return the evaluation result diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index e881a81aa..80e973a06 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -82,7 +82,7 @@ def lambda_handler(event: dict, context: Any) -> None: LOGGER.info(f"Annotation: {annotation}") config_client.put_evaluations( - Evaluations=[evaluation], # type: ignore + Evaluations=[evaluation], ResultToken=event['resultToken'] ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index bd0c0cd55..bfbb1f780 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -109,7 +109,7 @@ def lambda_handler(event: dict, context: Any) -> None: LOGGER.info(f"Annotation: {annotation}") config_client.put_evaluations( - Evaluations=[evaluation], # type: ignore + Evaluations=[evaluation], ResultToken=event['resultToken'] ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py index 17e1326be..d3a159355 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py @@ -84,7 +84,7 @@ def lambda_handler(event: dict, context: Any) -> None: # Submit compliance evaluations if evaluations: config_client.put_evaluations( - Evaluations=evaluations, # type: ignore + Evaluations=evaluations, ResultToken=event['resultToken'] ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py index 8fd187832..9e863cffd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -96,7 +96,7 @@ def lambda_handler(event: dict, context: Any) -> None: # Submit compliance evaluations if evaluations: config_client.put_evaluations( - Evaluations=evaluations, # type: ignore + Evaluations=evaluations, ResultToken=event['resultToken'] ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 258e4ba4e..5721222b8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -1,14 +1,15 @@ import logging import boto3 -from boto3.dynamodb.conditions import Key, Attr +from boto3.dynamodb.conditions import Key, Attr, ConditionBase import os import random import string from datetime import datetime from time import sleep import botocore +from botocore.exceptions import ClientError from boto3.session import Session -from typing import TYPE_CHECKING, Any, Sequence, cast +from typing import TYPE_CHECKING, Any, Sequence, cast, Dict, Tuple, List if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource @@ -125,7 +126,6 @@ def update_item(self, table_name: str, solution_name: str, record_id: str, attri else: update_expression = update_expression + ", " + attribute + "=:" + attribute expression_attribute_values[":" + attribute] = attributes_and_values[attribute] - # self.LOGGER.info(f"update expression: {update_expression}") response = table.update_item( Key={ "solution_name": solution_name, @@ -137,44 +137,59 @@ def update_item(self, table_name: str, solution_name: str, record_id: str, attri ) return response - def find_item(self, table_name: str, solution_name: str, additional_attributes: dict) -> tuple[bool, dict]: - """Find an item in the dynamodb table based on the solution name and additional attributes. + def find_item( + self, table_name: str, solution_name: str, additional_attributes: Dict[str, Any] + ) -> Tuple[bool, Dict[str, Any]]: + """Find an item in the DynamoDB table based on the solution name and additional attributes. Args: - table_name: dynamodb table name - dynamodb_resource: dynamodb resource - solution_name: solution name - additional_attributes: additional attributes to search for + table_name: DynamoDB table name. + solution_name: Solution name to search for. + additional_attributes: Additional attributes to search for. Returns: - True and the item if found, otherwise False and empty dict + True and the item if found, otherwise False and empty dict. """ - self.LOGGER.info(f"Searching for {additional_attributes} in {table_name} dynamodb table") - table = self.DYNAMODB_RESOURCE.Table(table_name) - expression_attribute_values = {":solution_name": solution_name} + self.LOGGER.info(f"Searching for {additional_attributes} in {table_name} DynamoDB table") - filter_expression = " AND ".join([f"{attr} = :{attr}" for attr in additional_attributes.keys()]) + # Get the DynamoDB table + table = self.DYNAMODB_RESOURCE.Table(table_name) - expression_attribute_values.update({f":{attr}": value for attr, value in additional_attributes.items()}) + # Prepare query parameters + expression_attribute_values: Dict[str, Any] = {":solution_name": solution_name} - query_params = {} + # Build the filter expression + filter_conditions: ConditionBase = Attr(list(additional_attributes.keys())[0]).eq( + additional_attributes[list(additional_attributes.keys())[0]] + ) + for attr, value in list(additional_attributes.items())[1:]: + filter_conditions &= Attr(attr).eq(value) - query_params = { - "KeyConditionExpression": "solution_name = :solution_name", + query_params: Dict[str, Any] = { + "KeyConditionExpression": Key("solution_name").eq(solution_name), "ExpressionAttributeValues": expression_attribute_values, - "FilterExpression": filter_expression, + "FilterExpression": filter_conditions, } - response = table.query(**query_params) # type: ignore + try: + response = table.query(**query_params) + except ClientError as e: + self.LOGGER.error(f"Error querying DynamoDB table {table_name}: {e}") + return False, {} - if len(response["Items"]) > 1: + # Handle the response + items = response.get("Items", []) + if len(items) > 1: self.LOGGER.info( - f"Found more than one record that matched solution name {solution_name}: {additional_attributes} Review {table_name} dynamodb table to determine cause." + f"Found more than one record that matched solution name {solution_name}: {additional_attributes}. " + f"Review {table_name} DynamoDB table to determine the cause." ) - elif len(response["Items"]) < 1: + elif not items: return False, {} - self.LOGGER.info(f"Found record id {response['Items'][0]}") - return True, response["Items"][0] + + self.LOGGER.info(f"Found record id {items[0]}") + return True, items[0] + def get_unique_values_from_list(self, list_of_values: list) -> list: unique_values = [] @@ -192,20 +207,45 @@ def get_distinct_solutions_and_accounts(self, table_name: str) -> tuple[list, li accounts = self.get_unique_values_from_list(accounts) return solution_names, accounts - def get_resources_for_solutions_by_account(self, table_name: str, solutions: list, account: str) -> dict: + def get_resources_for_solutions_by_account( + self, table_name: str, solutions: List[str], account: str + ) -> Dict[str, Any]: + """ + Retrieve resources for the specified solutions and account from a DynamoDB table. + + Args: + table_name: Name of the DynamoDB table. + solutions: List of solutions to query. + account: Account to filter by. + + Returns: + Dictionary of solutions and their corresponding query results. + """ table = self.DYNAMODB_RESOURCE.Table(table_name) - query_results = {} + query_results: Dict[str, Any] = {} + for solution in solutions: - query_params = { - "KeyConditionExpression": "solution_name = :solution_name", - "ExpressionAttributeValues": {":solution_name": solution, ":account": account}, - "FilterExpression": "account = :account", + # Build the query parameters + key_condition: ConditionBase = Key("solution_name").eq(solution) + filter_condition: ConditionBase = Attr("account").eq(account) + + query_params: Dict[str, Any] = { + "KeyConditionExpression": key_condition, + "ExpressionAttributeValues": { + ":solution_name": solution, + ":account": account, + }, + "FilterExpression": filter_condition, } - response = table.query(**query_params) # type: ignore + + # Perform the query + response = table.query(**query_params) self.LOGGER.info(f"response: {response}") query_results[solution] = response + return query_results + def delete_item(self, table_name: str, solution_name: str, record_id: str) -> Any: """Delete an item from the dynamodb table diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index aa1d94db4..e6a9fbdcd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -70,7 +70,7 @@ def create_lambda_function(self, code_zip_file: str, role_arn: str, function_nam try: create_response = self.LAMBDA_CLIENT.create_function( FunctionName=function_name, - Runtime=runtime, # type: ignore + Runtime=runtime, Handler=handler, Role=role_arn, Code={"ZipFile": open(code_zip_file, "rb").read()}, From 9450746f3328831f1cf59258bf69e6cde1a027ba Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 16:27:39 -0700 Subject: [PATCH 293/395] fixing mypy errors in config rules --- .../lambda/rules/sra_bedrock_check_guardrail_encryption/app.py | 2 +- .../lambda/rules/sra_bedrock_check_guardrails/app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index 80bb6ba18..bad0d0c79 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -66,7 +66,7 @@ def lambda_handler(event: dict, context: Any) -> None: LOGGER.info(f"Annotation: {annotation}") config_client.put_evaluations( - Evaluations=[evaluation], # type: ignore + Evaluations=[evaluation], ResultToken=event['resultToken'] ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 95246e05b..12bac5d7c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -106,7 +106,7 @@ def lambda_handler(event: dict, context: Any) -> dict: try: response = config.put_evaluations( - Evaluations=[evaluation], # type: ignore + Evaluations=[evaluation], ResultToken=event['resultToken'] ) LOGGER.info(f"Evaluation sent successfully: {response}") From b37a393c73d3d92143056ede4276aef5284cc502 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 16:38:28 -0700 Subject: [PATCH 294/395] fixing mypy errors in config --- .../solutions/config/config_org/lambda/src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/config/config_org/lambda/src/config.py b/aws_sra_examples/solutions/config/config_org/lambda/src/config.py index 666882a16..a5a1a2c50 100644 --- a/aws_sra_examples/solutions/config/config_org/lambda/src/config.py +++ b/aws_sra_examples/solutions/config/config_org/lambda/src/config.py @@ -92,7 +92,7 @@ def set_config_in_org( configuration_recorder: ConfigurationRecorderTypeDef = { "name": recorder_name, "roleARN": role_arn, - "recordingGroup": { + "recordingGroup": { # type: ignore "allSupported": all_supported, "includeGlobalResourceTypes": include_global_resource_types, "resourceTypes": resource_types, From 294245e5e56be0cbc4f0ee181644cf1c9785627c Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 9 Dec 2024 16:43:46 -0700 Subject: [PATCH 295/395] fix mypy errors in ami bakery --- .../ami_bakery/ami_bakery_org/lambda/src/codepipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py b/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py index c4ca779bc..01f68b50a 100644 --- a/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py +++ b/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py @@ -90,7 +90,7 @@ def create_codepipeline( "roleArn": "arn:" + aws_partition + ":iam::" + account_id + ":role/" + codepipeline_role_name, "artifactStore": {"type": "S3", "location": bucket_name}, "stages": [ - { + { # type: ignore "name": pipeline_name + "-CodeCommitSource", "actions": [ { @@ -104,7 +104,7 @@ def create_codepipeline( } ], }, - { + { # type: ignore "name": pipeline_name + "-DeployEC2ImageBuilder", "actions": [ { From eecedd0c8257c9eaa0588876023fb0f01dd1ee3d Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 10 Dec 2024 09:43:39 -0700 Subject: [PATCH 296/395] updated formatting --- .../genai/bedrock_org/lambda/src/app.py | 343 +++++++++++++----- 1 file changed, 248 insertions(+), 95 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 048614b0a..bf7a3910f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -67,10 +67,12 @@ def load_sra_cloudwatch_oam_trust_policy() -> dict: with open("sra_cloudwatch_oam_trust_policy.json", "r") as file: return json.load(file) + def load_sra_cloudwatch_dashboard() -> dict: with open("sra_cloudwatch_dashboard.json", "r") as file: return json.load(file) + # Global vars RESOURCE_TYPE: str = "" SOLUTION_NAME: str = "sra-bedrock-org" @@ -78,7 +80,7 @@ def load_sra_cloudwatch_dashboard() -> dict: ORGANIZATION_ID = "" SRA_ALARM_EMAIL: str = "" SRA_ALARM_TOPIC_ARN: str = "" -STATE_TABLE: str = "sra_state" # for saving resource info +STATE_TABLE: str = "sra_state" # for saving resource info LAMBDA_RECORD_ID: str = "" LAMBDA_START: str = "" @@ -114,15 +116,15 @@ def load_sra_cloudwatch_dashboard() -> dict: # Parameter validation rules PARAMETER_VALIDATION_RULES: dict = { - "SRA_REPO_ZIP_URL": r'^https://.*\.zip$', - "DRY_RUN": r'^true|false$', - "EXECUTION_ROLE_NAME": r'^sra-execution$', - "LOG_GROUP_DEPLOY": r'^true|false$', - "LOG_GROUP_RETENTION": r'^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$', - "LOG_LEVEL": r'^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$', - "SOLUTION_NAME": r'^sra-bedrock-org$', - "SOLUTION_VERSION": r'^[0-9]+\.[0-9]+\.[0-9]+$', - "SRA_ALARM_EMAIL": r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + "SRA_REPO_ZIP_URL": r"^https://.*\.zip$", + "DRY_RUN": r"^true|false$", + "EXECUTION_ROLE_NAME": r"^sra-execution$", + "LOG_GROUP_DEPLOY": r"^true|false$", + "LOG_GROUP_RETENTION": r"^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$", + "LOG_LEVEL": r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", + "SOLUTION_NAME": r"^sra-bedrock-org$", + "SOLUTION_VERSION": r"^[0-9]+\.[0-9]+\.[0-9]+$", + "SRA_ALARM_EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', @@ -158,6 +160,7 @@ def load_sra_cloudwatch_dashboard() -> dict: # propagate solution name to class objects cloudwatch.SOLUTION_NAME = SOLUTION_NAME + def get_resource_parameters(event: dict) -> None: global DRY_RUN global GOVERNED_REGIONS @@ -237,7 +240,7 @@ def validate_parameters(parameters: Dict[str, str], rules: Dict[str, str]) -> Di Dict[str, object]: Dictionary with 'success' key (bool) and 'errors' key (list of error messages) """ errors: List[str] = [] - + for param, regex in rules.items(): value = parameters.get(param) if value is None: @@ -245,10 +248,7 @@ def validate_parameters(parameters: Dict[str, str], rules: Dict[str, str]) -> Di elif not re.match(regex, value): errors.append(f"Parameter '{param}' with value '{value}' does not match the expected pattern '{regex}'.") - return { - "success": len(errors) == 0, - "errors": errors - } + return {"success": len(errors) == 0, "errors": errors} def get_accounts_and_regions(resource_properties: dict) -> tuple[list, list]: @@ -280,6 +280,7 @@ def get_accounts_and_regions(resource_properties: dict) -> tuple[list, list]: regions = [] return accounts, regions + def get_rule_params(rule_name: str, resource_properties: dict) -> tuple[bool, list, list, dict]: """Get rule parameters from event and return them in a tuple @@ -410,6 +411,7 @@ def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: s3_filter = s3_filter.replace('&& ($.requestParameters.bucketName = "")', "") return s3_filter + def build_cloudwatch_dashboard(dashboard_template: dict, solution: str, bedrock_accounts: list, regions: list) -> dict: i = 0 for bedrock_account in bedrock_accounts: @@ -482,7 +484,17 @@ def deploy_state_table() -> None: DRY_RUN_DATA["StateTableCreate"] = f"DRY_RUN: Create the {STATE_TABLE} state table" -def add_state_table_record(aws_service: str, component_state: str, description: str, component_type: str, resource_arn: str, account_id: Optional[str], region: str, component_name: str, key_id: str = "") -> str: +def add_state_table_record( + aws_service: str, + component_state: str, + description: str, + component_type: str, + resource_arn: str, + account_id: Optional[str], + region: str, + component_name: str, + key_id: str = "", +) -> str: """Add a record to the state table Args: aws_service (str): aws service @@ -568,6 +580,7 @@ def remove_state_table_record(resource_arn: str) -> Any: response = {} return response + def update_state_table_record(record_id: str, update_data: dict) -> None: dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) @@ -609,7 +622,7 @@ def deploy_sns_configuration_topics(context: Any) -> str: global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA - + sns.SNS_CLIENT = sts.assume_role(sts.MANAGEMENT_ACCOUNT, sts.CONFIGURATION_ROLE, "sns", sts.HOME_REGION) topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-configuration") if topic_search is None: @@ -617,7 +630,7 @@ def deploy_sns_configuration_topics(context: Any) -> str: topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-configuration", SOLUTION_NAME) LIVE_RUN_DATA["SNSCreate"] = f"Created {SOLUTION_NAME}-configuration SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 + CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Creating SNS topic policy permissions for {topic_arn} on {context.function_name} lambda function") statement_name = "sra-sns-invoke" @@ -637,22 +650,27 @@ def deploy_sns_configuration_topics(context: Any) -> str: CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 if DRY_RUN is False: # SNS State table record: - add_state_table_record("sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration") + add_state_table_record( + "sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration" + ) else: DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: Created {SOLUTION_NAME}-configuration SNS topic" DRY_RUN_DATA["SNSPermissions"] = "DRY_RUN: Added lambda sns-invoke permissions for SNS topic" - DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" + DRY_RUN_DATA["SNSSubscription"] = f"DRY_RUN: Subscribed {context.invoked_function_arn} lambda to {SOLUTION_NAME}-configuration SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-configuration SNS topic already exists.") topic_arn = topic_search if DRY_RUN is False: # SNS State table record: - add_state_table_record("sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration") + add_state_table_record( + "sns", "implemented", "configuration topic", "topic", topic_arn, ACCOUNT, sts.HOME_REGION, f"{SOLUTION_NAME}-configuration" + ) else: DRY_RUN_DATA["SNSCreate"] = f"DRY_RUN: {SOLUTION_NAME}-configuration SNS topic already exists" return topic_arn + def deploy_config_rules(region: str, accounts: list, resource_properties: dict) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA @@ -695,8 +713,8 @@ def deploy_config_rules(region: str, accounts: list, resource_properties: dict) if DRY_RUN is False: # download rule zip file s3_key = f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" - local_base_path = '/tmp/sra_staging_upload' - local_file_path = os.path.join(local_base_path, f'{SOLUTION_NAME}', 'rules', rule_name, f'{rule_name}.zip') + local_base_path = "/tmp/sra_staging_upload" + local_file_path = os.path.join(local_base_path, f"{SOLUTION_NAME}", "rules", rule_name, f"{rule_name}.zip") s3.download_s3_file(local_file_path, s3_key, s3.STAGING_BUCKET) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_LambdaCode"] = "Downloaded custom config rule lambda code" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 @@ -720,6 +738,7 @@ def deploy_config_rules(region: str, accounts: list, resource_properties: dict) LOGGER.info(f"DRY_RUN: Deploying custom config rule in {acct} in {region}") DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" + def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_properties: dict) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA @@ -744,7 +763,9 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope if acct not in filter_accounts: LOGGER.info(f"Check found that {filter_name} filter not requested for {acct}. Skipping account...") else: - LOGGER.info(f"Check found that {filter_name} filter was defined for {acct} in {region}; Checking for need to be removed...") + LOGGER.info( + f"Check found that {filter_name} filter was defined for {acct} in {region}; Checking for need to be removed..." + ) delete_metric_filter_and_alarm(filter_name, acct, region, filter_params) continue if filter_regions: @@ -772,7 +793,9 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info(f"{filter_name} filter not requested for {acct}. Skipping...") continue kms.KMS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "kms", region) - search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists(kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}") + search_alarm_kms_key, alarm_key_alias, alarm_key_id, alarm_key_arn = kms.check_alias_exists( + kms.KMS_CLIENT, f"alias/{ALARM_SNS_KEY_ALIAS}" + ) if search_alarm_kms_key is False: LOGGER.info(f"alias/{ALARM_SNS_KEY_ALIAS} not found.") if DRY_RUN is False: @@ -800,7 +823,17 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 # Add KMS resource records to sra state table - add_state_table_record("kms", "implemented", "alarms sns kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) + add_state_table_record( + "kms", + "implemented", + "alarms sns kms key", + "key", + f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", + acct, + region, + alarm_key_id, + alarm_key_id, + ) # 4aii KMS alias for SNS topic used by CloudWatch alarms LOGGER.info("Creating SRA alarm KMS key alias") @@ -809,7 +842,17 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 # Add KMS resource records to sra state table - add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) + add_state_table_record( + "kms", + "implemented", + "alarms sns kms alias", + "alias", + f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", + acct, + region, + ALARM_SNS_KEY_ALIAS, + alarm_key_id, + ) else: LOGGER.info("DRY_RUN: Creating SRA alarm KMS key") @@ -819,9 +862,28 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope else: LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") # Add KMS resource records to sra state table - add_state_table_record("kms", "implemented", "alarms sns kms key", "key", f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", acct, region, alarm_key_id, alarm_key_id) - add_state_table_record("kms", "implemented", "alarms sns kms alias", "alias", f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", acct, region, ALARM_SNS_KEY_ALIAS, alarm_key_id) - + add_state_table_record( + "kms", + "implemented", + "alarms sns kms key", + "key", + f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", + acct, + region, + alarm_key_id, + alarm_key_id, + ) + add_state_table_record( + "kms", + "implemented", + "alarms sns kms alias", + "alias", + f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", + acct, + region, + ALARM_SNS_KEY_ALIAS, + alarm_key_id, + ) # 4b) SNS topics for alarms sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) @@ -847,7 +909,9 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 # add SNS state table record - add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms") + add_state_table_record( + "sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms" + ) else: LOGGER.info(f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic") @@ -856,9 +920,9 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info( f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" ) - DRY_RUN_DATA[ - "SNSAlarmPermissions" - ] = f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + DRY_RUN_DATA["SNSAlarmPermissions"] = ( + f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + ) LOGGER.info(f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic") DRY_RUN_DATA["SNSAlarmSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" @@ -866,7 +930,9 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") SRA_ALARM_TOPIC_ARN = topic_search # add SNS state table record - add_state_table_record("sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms") + add_state_table_record( + "sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms" + ) # 4c) Cloudwatch metric filters and alarms # metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter_name}" @@ -875,7 +941,9 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter_name} CloudWatch metric filter...") - deploy_metric_filter(region, acct, filter_params["log_group_name"], filter_name, filter_pattern, f"{filter_name}-metric", "sra-bedrock", "1") + deploy_metric_filter( + region, acct, filter_params["log_group_name"], filter_name, filter_pattern, f"{filter_name}-metric", "sra-bedrock", "1" + ) LIVE_RUN_DATA[f"{filter_name}_CloudWatch"] = "Deployed CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 @@ -910,7 +978,10 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope DRY_RUN_DATA[f"{filter_name}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" else: LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter_name} CloudWatch metric filter deployment") - DRY_RUN_DATA[f"{filter_name}_CloudWatch"] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" + DRY_RUN_DATA[f"{filter_name}_CloudWatch"] = ( + "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" + ) + def deploy_central_cloudwatch_observability(event: dict) -> None: global DRY_RUN_DATA @@ -1001,19 +1072,43 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: if DRY_RUN is False: xacct_role = iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) xacct_role_arn = xacct_role["Role"]["Arn"] - LIVE_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" + LIVE_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = ( + f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" + ) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") # add cross account role state table record - add_state_table_record("iam", "implemented", "cross account sharing role", "role", xacct_role_arn, bedrock_account, iam.get_iam_global_region(), cloudwatch.CROSS_ACCOUNT_ROLE_NAME) + add_state_table_record( + "iam", + "implemented", + "cross account sharing role", + "role", + xacct_role_arn, + bedrock_account, + iam.get_iam_global_region(), + cloudwatch.CROSS_ACCOUNT_ROLE_NAME, + ) else: - DRY_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" + DRY_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = ( + f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" + ) else: - LOGGER.info(f"CloudWatch observability access manager {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} cross-account role found in {bedrock_account}") + LOGGER.info( + f"CloudWatch observability access manager {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} cross-account role found in {bedrock_account}" + ) xacct_role_arn = search_iam_role[1] # add cross account role state table record - add_state_table_record("iam", "implemented", "cross account sharing role", "role", xacct_role_arn, bedrock_account, iam.get_iam_global_region(), cloudwatch.CROSS_ACCOUNT_ROLE_NAME) + add_state_table_record( + "iam", + "implemented", + "cross account sharing role", + "role", + xacct_role_arn, + bedrock_account, + iam.get_iam_global_region(), + cloudwatch.CROSS_ACCOUNT_ROLE_NAME, + ) # 5d) Attach managed policies to CloudWatch-CrossAccountSharingRole IAM role cross_account_policies = [ @@ -1027,17 +1122,17 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: LOGGER.info(f"Attaching {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}...") if DRY_RUN is False: iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) - LIVE_RUN_DATA[ - f"OamXacctRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}" - ] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + LIVE_RUN_DATA[f"OamXacctRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}"] = ( + f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + ) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}") else: - DRY_RUN_DATA[ - f"OAMCrossAccountRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}" - ] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" + DRY_RUN_DATA[f"OAMCrossAccountRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}"] = ( + f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" + ) # 5e) OAM link in bedrock account cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) @@ -1046,7 +1141,9 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: if DRY_RUN is False: LOGGER.info("CloudWatch observability access manager link not found, creating...") oam_link_arn = cloudwatch.create_oam_link(oam_sink_arn) - LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}_{bedrock_region}"] = f"Created CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" + LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}_{bedrock_region}"] = ( + f"Created CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" + ) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 @@ -1055,7 +1152,9 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") else: LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") - DRY_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = f"DRY_RUN: Create CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" + DRY_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = ( + f"DRY_RUN: Create CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" + ) # Set link arn to default value (for dry run) oam_link_arn = f"arn:aws:cloudwatch::{bedrock_account}:link/arn" else: @@ -1064,6 +1163,7 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # add OAM link state table record add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") + def deploy_cloudwatch_dashboard(event: dict) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA @@ -1071,7 +1171,9 @@ def deploy_cloudwatch_dashboard(event: dict) -> None: central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) - cloudwatch_dashboard = build_cloudwatch_dashboard(CLOUDWATCH_DASHBOARD, SOLUTION_NAME, central_observability_params["bedrock_accounts"], central_observability_params["regions"]) + cloudwatch_dashboard = build_cloudwatch_dashboard( + CLOUDWATCH_DASHBOARD, SOLUTION_NAME, central_observability_params["bedrock_accounts"], central_observability_params["regions"] + ) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "cloudwatch", sts.HOME_REGION) search_dashboard = cloudwatch.find_dashboard(SOLUTION_NAME) @@ -1085,13 +1187,32 @@ def deploy_cloudwatch_dashboard(event: dict) -> None: CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info("Created CloudWatch observability dashboard") # add dashboard state table record - add_state_table_record("cloudwatch", "implemented", "cloudwatch dashboard", "dashboard", search_dashboard[1], ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, SOLUTION_NAME) + add_state_table_record( + "cloudwatch", + "implemented", + "cloudwatch dashboard", + "dashboard", + search_dashboard[1], + ssm_params.SRA_SECURITY_ACCT, + sts.HOME_REGION, + SOLUTION_NAME, + ) else: LOGGER.info("DRY_RUN: CloudWatch observability dashboard not found, creating...") DRY_RUN_DATA["CloudWatchDashboardCreate"] = "DRY_RUN: Create CloudWatch observability dashboard" else: LOGGER.info(f"Cloudwatch dashboard already exists: {search_dashboard[1]}") - add_state_table_record("cloudwatch", "implemented", "cloudwatch dashboard", "dashboard", search_dashboard[1], ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, SOLUTION_NAME) + add_state_table_record( + "cloudwatch", + "implemented", + "cloudwatch dashboard", + "dashboard", + search_dashboard[1], + ssm_params.SRA_SECURITY_ACCT, + sts.HOME_REGION, + SOLUTION_NAME, + ) + def remove_cloudwatch_dashboard() -> None: global DRY_RUN_DATA @@ -1137,11 +1258,21 @@ def create_event(event: dict, context: Any) -> str: execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) execution_role_name = execution_role_arn.split("/")[-1] LOGGER.info(f"Adding state table record for lambda IAM execution role: {execution_role_arn}") - add_state_table_record("iam", "implemented", "lambda execution role", "role", execution_role_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, execution_role_name) + add_state_table_record( + "iam", "implemented", "lambda execution role", "role", execution_role_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, execution_role_name + ) # add lambda function state table record LOGGER.info(f"Adding state table record for lambda function: {context.invoked_function_arn}") - LAMBDA_RECORD_ID = add_state_table_record("lambda", "implemented", "bedrock solution function", "lambda", context.invoked_function_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, context.function_name) - + LAMBDA_RECORD_ID = add_state_table_record( + "lambda", + "implemented", + "bedrock solution function", + "lambda", + context.invoked_function_arn, + sts.MANAGEMENT_ACCOUNT, + sts.HOME_REGION, + context.function_name, + ) # 1) Stage config rule lambda code (global/home region) deploy_stage_config_rule_lambda_code() @@ -1191,6 +1322,7 @@ def update_event(event: dict, context: Any) -> str: create_event(event, context) return CFN_RESOURCE_ID + def delete_custom_config_rule(rule_name: str, acct: str, region: str) -> None: # Delete the config rule config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) @@ -1226,6 +1358,7 @@ def delete_custom_config_rule(rule_name: str, acct: str, region: str) -> None: else: LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") + def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: global DRY_RUN_DATA global LIVE_RUN_DATA @@ -1240,17 +1373,17 @@ def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: if DRY_RUN is False: LOGGER.info(f"Detaching {policy['PolicyName']} IAM policy from account {acct} in {region}") iam.detach_policy(rule_name, policy["PolicyArn"]) - LIVE_RUN_DATA[ - f"{rule_name}_{acct}_{region}_PolicyDetach" - ] = f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" + LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDetach"] = ( + f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" + ) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 else: LOGGER.info(f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}") - DRY_RUN_DATA[ - f"{rule_name}_{acct}_{region}_Delete" - ] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = ( + f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" + ) else: - LOGGER.info(f"No IAM policies attached to {rule_name} for account {acct} in {region}") + LOGGER.info(f"No IAM policies attached to {rule_name} for account {acct} in {region}") # Delete IAM policy policy_arn = f"arn:{sts.PARTITION}:iam::{acct}:policy/{rule_name}-lamdba-basic-execution" @@ -1266,9 +1399,9 @@ def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: remove_state_table_record(policy_arn) else: LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") - DRY_RUN_DATA[ - f"{rule_name}_{acct}_{region}_PolicyDelete" - ] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDelete"] = ( + f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" + ) else: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region} does not exist.") @@ -1285,9 +1418,7 @@ def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: remove_state_table_record(policy_arn2) else: LOGGER.info(f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}") - DRY_RUN_DATA[ - f"{rule_name}_{acct}_{region}_PolicyDelete" - ] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" + DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDelete"] = f"DRY_RUN: Delete {rule_name} IAM policy for account {acct} in {region}" else: LOGGER.info(f"{rule_name} IAM policy for account {acct} in {region} does not exist.") @@ -1307,6 +1438,7 @@ def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: else: LOGGER.info(f"{rule_name} IAM role for account {acct} in {region} does not exist.") + def delete_sns_topic_and_key(acct: str, region: str) -> None: # Delete the alarm topic sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) @@ -1395,6 +1527,7 @@ def delete_metric_filter_and_alarm(filter_name: str, acct: str, region: str, fil LOGGER.info(f"DRY_RUN: Delete {filter_name} CloudWatch metric filter") DRY_RUN_DATA[f"{filter_name}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter_name} CloudWatch metric filter" + def delete_event(event: dict, context: Any) -> None: # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. # TODO(liamschn): move re-used delete event operation code to separate functions @@ -1468,18 +1601,18 @@ def delete_event(event: dict, context: Any) -> None: for policy in cross_account_policies: LOGGER.info(f"Detaching {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") iam.detach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy["PolicyArn"]) - LIVE_RUN_DATA[ - "OAMCrossAccountRolePolicyDetach" - ] = f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + LIVE_RUN_DATA["OAMCrossAccountRolePolicyDetach"] = ( + f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + ) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") else: for policy in cross_account_policies: LOGGER.info(f"DRY_RUN: Detaching {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") - DRY_RUN_DATA[ - "OAMCrossAccountRolePolicyDetach" - ] = f"DRY_RUN: Detach {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + DRY_RUN_DATA["OAMCrossAccountRolePolicyDetach"] = ( + f"DRY_RUN: Detach {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" + ) else: LOGGER.info(f"No policies attached to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") @@ -1559,7 +1692,13 @@ def delete_event(event: dict, context: Any) -> None: cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) -def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, resource_properties: dict, action: str, ) -> None: +def create_sns_messages( + accounts: list, + regions: list, + sns_topic_arn: str, + resource_properties: dict, + action: str, +) -> None: """Create SNS Message. Args: @@ -1577,14 +1716,14 @@ def create_sns_messages(accounts: list, regions: list, sns_topic_arn: str, resou LOGGER.info("ResourceProperties found in event") for region in regions: - sns_message = {"Accounts": accounts, "Region": region, "ResourceProperties": resource_properties, "Action": action} - sns_messages.append( - { - "Id": region, - "Message": json.dumps(sns_message), - "Subject": "SRA Bedrock Configuration", - } - ) + sns_message = {"Accounts": accounts, "Region": region, "ResourceProperties": resource_properties, "Action": action} + sns_messages.append( + { + "Id": region, + "Message": json.dumps(sns_message), + "Subject": "SRA Bedrock Configuration", + } + ) sns.process_sns_message_batches(sns_messages, sns_topic_arn) if DRY_RUN is False: LIVE_RUN_DATA["SNSFanout"] = "Published SNS messages for regional fanout configuration" @@ -1608,10 +1747,10 @@ def process_sns_records(event: dict) -> None: LOGGER.info("Continuing process to enable SRA security controls for Bedrock (sns event)") # 3) Deploy config rules (regional) - message['Accounts'].append(sts.MANAGEMENT_ACCOUNT) + message["Accounts"].append(sts.MANAGEMENT_ACCOUNT) deploy_config_rules( message["Region"], - message["Accounts"], + message["Accounts"], message["ResourceProperties"], ) @@ -1647,6 +1786,7 @@ def create_json_file(file_name: str, data: dict) -> None: with open(f"/tmp/{file_name}", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=4) + def deploy_iam_role(account_id: str, rule_name: str) -> str: """Deploy IAM role. @@ -1697,13 +1837,17 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 # add IAM policy state table record - add_state_table_record("iam", "implemented", "policy for config rule role", "policy", policy_arn, account_id, "Global", f"{rule_name}-lamdba-basic-execution") + add_state_table_record( + "iam", "implemented", "policy for config rule role", "policy", policy_arn, account_id, "Global", f"{rule_name}-lamdba-basic-execution" + ) else: LOGGER.info(f"DRY _RUN: Creating {rule_name}-lamdba-basic-execution IAM policy in {account_id}...") else: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") # add IAM policy state table record - add_state_table_record("iam", "implemented", "policy for config rule role", "policy", policy_arn, account_id, "Global", f"{rule_name}-lamdba-basic-execution") + add_state_table_record( + "iam", "implemented", "policy for config rule role", "policy", policy_arn, account_id, "Global", f"{rule_name}-lamdba-basic-execution" + ) policy_arn2 = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}" iam_policy_search2 = iam.check_iam_policy_exists(policy_arn2) @@ -1852,7 +1996,9 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: add_state_table_record("config", "implemented", "config rule", "rule", config_rule_arn, account_id, region, rule_name) -def deploy_metric_filter(region: str, acct: str, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str) -> None: +def deploy_metric_filter( + region: str, acct: str, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str +) -> None: """Deploy metric filter. Args: @@ -1880,7 +2026,6 @@ def deploy_metric_filter(region: str, acct: str, log_group_name: str, filter_nam add_state_table_record("cloudwatch", "implemented", "log metric filter", "filter", metric_filter_arn, acct, region, filter_name) - def deploy_metric_alarm( region: str, acct: str, @@ -1888,11 +2033,19 @@ def deploy_metric_alarm( alarm_description: str, metric_name: str, metric_namespace: str, - metric_statistic: Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum'], + metric_statistic: Literal["Average", "Maximum", "Minimum", "SampleCount", "Sum"], metric_period: int, metric_evaluation_periods: int, metric_threshold: float, - metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', 'LessThanThreshold'], + metric_comparison_operator: Literal[ + "GreaterThanOrEqualToThreshold", + "GreaterThanThreshold", + "GreaterThanUpperThreshold", + "LessThanLowerOrGreaterThanUpperThreshold", + "LessThanLowerThreshold", + "LessThanOrEqualToThreshold", + "LessThanThreshold", + ], metric_treat_missing_data: str, alarm_actions: list, ) -> None: @@ -1994,12 +2147,12 @@ def lambda_handler(event: dict, context: Any) -> dict: "dry_run_data": DRY_RUN_DATA, } LAMBDA_FINISH = dynamodb.get_date_time() - + lambda_data = { "start_time": LAMBDA_START, "end_time": LAMBDA_FINISH, "lambda_result": "SUCCESS", - } + } item_found, find_result = dynamodb.find_item( STATE_TABLE, @@ -2014,7 +2167,7 @@ def lambda_handler(event: dict, context: Any) -> dict: update_state_table_record(sra_resource_record_id, lambda_data) else: LOGGER.info(f"Lambda record not found in {STATE_TABLE} table so unable to update it.") - + return { "statusCode": 200, "lambda_start": LAMBDA_START, From ec01b9fb3a7099b943a7b264e8e25b804fa8bba4 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 10 Dec 2024 10:20:37 -0700 Subject: [PATCH 297/395] fixing mypy issues again in dynamodb --- .../bedrock_org/lambda/src/sra_dynamodb.py | 99 ++++++------------- 1 file changed, 29 insertions(+), 70 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 5721222b8..e8b5640bf 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -1,15 +1,14 @@ import logging import boto3 -from boto3.dynamodb.conditions import Key, Attr, ConditionBase +from boto3.dynamodb.conditions import Key, Attr import os import random import string from datetime import datetime from time import sleep import botocore -from botocore.exceptions import ClientError from boto3.session import Session -from typing import TYPE_CHECKING, Any, Sequence, cast, Dict, Tuple, List +from typing import TYPE_CHECKING, Any, Dict, Sequence, cast if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource @@ -137,59 +136,44 @@ def update_item(self, table_name: str, solution_name: str, record_id: str, attri ) return response - def find_item( - self, table_name: str, solution_name: str, additional_attributes: Dict[str, Any] - ) -> Tuple[bool, Dict[str, Any]]: - """Find an item in the DynamoDB table based on the solution name and additional attributes. + def find_item(self, table_name: str, solution_name: str, additional_attributes: dict) -> tuple[bool, dict]: + """Find an item in the dynamodb table based on the solution name and additional attributes. Args: - table_name: DynamoDB table name. - solution_name: Solution name to search for. - additional_attributes: Additional attributes to search for. + table_name: dynamodb table name + dynamodb_resource: dynamodb resource + solution_name: solution name + additional_attributes: additional attributes to search for Returns: - True and the item if found, otherwise False and empty dict. + True and the item if found, otherwise False and empty dict """ - self.LOGGER.info(f"Searching for {additional_attributes} in {table_name} DynamoDB table") - - # Get the DynamoDB table + self.LOGGER.info(f"Searching for {additional_attributes} in {table_name} dynamodb table") table = self.DYNAMODB_RESOURCE.Table(table_name) + expression_attribute_values = {":solution_name": solution_name} - # Prepare query parameters - expression_attribute_values: Dict[str, Any] = {":solution_name": solution_name} + filter_expression = " AND ".join([f"{attr} = :{attr}" for attr in additional_attributes.keys()]) - # Build the filter expression - filter_conditions: ConditionBase = Attr(list(additional_attributes.keys())[0]).eq( - additional_attributes[list(additional_attributes.keys())[0]] - ) - for attr, value in list(additional_attributes.items())[1:]: - filter_conditions &= Attr(attr).eq(value) + expression_attribute_values.update({f":{attr}": value for attr, value in additional_attributes.items()}) + + query_params: Dict[str, Any] = {} - query_params: Dict[str, Any] = { - "KeyConditionExpression": Key("solution_name").eq(solution_name), + query_params = { + "KeyConditionExpression": "solution_name = :solution_name", "ExpressionAttributeValues": expression_attribute_values, - "FilterExpression": filter_conditions, + "FilterExpression": filter_expression, } - try: - response = table.query(**query_params) - except ClientError as e: - self.LOGGER.error(f"Error querying DynamoDB table {table_name}: {e}") - return False, {} + response = table.query(**query_params) - # Handle the response - items = response.get("Items", []) - if len(items) > 1: + if len(response["Items"]) > 1: self.LOGGER.info( - f"Found more than one record that matched solution name {solution_name}: {additional_attributes}. " - f"Review {table_name} DynamoDB table to determine the cause." + f"Found more than one record that matched solution name {solution_name}: {additional_attributes} Review {table_name} dynamodb table to determine cause." ) - elif not items: + elif len(response["Items"]) < 1: return False, {} - - self.LOGGER.info(f"Found record id {items[0]}") - return True, items[0] - + self.LOGGER.info(f"Found record id {response['Items'][0]}") + return True, response["Items"][0] def get_unique_values_from_list(self, list_of_values: list) -> list: unique_values = [] @@ -207,45 +191,20 @@ def get_distinct_solutions_and_accounts(self, table_name: str) -> tuple[list, li accounts = self.get_unique_values_from_list(accounts) return solution_names, accounts - def get_resources_for_solutions_by_account( - self, table_name: str, solutions: List[str], account: str - ) -> Dict[str, Any]: - """ - Retrieve resources for the specified solutions and account from a DynamoDB table. - - Args: - table_name: Name of the DynamoDB table. - solutions: List of solutions to query. - account: Account to filter by. - - Returns: - Dictionary of solutions and their corresponding query results. - """ + def get_resources_for_solutions_by_account(self, table_name: str, solutions: list, account: str) -> dict: table = self.DYNAMODB_RESOURCE.Table(table_name) - query_results: Dict[str, Any] = {} - + query_results = {} for solution in solutions: - # Build the query parameters - key_condition: ConditionBase = Key("solution_name").eq(solution) - filter_condition: ConditionBase = Attr("account").eq(account) - query_params: Dict[str, Any] = { - "KeyConditionExpression": key_condition, - "ExpressionAttributeValues": { - ":solution_name": solution, - ":account": account, - }, - "FilterExpression": filter_condition, + "KeyConditionExpression": "solution_name = :solution_name", + "ExpressionAttributeValues": {":solution_name": solution, ":account": account}, + "FilterExpression": "account = :account", } - - # Perform the query response = table.query(**query_params) self.LOGGER.info(f"response: {response}") query_results[solution] = response - return query_results - def delete_item(self, table_name: str, solution_name: str, record_id: str) -> Any: """Delete an item from the dynamodb table From 49aac979c0d9d0e62083aa01b460e6ad5e792dfd Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 10 Dec 2024 12:41:53 -0700 Subject: [PATCH 298/395] fixing flake8 errors; adding docstrings --- .../genai/bedrock_org/lambda/src/app.py | 76 +++++++++++++++---- .../bedrock_org/lambda/src/sra_lambda.py | 2 +- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index bf7a3910f..061667a1c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1,12 +1,22 @@ +"""This script performs operations to enable, configure, and disable Bedrock security controls. + +Version: 1.0 + +Main app module for SRA GenAI Bedrock org security controls solution in the repo, +https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" import copy from datetime import datetime import json import os import logging +from pathlib import Path import re import boto3 import cfnresponse -from botocore.exceptions import ClientError import sra_s3 import sra_repo @@ -22,8 +32,6 @@ from typing import Dict, Any, List, Literal, Optional -# import sra_lambda - # TODO(liamschn): deploy example bedrock guardrail # TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority/not necessary? # TODO(liamschn): deploy example bucket policy(ies) - lower priority/not necessary? @@ -31,11 +39,6 @@ # TODO(liamschn): check for unused parameters (in progress) # TODO(liamschn): make sure things don't fail (create or delete) if the dynamodb table is deleted/doesn't exist (use case, maybe someone deletes it) -from typing import TYPE_CHECKING, Sequence # , Union, Literal, Optional - -if TYPE_CHECKING: - from mypy_boto3_ssm.type_defs import TagTypeDef - LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) @@ -43,33 +46,74 @@ # TODO(liamschn): change this so that it downloads the sra_config_lambda_iam_permissions.json from the repo then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) def load_iam_policy_documents() -> Dict[str, Any]: - json_file_path = os.path.join(os.path.dirname(__file__), "sra_config_lambda_iam_permissions.json") - with open(json_file_path, "r") as file: + """Load IAM Policy Documents from JSON file. + + Returns: + dict: IAM Policy Documents + """ + LOGGER.info("...load_iam_policy_documents") + json_file_path = Path(__file__).parent / "sra_config_lambda_iam_permissions.json" + with json_file_path.open("r") as file: return json.load(file) -def load_cloudwatch_metric_filters() -> dict: - with open("sra_cloudwatch_metric_filters.json", "r") as file: +def load_cloudwatch_metric_filters() -> Dict[str, Any]: + """Load CloudWatch Metric Filters from JSON file. + + Returns: + dict: CloudWatch Metric Filters + """ + LOGGER.info("...load_cloudwatch_metric_filters") + json_file_path = Path(__file__).parent / "sra_cloudwatch_metric_filters.json" + with json_file_path.open("r") as file: return json.load(file) def load_kms_key_policies() -> dict: - with open("sra_kms_keys.json", "r") as file: + """Load KMS Key Policies from JSON file. + + Returns: + dict: KMS Key Policies + """ + LOGGER.info("...load_kms_key_policies") + json_file_path = Path(__file__).parent / "sra_kms_keys.json" + with json_file_path.open("r") as file: return json.load(file) def load_cloudwatch_oam_sink_policy() -> dict: - with open("sra_cloudwatch_oam_sink_policy.json", "r") as file: + """Load CloudWatch OAM Sink Policy from JSON file. + + Returns: + dict: CloudWatch OAM Sink Policy + """ + LOGGER.info("...load_cloudwatch_oam_sink_policy") + json_file_path = Path(__file__).parent / "sra_cloudwatch_oam_sink_policy.json" + with json_file_path.open("r") as file: return json.load(file) def load_sra_cloudwatch_oam_trust_policy() -> dict: - with open("sra_cloudwatch_oam_trust_policy.json", "r") as file: + """Load CloudWatch OAM Sink Policy from JSON file. + + Returns: + dict: CloudWatch OAM Sink Policy + """ + LOGGER.info("...load_sra_cloudwatch_oam_trust_policy") + json_file_path = Path(__file__).parent / "sra_cloudwatch_oam_trust_policy.json" + with json_file_path.open("r") as file: return json.load(file) def load_sra_cloudwatch_dashboard() -> dict: - with open("sra_cloudwatch_dashboard.json", "r") as file: + """Load CloudWatch Dashboard from JSON file. + + Returns: + dict: CloudWatch Dashboard + """ + LOGGER.info("...load_sra_cloudwatch_dashboard") + json_file_path = Path(__file__).parent / "sra_cloudwatch_dashboard.json" + with json_file_path.open("r") as file: return json.load(file) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index e6a9fbdcd..c5b43cd83 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -1,4 +1,4 @@ -"""Custom Resource to setup SRA Lambda resources in the organization. +"""Lambda python module to setup SRA Lambda resources in the organization. Version: 1.0 From 903af2d039e1344f7a8c9c86b3b72cb2c650e1ba Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 10 Dec 2024 13:21:37 -0700 Subject: [PATCH 299/395] fixing flake8 issues --- .../genai/bedrock_org/lambda/src/app.py | 109 ++++++++++++++---- 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 061667a1c..f793cdc67 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -44,7 +44,8 @@ LOGGER.setLevel(log_level) -# TODO(liamschn): change this so that it downloads the sra_config_lambda_iam_permissions.json from the repo then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) +# TODO(liamschn): change this so that it downloads the sra_config_lambda_iam_permissions.json from the repo +# then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) def load_iam_policy_documents() -> Dict[str, Any]: """Load IAM Policy Documents from JSON file. @@ -164,31 +165,88 @@ def load_sra_cloudwatch_dashboard() -> dict: "DRY_RUN": r"^true|false$", "EXECUTION_ROLE_NAME": r"^sra-execution$", "LOG_GROUP_DEPLOY": r"^true|false$", - "LOG_GROUP_RETENTION": r"^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$", + "LOG_GROUP_RETENTION": ( + r"^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$" + ), "LOG_LEVEL": r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", "SOLUTION_NAME": r"^sra-bedrock-org$", "SOLUTION_VERSION": r"^[0-9]+\.[0-9]+\.[0-9]+$", "SRA_ALARM_EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', - "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', - "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', - "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$', - "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-CHECK-S3-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-FILTER-SERVICE-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-FILTER-BUCKET-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$', - "SRA-BEDROCK-FILTER-PROMPT-INJECTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-FILTER-SENSITIVE-INFO": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-CENTRAL-OBSERVABILITY": r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$', + "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"' + + r'\s*)?})\}$' + ), + "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"' + + r'\s*)?})\}$' + ), + "SRA-BEDROCK-CHECK-GUARDRAILS": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?' + + r'(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?' + + r'(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?' + + r'\s*\}\}$' + ), + "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?' + + r'(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false")' + + r')?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$' + ), + "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?' + + r'(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$' + ), + "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?' + + r'(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?' + + r'(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$' + ), + "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$' + ), + "SRA-BEDROCK-CHECK-S3-ENDPOINTS": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$' + ), + "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$' + ), + "SRA-BEDROCK-FILTER-SERVICE-CHANGES": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$' + ), + "SRA-BEDROCK-FILTER-BUCKET-CHANGES": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*' + + r'"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$' + ), + "SRA-BEDROCK-FILTER-PROMPT-INJECTION": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*' + + r'"input_path"\s*:\s*"[^"\s]+"\}\}$' + ), + "SRA-BEDROCK-FILTER-SENSITIVE-INFO": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*' + + r'"input_path"\s*:\s*"[^"\s]+"\}\}$' + ), + "SRA-BEDROCK-CENTRAL-OBSERVABILITY": ( + r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*' + + r':\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$' + ), } # Instantiate sra class objects -# todo(liamschn): can these files exist in some central location to be shared with other solutions? +# TODO(liamschn): can these files exist in some central location to be shared with other solutions? ssm_params = sra_ssm_params.sra_ssm_params() iam = sra_iam.sra_iam() dynamodb = sra_dynamodb.sra_dynamodb() @@ -206,6 +264,15 @@ def load_sra_cloudwatch_dashboard() -> dict: def get_resource_parameters(event: dict) -> None: + """Get resource parameters from event. + + Args: + event: event from lambda handler + + Raises: + ValueError: If the event is not valid + """ + LOGGER.info("Getting resource parameters...") global DRY_RUN global GOVERNED_REGIONS global CFN_RESPONSE_DATA @@ -221,8 +288,8 @@ def get_resource_parameters(event: dict) -> None: LOGGER.info("Getting resource params...") repo.REPO_ZIP_URL = event["ResourceProperties"]["SRA_REPO_ZIP_URL"] - repo.REPO_BRANCH = repo.REPO_ZIP_URL.split(".")[1].split("/")[len(repo.REPO_ZIP_URL.split(".")[1].split("/")) - 1] - repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" + repo.REPO_BRANCH = repo.REPO_ZIP_URL.split(".")[1].split("/")[len(repo.REPO_ZIP_URL.split(".")[1].split("/")) - 1] # noqa: ECE001 + repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" # noqa: S108 sts.CONFIGURATION_ROLE = "sra-execution" governed_regions_param = ssm_params.get_ssm_parameter( @@ -274,7 +341,7 @@ def get_resource_parameters(event: dict) -> None: def validate_parameters(parameters: Dict[str, str], rules: Dict[str, str]) -> Dict[str, object]: - """Validates each parameter against its corresponding regular expression. + """Validate parameters. Args: parameters (Dict[str, str]): Dictionary of parameters to validate @@ -296,7 +363,7 @@ def validate_parameters(parameters: Dict[str, str], rules: Dict[str, str]) -> Di def get_accounts_and_regions(resource_properties: dict) -> tuple[list, list]: - """Get accounts and regions from event and return them in a tuple + """Get accounts and regions from event and return them in a tuple. Args: resource_properties (dict): lambda event resource properties From cd0fb1e78d696a2aa0674f67e67651c846a91ec4 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 10 Dec 2024 22:31:28 -0700 Subject: [PATCH 300/395] fix flake8 errors in app --- .../genai/bedrock_org/lambda/src/app.py | 304 +++++++++++++----- 1 file changed, 221 insertions(+), 83 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index f793cdc67..e2ec7fa2b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -44,7 +44,7 @@ LOGGER.setLevel(log_level) -# TODO(liamschn): change this so that it downloads the sra_config_lambda_iam_permissions.json from the repo +# TODO(liamschn): change this so that it downloads the sra_config_lambda_iam_permissions.json from the repo # then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) def load_iam_policy_documents() -> Dict[str, Any]: """Load IAM Policy Documents from JSON file. @@ -392,8 +392,8 @@ def get_accounts_and_regions(resource_properties: dict) -> tuple[list, list]: return accounts, regions -def get_rule_params(rule_name: str, resource_properties: dict) -> tuple[bool, list, list, dict]: - """Get rule parameters from event and return them in a tuple +def get_rule_params(rule_name: str, resource_properties: dict) -> tuple[bool, list, list, dict]: # noqa: CCR001 + """Get rule parameters from event and return them in a tuple. Args: rule_name (str): name of config rule @@ -406,7 +406,6 @@ def get_rule_params(rule_name: str, resource_properties: dict) -> tuple[bool, li rule_regions (list): list of regions to deploy the rule to rule_input_params (dict): dictionary of rule input parameters """ - if rule_name.upper() in resource_properties: LOGGER.info(f"{rule_name} parameter found in event ResourceProperties") rule_params = json.loads(resource_properties[rule_name.upper()]) @@ -446,13 +445,12 @@ def get_rule_params(rule_name: str, resource_properties: dict) -> tuple[bool, li LOGGER.info(f"{rule_name.upper()} 'input_params' parameter not found in event ResourceProperties; setting to None") rule_input_params = {} return rule_deploy, rule_accounts, rule_regions, rule_input_params - else: - LOGGER.info(f"{rule_name.upper()} config rule parameter not found in event ResourceProperties; skipping...") - return False, [], [], {} + LOGGER.info(f"{rule_name.upper()} config rule parameter not found in event ResourceProperties; skipping...") + return False, [], [], {} -def get_filter_params(filter_name: str, resource_properties: dict) -> tuple[bool, list, list, dict]: - """Get filter parameters from event resource_properties and return them in a tuple +def get_filter_params(filter_name: str, resource_properties: dict) -> tuple[bool, list, list, dict]: # noqa: CCR001 + """Get filter parameters from event resource_properties and return them in a tuple. Args: filter_name (str): name of cloudwatch filter @@ -508,6 +506,16 @@ def get_filter_params(filter_name: str, resource_properties: dict) -> tuple[bool def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: str) -> str: + """Build the S3 filter pattern. + + Args: + bucket_names (list): list of bucket names to build the filter pattern for + filter_pattern_template (str): filter pattern template + + Returns: + str: filter pattern + """ + LOGGER.info("Building S3 filter pattern...") # Get the S3 filter s3_filter = filter_pattern_template @@ -519,34 +527,48 @@ def build_s3_metric_filter_pattern(bucket_names: list, filter_pattern_template: s3_filter = s3_filter.replace("", bucket_names[0]) else: # If no bucket names are provided, remove the bucket condition entirely - s3_filter = s3_filter.replace('&& ($.requestParameters.bucketName = "")', "") + return s3_filter.replace('&& ($.requestParameters.bucketName = "")', "") return s3_filter def build_cloudwatch_dashboard(dashboard_template: dict, solution: str, bedrock_accounts: list, regions: list) -> dict: + """Build the CloudWatch dashboard template. + + Args: + dashboard_template (dict): CloudWatch dashboard template + solution (str): name of solution + bedrock_accounts (list): list of accounts to build the dashboard for + regions (list): list of regions to build the dashboard for + + Returns: + dict: CloudWatch dashboard template + """ + LOGGER.info("Building CloudWatch dashboard template...") i = 0 for bedrock_account in bedrock_accounts: for region in regions: if i == 0: - injection_template = copy.deepcopy(dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2]) - sensitive_info_template = copy.deepcopy(dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3]) + injection_template = copy.deepcopy(dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2]) # noqa: ECE001 + sensitive_info_template = copy.deepcopy(dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3]) # noqa: ECE001 else: dashboard_template[solution]["widgets"][0]["properties"]["metrics"].append(copy.deepcopy(injection_template)) dashboard_template[solution]["widgets"][0]["properties"]["metrics"].append(copy.deepcopy(sensitive_info_template)) - dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2 + i][2]["accountId"] = bedrock_account - dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2 + i][2]["region"] = region - dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3 + i][2]["accountId"] = bedrock_account - dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3 + i][2]["region"] = region + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2 + i][2]["accountId"] = bedrock_account # noqa: ECE001 + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][2 + i][2]["region"] = region # noqa: ECE001 + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3 + i][2]["accountId"] = bedrock_account # noqa: ECE001 + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][3 + i][2]["region"] = region # noqa: ECE001 i += 2 - dashboard_template[solution]["widgets"][0]["properties"]["metrics"][0][2]["accountId"] = sts.MANAGEMENT_ACCOUNT - dashboard_template[solution]["widgets"][0]["properties"]["metrics"][0][2]["region"] = sts.HOME_REGION - dashboard_template[solution]["widgets"][0]["properties"]["metrics"][1][2]["accountId"] = sts.MANAGEMENT_ACCOUNT - dashboard_template[solution]["widgets"][0]["properties"]["metrics"][1][2]["region"] = sts.HOME_REGION + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][0][2]["accountId"] = sts.MANAGEMENT_ACCOUNT # noqa: ECE001 + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][0][2]["region"] = sts.HOME_REGION # noqa: ECE001 + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][1][2]["accountId"] = sts.MANAGEMENT_ACCOUNT # noqa: ECE001 + dashboard_template[solution]["widgets"][0]["properties"]["metrics"][1][2]["region"] = sts.HOME_REGION # noqa: ECE001 dashboard_template[solution]["widgets"][0]["properties"]["region"] = sts.HOME_REGION return dashboard_template[solution] def deploy_state_table() -> None: + """Deploy the state table to DynamoDB.""" + LOGGER.info("Deploying the state table to DynamoDB...") global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -595,7 +617,7 @@ def deploy_state_table() -> None: DRY_RUN_DATA["StateTableCreate"] = f"DRY_RUN: Create the {STATE_TABLE} state table" -def add_state_table_record( +def add_state_table_record( # noqa: CFQ002 aws_service: str, component_state: str, description: str, @@ -606,7 +628,8 @@ def add_state_table_record( component_name: str, key_id: str = "", ) -> str: - """Add a record to the state table + """Add a record to the state table. + Args: aws_service (str): aws service component_state (str): component state @@ -622,7 +645,7 @@ def add_state_table_record( None """ LOGGER.info(f"Add a record to the state table for {component_name}") - if account_id == None: + if account_id is None: account_id = "Unknown" # TODO(liamschn): check to ensure we got a 200 back from the service API call before inserting the dynamodb records dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) @@ -660,13 +683,13 @@ def add_state_table_record( def remove_state_table_record(resource_arn: str) -> Any: - """Remove a record from the state table + """Remove a record from the state table. Args: resource_arn (str): arn of the resource Returns: - response: response from dynamodb delete_item + Any: response from the dynamodb delete_item function """ dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) LOGGER.info(f"Searching for {resource_arn} in {STATE_TABLE} dynamodb table...") @@ -693,6 +716,16 @@ def remove_state_table_record(resource_arn: str) -> Any: def update_state_table_record(record_id: str, update_data: dict) -> None: + """Update a record in the state table. + + Args: + record_id (str): record id + update_data (dict): data to update + + Returns: + None + """ + LOGGER.info(f"Updating {record_id} record in {STATE_TABLE} dynamodb table...") dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) try: @@ -708,6 +741,7 @@ def update_state_table_record(record_id: str, update_data: dict) -> None: def deploy_stage_config_rule_lambda_code() -> None: + """Deploy the config rule lambda code to the staging s3 bucket.""" global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -730,6 +764,14 @@ def deploy_stage_config_rule_lambda_code() -> None: def deploy_sns_configuration_topics(context: Any) -> str: + """Deploy sns configuration topics. + + Args: + context (Any): lambda context object + + Returns: + str: sns topic arn + """ global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -782,7 +824,14 @@ def deploy_sns_configuration_topics(context: Any) -> str: return topic_arn -def deploy_config_rules(region: str, accounts: list, resource_properties: dict) -> None: +def deploy_config_rules(region: str, accounts: list, resource_properties: dict) -> None: # noqa: CCR001 + """Deploy config rules. + + Args: + region (str): aws region + accounts (list): aws accounts + resource_properties (dict): event resource properties + """ global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -824,8 +873,8 @@ def deploy_config_rules(region: str, accounts: list, resource_properties: dict) if DRY_RUN is False: # download rule zip file s3_key = f"{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" - local_base_path = "/tmp/sra_staging_upload" - local_file_path = os.path.join(local_base_path, f"{SOLUTION_NAME}", "rules", rule_name, f"{rule_name}.zip") + local_base_path = "/tmp/sra_staging_upload" # noqa: S108 + local_file_path = os.path.join(local_base_path, f"{SOLUTION_NAME}", "rules", rule_name, f"{rule_name}.zip") # noqa: PL118 s3.download_s3_file(local_file_path, s3_key, s3.STAGING_BUCKET) LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_LambdaCode"] = "Downloaded custom config rule lambda code" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 @@ -850,7 +899,14 @@ def deploy_config_rules(region: str, accounts: list, resource_properties: dict) DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Config"] = "DRY_RUN: Deploy custom config rule" -def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_properties: dict) -> None: +def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_properties: dict) -> None: # noqa: CCR001, CFQ001, C901 + """Deploy metric filters and alarms. + + Args: + region (str): aws region + accounts (list): aws accounts + resource_properties (dict): event resource properties + """ global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -914,14 +970,13 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info("Customizing key policy...") kms_key_policy = json.loads(json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS])) LOGGER.info(f"kms_key_policy: {kms_key_policy}") - kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"][ + kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"][ # noqa ECE001 "AWS" ].replace("ACCOUNT_ID", acct) kms_key_policy["Statement"][2]["Principal"]["AWS"] = execution_role_arn LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") - # TODO(liamschn): search for key itself (by policy) before creating the key; then separate the alias creation from this section (in progress) - LOGGER.info(f"Searching for existing keys with proper policy...") + LOGGER.info("Searching for existing keys with proper policy...") kms_search_result, kms_found_id = kms.search_key_policies(kms.KMS_CLIENT, json.dumps(kms_key_policy)) if kms_search_result is True: LOGGER.info(f"Found existing key with proper policy: {kms_found_id}") @@ -1002,26 +1057,26 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope if topic_search is None: if DRY_RUN is False: LOGGER.info(f"Creating {SOLUTION_NAME}-alarms SNS topic") - SRA_ALARM_TOPIC_ARN = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME, kms_key=alarm_key_id) - LIVE_RUN_DATA["SNSAlarmTopic"] = f"Created {SOLUTION_NAME}-alarms SNS topic (ARN: {SRA_ALARM_TOPIC_ARN})" + alarm_topic_arn = sns.create_sns_topic(f"{SOLUTION_NAME}-alarms", SOLUTION_NAME, kms_key=alarm_key_id) + LIVE_RUN_DATA["SNSAlarmTopic"] = f"Created {SOLUTION_NAME}-alarms SNS topic (ARN: {alarm_topic_arn})" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Setting access for CloudWatch alarms in {acct} to publish to {SOLUTION_NAME}-alarms SNS topic") # TODO(liamschn): search for policy on SNS topic before adding the policy - sns.set_topic_access_for_alarms(SRA_ALARM_TOPIC_ARN, acct) + sns.set_topic_access_for_alarms(alarm_topic_arn, acct) LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {SRA_ALARM_TOPIC_ARN}") - sns.create_sns_subscription(SRA_ALARM_TOPIC_ARN, "email", SRA_ALARM_EMAIL) + LOGGER.info(f"Subscribing {SRA_ALARM_EMAIL} to {alarm_topic_arn}") + sns.create_sns_subscription(alarm_topic_arn, "email", SRA_ALARM_EMAIL) LIVE_RUN_DATA["SNSAlarmSubscription"] = f"Subscribed {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 # add SNS state table record add_state_table_record( - "sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms" + "sns", "implemented", "sns topic for alarms", "topic", alarm_topic_arn, acct, region, f"{SOLUTION_NAME}-alarms" ) else: @@ -1029,24 +1084,24 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope DRY_RUN_DATA["SNSAlarmCreate"] = f"DRY_RUN: Create {SOLUTION_NAME}-alarms SNS topic" LOGGER.info( - f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to allow " + + f"CloudWatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" ) DRY_RUN_DATA["SNSAlarmPermissions"] = ( - f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to alow cloudwatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" + f"DRY_RUN: Create SNS topic policy for {SOLUTION_NAME}-alarms SNS topic to allow " + + f"CloudWatch alarm access from {sts.MANAGEMENT_ACCOUNT} account" ) - LOGGER.info(f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic") DRY_RUN_DATA["SNSAlarmSubscription"] = f"DRY_RUN: Subscribe {SRA_ALARM_EMAIL} lambda to {SOLUTION_NAME}-alarms SNS topic" else: LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") - SRA_ALARM_TOPIC_ARN = topic_search + alarm_topic_arn = topic_search # add SNS state table record add_state_table_record( - "sns", "implemented", "sns topic for alarms", "topic", SRA_ALARM_TOPIC_ARN, acct, region, f"{SOLUTION_NAME}-alarms" + "sns", "implemented", "sns topic for alarms", "topic", alarm_topic_arn, acct, region, f"{SOLUTION_NAME}-alarms" ) # 4c) Cloudwatch metric filters and alarms - # metric_filter_arn = f"arn:aws:logs:{region}:{acct}:metric-filter:{filter_name}" if DRY_RUN is False: if filter_deploy is True: cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) @@ -1058,7 +1113,7 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LIVE_RUN_DATA[f"{filter_name}_CloudWatch"] = "Deployed CloudWatch metric filter" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 - LOGGER.info(f"DEBUG: Alarm topic ARN: {SRA_ALARM_TOPIC_ARN}") + LOGGER.info(f"DEBUG: Alarm topic ARN: {alarm_topic_arn}") deploy_metric_alarm( region, acct, @@ -1072,7 +1127,7 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope 0, "GreaterThanThreshold", "missing", - [SRA_ALARM_TOPIC_ARN], + [alarm_topic_arn], ) LIVE_RUN_DATA[f"{filter_name}_CloudWatch_Alarm"] = "Deployed CloudWatch metric alarm" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 @@ -1094,7 +1149,14 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope ) -def deploy_central_cloudwatch_observability(event: dict) -> None: +def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR001, CFQ001, C901 + """ + Deploy central cloudwatch observability. + + Args: + event: Lambda event object. + """ + LOGGER.info("Deploying central cloudwatch observability...") global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1140,8 +1202,6 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: LOGGER.info("CloudWatch observability access manager sink policy created") LIVE_RUN_DATA["OAMSinkPolicyCreate"] = "Created CloudWatch observability access manager sink policy" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - put_oam_sink_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink policy not found, creating...") @@ -1155,8 +1215,6 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: LOGGER.info("CloudWatch observability access manager sink policy updated") LIVE_RUN_DATA["OAMSinkPolicyUpdate"] = "Updated CloudWatch observability access manager sink policy" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 - # LOGGER.info(f"DEBUG deploy_central_cloudwatch_observability - COMPARE put_oam_sink_policy: action count increased to {CFN_RESPONSE_DATA["deployment_info"]["action_count"]}") - CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 else: LOGGER.info("DRY_RUN: CloudWatch observability access manager sink policy needs updating...") @@ -1172,13 +1230,13 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: for bedrock_region in central_observability_params["regions"]: iam.IAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) cloudwatch.CROSS_ACCOUNT_TRUST_POLICY = CLOUDWATCH_OAM_TRUST_POLICY[cloudwatch.CROSS_ACCOUNT_ROLE_NAME] - cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0][ - "Principal" - ]["AWS"].replace("", ssm_params.SRA_SECURITY_ACCT) + cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = cloudwatch.CROSS_ACCOUNT_TRUST_POLICY[ # noqa: ECE001 + "Statement"][0]["Principal"]["AWS"].replace("", ssm_params.SRA_SECURITY_ACCT) search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) if search_iam_role[0] is False: LOGGER.info( - f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}..." + f"CloudWatch observability access manager cross-account role not found, creating {cloudwatch.CROSS_ACCOUNT_ROLE_NAME}" + + f" IAM role in {bedrock_account}..." ) if DRY_RUN is False: xacct_role = iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) @@ -1276,6 +1334,11 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: def deploy_cloudwatch_dashboard(event: dict) -> None: + """Deploy CloudWatch dashboard. + + Args: + event (dict): Lambda event data. + """ global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1326,6 +1389,7 @@ def deploy_cloudwatch_dashboard(event: dict) -> None: def remove_cloudwatch_dashboard() -> None: + """Remove cloudwatch dashboard.""" global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1346,11 +1410,20 @@ def remove_cloudwatch_dashboard() -> None: LOGGER.info("DRY_RUN: CloudWatch observability dashboard found, needs to be deleted...") DRY_RUN_DATA["CloudWatchDashboardDelete"] = "DRY_RUN: Delete CloudWatch observability dashboard" else: - LOGGER.info(f"Cloudwatch dashboard not found...") + LOGGER.info(f"{SOLUTION_NAME} cloudwatch dashboard not found...") remove_state_table_record(f"arn:aws:cloudwatch::{ssm_params.SRA_SECURITY_ACCT}:dashboard/{SOLUTION_NAME}") def create_event(event: dict, context: Any) -> str: + """Create event. + + Args: + event (dict): Lambda event data. + context (Any): Lambda context data. + + Returns: + str: CloudFormation response URL. + """ global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1414,7 +1487,8 @@ def create_event(event: dict, context: Any) -> str: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) create_json_file("dry_run_data.json", DRY_RUN_DATA) LOGGER.info("Dry run data saved to file") - s3.upload_file_to_s3("/tmp/dry_run_data.json", s3.STAGING_BUCKET, f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") + s3.upload_file_to_s3("/tmp/dry_run_data.json", s3.STAGING_BUCKET, # noqa: S108 + f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") LOGGER.info(f"Dry run data file uploaded to s3://{s3.STAGING_BUCKET}/dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: @@ -1426,6 +1500,17 @@ def create_event(event: dict, context: Any) -> str: def update_event(event: dict, context: Any) -> str: + """Update event. + + Args: + event (dict): Lambda event data. + context (Any): Lambda context data. + + Returns: + str: CloudFormation response URL. + """ + global CFN_RESPONSE_DATA + CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 # TODO(liamschn): handle CFN update events; use case: add additional config rules via new rules in code (i.e. ...\rules\new_rule\app.py) # TODO(liamschn): handle CFN update events; use case: changing config rule parameters (i.e. deploy, accounts, regions, input_params) global DRY_RUN_DATA @@ -1435,6 +1520,13 @@ def update_event(event: dict, context: Any) -> str: def delete_custom_config_rule(rule_name: str, acct: str, region: str) -> None: + """Delete custom config rule. + + Args: + rule_name (str): Config rule name + acct (str): AWS account number + region (str): AWS region name + """ # Delete the config rule config.CONFIG_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "config", region) config_rule_search = config.find_config_rule(rule_name) @@ -1470,7 +1562,13 @@ def delete_custom_config_rule(rule_name: str, acct: str, region: str) -> None: LOGGER.info(f"{rule_name} lambda function for account {acct} in {region} does not exist.") -def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: +def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: # noqa: CCR001 + """Delete custom config IAM role. + + Args: + rule_name (str): config rule name + acct (str): AWS account ID + """ global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1551,6 +1649,12 @@ def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: def delete_sns_topic_and_key(acct: str, region: str) -> None: + """Delete SNS topic and key. + + Args: + acct (str): AWS account ID + region (str): AWS region name + """ # Delete the alarm topic sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) alarm_topic_search = sns.find_sns_topic(f"{SOLUTION_NAME}-alarms", region, acct) @@ -1600,6 +1704,14 @@ def delete_sns_topic_and_key(acct: str, region: str) -> None: def delete_metric_filter_and_alarm(filter_name: str, acct: str, region: str, filter_params: dict) -> None: + """Delete CloudWatch metric filter and alarm. + + Args: + filter_name (str): CloudWatch metric filter name + acct (str): AWS account ID + region (str): AWS region name + filter_params (dict): CloudWatch metric filter parameters + """ cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) if DRY_RUN is False: @@ -1639,8 +1751,16 @@ def delete_metric_filter_and_alarm(filter_name: str, acct: str, region: str, fil DRY_RUN_DATA[f"{filter_name}_CloudWatchDelete"] = f"DRY_RUN: Delete {filter_name} CloudWatch metric filter" -def delete_event(event: dict, context: Any) -> None: - # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. +def delete_event(event: dict, context: Any) -> None: # noqa: CFQ001, CCR001, C901 + """Delete event function. + + Args: + event (dict): Lambda event object + context (Any): Lambda context object + """ + # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: + # An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. + # Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. # TODO(liamschn): move re-used delete event operation code to separate functions global DRY_RUN_DATA global LIVE_RUN_DATA @@ -1816,7 +1936,8 @@ def create_sns_messages( accounts: Account List regions: list of AWS regions sns_topic_arn: SNS Topic ARN - action: Action + resource_properties: Resource Properties + action: action """ global DRY_RUN_DATA global LIVE_RUN_DATA @@ -1847,7 +1968,7 @@ def process_sns_records(event: dict) -> None: """Process SNS records. Args: - records: list of SNS event records + event: SNS event """ LOGGER.info("Processing SNS records...") for record in event["Records"]: @@ -1872,8 +1993,6 @@ def process_sns_records(event: dict) -> None: message["ResourceProperties"], ) - # # 5) Central CloudWatch Observability (regional) - # deploy_central_cloudwatch_observability(event) else: LOGGER.info(f"Action specified is {message['Action']}") LOGGER.info("SNS records processed.") @@ -1883,7 +2002,8 @@ def process_sns_records(event: dict) -> None: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) create_json_file("dry_run_data.json", DRY_RUN_DATA) LOGGER.info("Dry run data saved to file") - s3.upload_file_to_s3("/tmp/dry_run_data.json", s3.STAGING_BUCKET, f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") + s3.upload_file_to_s3("/tmp/dry_run_data.json", s3.STAGING_BUCKET, # noqa: S108 + f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") LOGGER.info(f"Dry run data file uploaded to s3://{s3.STAGING_BUCKET}/dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") @@ -1894,16 +2014,19 @@ def create_json_file(file_name: str, data: dict) -> None: file_name: name of file to be created data: data to be written to file """ - with open(f"/tmp/{file_name}", "w", encoding="utf-8") as f: + with open(f"/tmp/{file_name}", "w", encoding="utf-8") as f: # noqa: S108, PL123 json.dump(data, f, ensure_ascii=False, indent=4) -def deploy_iam_role(account_id: str, rule_name: str) -> str: +def deploy_iam_role(account_id: str, rule_name: str) -> str: # noqa: CFQ001, CCR001, C901 """Deploy IAM role. Args: account_id: AWS account ID rule_name: config rule name + + Returns: + IAM role ARN """ global CFN_RESPONSE_DATA iam.IAM_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "iam", REGION) @@ -1924,20 +2047,17 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: else: LOGGER.info(f"{rule_name} IAM role already exists.") role_arn = iam_role_search[1] - if role_arn == None: + if role_arn is None: role_arn = "" # add IAM role state table record add_state_table_record("iam", "implemented", "role for config rule", "role", role_arn, account_id, "Global", rule_name) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][0]["Resource"].replace("ACCOUNT_ID", account_id) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][1]["Resource"].replace("ACCOUNT_ID", account_id) - iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"][ - "Statement" - ][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS[ # noqa: ECE001 + "sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("ACCOUNT_ID", account_id) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS[ # noqa: ECE001 + "sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("ACCOUNT_ID", account_id) + iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS[ # noqa: ECE001 + "sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") policy_arn = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}-lamdba-basic-execution" iam_policy_search = iam.check_iam_policy_exists(policy_arn) @@ -2025,16 +2145,19 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio Args: account_id: AWS account ID - config_rule_name: config rule name + rule_name: config rule name role_arn: IAM role ARN - regions: list of regions to deploy the lambda function + region: AWS region + + Returns: + Lambda function ARN """ lambdas.LAMBDA_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "lambda", region) LOGGER.info(f"Deploying lambda function for {rule_name} config rule to {account_id} in {region}...") lambda_function_search = lambdas.find_lambda_function(rule_name) if lambda_function_search == "None": LOGGER.info(f"{rule_name} lambda function not found in {account_id}. Creating...") - lambda_source_zip = f"/tmp/sra_staging_upload/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" + lambda_source_zip = f"/tmp/sra_staging_upload/{SOLUTION_NAME}/rules/{rule_name}/{rule_name}.zip" # noqa: S108 LOGGER.info(f"Lambda zip file: {lambda_source_zip}") lambda_create = lambdas.create_lambda_function( lambda_source_zip, @@ -2065,7 +2188,7 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: account_id: AWS account ID rule_name: config rule name lambda_arn: lambda function ARN - regions: list of regions to deploy the config rule + region: AWS region input_params: input parameters for the config rule """ LOGGER.info(f"Deploying {rule_name} config rule to {account_id} in {region}...") @@ -2113,6 +2236,8 @@ def deploy_metric_filter( """Deploy metric filter. Args: + region: region + acct: account ID log_group_name: log group name filter_name: filter name filter_pattern: filter pattern @@ -2137,7 +2262,7 @@ def deploy_metric_filter( add_state_table_record("cloudwatch", "implemented", "log metric filter", "filter", metric_filter_arn, acct, region, filter_name) -def deploy_metric_alarm( +def deploy_metric_alarm( # noqa: CFQ002 region: str, acct: str, alarm_name: str, @@ -2205,7 +2330,20 @@ def deploy_metric_alarm( add_state_table_record("cloudwatch", "implemented", "cloudwatch metric alarm", "alarm", alarm_arn, acct, region, alarm_name) -def lambda_handler(event: dict, context: Any) -> dict: +def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001 + """Lambda handler. + + Args: + event: Lambda event + context: Lambda context + + Returns: + Lambda response + + Raises: + ValueError: If the event does not include Records or RequestType + """ + LOGGER.info("Starting Lambda function...") global RESOURCE_TYPE global LAMBDA_START global LAMBDA_FINISH From b31dffdbf2e6d354d87c87aea1107f491cf36e4e Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 07:58:56 -0700 Subject: [PATCH 301/395] fixing flake8 errors in app and cloudwatch module --- .../genai/bedrock_org/lambda/src/app.py | 2 +- .../bedrock_org/lambda/src/sra_cloudwatch.py | 221 +++++++++++++----- 2 files changed, 163 insertions(+), 60 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index e2ec7fa2b..732ed3479 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -256,7 +256,7 @@ def load_sra_cloudwatch_dashboard() -> dict: lambdas = sra_lambda.sra_lambda() sns = sra_sns.sra_sns() config = sra_config.sra_config() -cloudwatch = sra_cloudwatch.sra_cloudwatch() +cloudwatch = sra_cloudwatch.SRACloudWatch() kms = sra_kms.sra_kms() # propagate solution name to class objects diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 217f03a67..119299c95 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -1,4 +1,4 @@ -"""Custom Resource to setup SRA Config resources in the organization. +"""Lambda function module to setup SRA Cloudwatch resources in the organization. Version: 1.0 @@ -12,7 +12,6 @@ import logging import os -from time import sleep from typing import TYPE_CHECKING, Literal @@ -22,18 +21,15 @@ import json -import cfnresponse - if TYPE_CHECKING: from mypy_boto3_cloudwatch import CloudWatchClient from mypy_boto3_logs import CloudWatchLogsClient from mypy_boto3_oam import CloudWatchObservabilityAccessManagerClient - from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef - # from mypy_boto3_cloudwatch.type_defs import StatisticType # , MetricFilterTypeDef, GetMetricDataResponseTypeDef, - from mypy_boto3_logs.type_defs import FilteredLogEventTypeDef, GetLogEventsResponseTypeDef -class sra_cloudwatch: +class SRACloudWatch: + """Class to setup SRA Cloudwatch resources in the organization.""" + # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") @@ -58,22 +54,45 @@ class sra_cloudwatch: raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def find_metric_filter(self, log_group_name: str, filter_name: str) -> bool: + """Find metric filter. + + Args: + log_group_name (str): Log group name to search for metric filter + filter_name (str): Metric filter name to search for + + Raises: + ValueError: Unexpected error executing Lambda function. Review CloudWatch logs for details. + + Returns: + bool: True if metric filter is found, False if not found + """ try: response = self.CWLOGS_CLIENT.describe_metric_filters(logGroupName=log_group_name, filterNamePrefix=filter_name) if response["metricFilters"]: return True - else: - return False + return False except ClientError as error: if error.response["Error"]["Code"] == "ResourceNotFoundException": return False - else: - self.LOGGER.info(f"{self.UNEXPECTED} error finding metric filter: {error}") - raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + self.LOGGER.info(f"{self.UNEXPECTED} error finding metric filter: {error}") + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def create_metric_filter( self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str ) -> None: + """Create metric filter. + + Args: + log_group_name (str): Log group name to create metric filter + filter_name (str): Metric filter name to create + filter_pattern (str): Metric filter pattern to create + metric_name (str): Metric name to create + metric_namespace (str): Metric namespace to create + metric_value (str): Metric value to create + + Raises: + ValueError: Unexpected error executing Lambda function. Review CloudWatch logs for details. + """ try: if not self.find_metric_filter(log_group_name, filter_name): self.CWLOGS_CLIENT.put_metric_filter( @@ -95,6 +114,15 @@ def create_metric_filter( raise ValueError(f"Unexpected error executing Lambda function. {e}") from None def delete_metric_filter(self, log_group_name: str, filter_name: str) -> None: + """Delete metric filter. + + Args: + log_group_name (str): Log group name to delete metric filter + filter_name (str): Metric filter name to delete + + Raises: + ValueError: Unexpected error executing Lambda function. Review CloudWatch logs for details. + """ try: if self.find_metric_filter(log_group_name, filter_name): self.CWLOGS_CLIENT.delete_metric_filter(logGroupName=log_group_name, filterName=filter_name) @@ -105,6 +133,19 @@ def delete_metric_filter(self, log_group_name: str, filter_name: str) -> None: def update_metric_filter( self, log_group_name: str, filter_name: str, filter_pattern: str, metric_name: str, metric_namespace: str, metric_value: str ) -> None: + """Update metric filter. + + Args: + log_group_name (str): Log group name to update metric filter + filter_name (str): Metric filter name to update + filter_pattern (str): Metric filter pattern to update + metric_name (str): Metric name to update + metric_namespace (str): Metric namespace to update + metric_value (str): Metric value to update + + Raises: + ValueError: Unexpected error executing Lambda function. Review CloudWatch logs for details. + """ try: self.delete_metric_filter(log_group_name, filter_name) self.create_metric_filter(log_group_name, filter_name, filter_pattern, metric_name, metric_namespace, metric_value) @@ -113,20 +154,29 @@ def update_metric_filter( raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def find_metric_alarm(self, alarm_name: str) -> bool: + """Find metric alarm. + + Args: + alarm_name (str): Alarm name to search for + + Raises: + ValueError: Unexpected error executing Lambda function. Review CloudWatch logs for details. + + Returns: + bool: True if metric alarm is found, False if not found + """ try: response = self.CLOUDWATCH_CLIENT.describe_alarms(AlarmNames=[alarm_name]) if response["MetricAlarms"]: return True - else: - return False + return False except ClientError as error: if error.response["Error"]["Code"] == "ResourceNotFoundException": return False - else: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + self.LOGGER.info(self.UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - def create_metric_alarm( + def create_metric_alarm( # noqa: CFQ002 self, alarm_name: str, alarm_description: str, @@ -135,11 +185,30 @@ def create_metric_alarm( metric_statistic: Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum'], metric_period: int, metric_threshold: float, - metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', 'LessThanThreshold'], + metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', + 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', + 'LessThanThreshold'], metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list, ) -> None: + """Create metric alarm. + + Args: + alarm_name (str): Alarm name to create + alarm_description (str): Alarm description to create + metric_name (str): Metric name to create + metric_namespace (str): Metric namespace to create + metric_statistic (Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum']): Metric statistic to create + metric_period (int): Metric period to create + metric_threshold (float): Metric threshold to create + metric_comparison_operator (Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', + 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', + 'LessThanThreshold']): Metric comparison operator to create + metric_evaluation_periods (int): Metric evaluation periods to create + metric_treat_missing_data (str): Metric treat missing data to create + alarm_actions (list): Alarm actions to create + """ self.LOGGER.info(f"DEBUG: Alarm actions: {alarm_actions}") try: if not self.find_metric_alarm(alarm_name): @@ -160,13 +229,18 @@ def create_metric_alarm( self.LOGGER.info(f"{self.UNEXPECTED} error: {e}") def delete_metric_alarm(self, alarm_name: str) -> None: + """Delete metric alarm. + + Args: + alarm_name (str): Alarm name to delete + """ try: if self.find_metric_alarm(alarm_name): self.CLOUDWATCH_CLIENT.delete_alarms(AlarmNames=[alarm_name]) except ClientError: self.LOGGER.info(self.UNEXPECTED) - def update_metric_alarm( + def update_metric_alarm( # noqa: CFQ002 self, alarm_name: str, alarm_description: str, @@ -175,11 +249,30 @@ def update_metric_alarm( metric_statistic: Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum'], metric_period: int, metric_threshold: float, - metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', 'LessThanThreshold'], + metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', + 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', + 'LessThanOrEqualToThreshold', 'LessThanThreshold'], metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list, ) -> None: + """Update metric alarm. + + Args: + alarm_name (str): Alarm name to update + alarm_description (str): Alarm description to update + metric_name (str): Metric name to update + metric_namespace (str): Metric namespace to update + metric_statistic (Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum']): Metric statistic to update + metric_period (int): Metric period to update + metric_threshold (float): Metric threshold to update + metric_comparison_operator (Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', + 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', + 'LessThanThreshold']): Metric comparison operator to create + metric_evaluation_periods (int): Metric evaluation periods to update + metric_treat_missing_data (str): Metric treat missing data to update + alarm_actions (list): Alarm actions to update + """ try: self.delete_metric_alarm(alarm_name) self.create_metric_alarm( @@ -221,9 +314,8 @@ def find_oam_sink(self) -> tuple[bool, str, str]: if error.response["Error"]["Code"] == "ResourceNotFoundException": self.LOGGER.info(f"Observability access manager sink not found. Error code: {error.response['Error']['Code']}") return False, "", "" - else: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None + self.LOGGER.info(self.UNEXPECTED) + raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def create_oam_sink(self, sink_name: str) -> str: """Create the Observability Access Manager sink for SRA in the organization. @@ -231,6 +323,9 @@ def create_oam_sink(self, sink_name: str) -> str: Args: sink_name (str): name of the sink + Raises: + ValueError: unexpected error + Returns: str: ARN of the created sink """ @@ -242,9 +337,8 @@ def create_oam_sink(self, sink_name: str) -> str: if e.response["Error"]["Code"] == "ConflictException": self.LOGGER.info(f"Observability access manager sink {sink_name} already exists") return self.find_oam_sink()[1] - else: - self.LOGGER.error(f"{self.UNEXPECTED} error: {e}") - raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + self.LOGGER.error(f"{self.UNEXPECTED} error: {e}") + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None def find_oam_sink_policy(self, sink_arn: str) -> tuple[bool, dict]: """Check if the Observability Access Manager sink policy for SRA in the organization exists. @@ -252,6 +346,9 @@ def find_oam_sink_policy(self, sink_arn: str) -> tuple[bool, dict]: Args: sink_arn (str): ARN of the sink + Raises: + ValueError: unexpected error + Returns: tuple[bool, dict]: True if the policy is found, False if not, and the policy """ @@ -264,9 +361,8 @@ def find_oam_sink_policy(self, sink_arn: str) -> tuple[bool, dict]: if error.response["Error"]["Code"] == "ResourceNotFoundException": self.LOGGER.info(f"Observability access manager sink policy for {sink_arn} not found") return False, {} - else: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None def compare_oam_sink_policy(self, existing_policy: dict, new_policy: dict) -> bool: """Compare the existing Observability Access Manager sink policy with the new policy. @@ -281,9 +377,8 @@ def compare_oam_sink_policy(self, existing_policy: dict, new_policy: dict) -> bo if existing_policy == new_policy: self.LOGGER.info("New observability access manager sink policy is the same") return True - else: - self.LOGGER.info("New observability access manager sink policy is different") - return False + self.LOGGER.info("New observability access manager sink policy is different") + return False def put_oam_sink_policy(self, sink_arn: str, sink_policy: dict) -> None: """Put the Observability Access Manager sink policy for SRA in the organization. @@ -292,8 +387,8 @@ def put_oam_sink_policy(self, sink_arn: str, sink_policy: dict) -> None: sink_arn (str): ARN of the sink sink_policy (dict): policy for the sink - Returns: - None + Raises: + ValueError: unexpected error """ try: self.CWOAM_CLIENT.put_sink_policy(SinkIdentifier=sink_arn, Policy=json.dumps(sink_policy)) @@ -301,15 +396,15 @@ def put_oam_sink_policy(self, sink_arn: str, sink_policy: dict) -> None: except ClientError as e: self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {e}") from None - + def delete_oam_sink(self, sink_arn: str) -> None: """Delete the Observability Access Manager sink for SRA in the organization. Args: sink_arn (str): ARN of the sink - Returns: - None + Raises: + ValueError: unexpected error """ try: self.CWOAM_CLIENT.delete_sink(Identifier=sink_arn) @@ -324,6 +419,9 @@ def find_oam_link(self, sink_arn: str) -> tuple[bool, str]: Args: sink_arn (str): ARN of the sink + Raises: + ValueError: unexpected error + Returns: tuple[bool, str]: True if the link is found, False if not, and the link ARN """ @@ -339,16 +437,18 @@ def find_oam_link(self, sink_arn: str) -> tuple[bool, str]: if error.response["Error"]["Code"] == "ResourceNotFoundException": self.LOGGER.info(f"Observability access manager link for {sink_arn} not found. Error code: {error.response['Error']['Code']}") return False, "" - else: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {error}") from None - + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + def create_oam_link(self, sink_arn: str) -> str: """Create the Observability Access Manager link for SRA in the organization. Args: sink_arn (str): ARN of the sink + Raises: + ValueError: unexpected error + Returns: str: ARN of the created link """ @@ -371,18 +471,17 @@ def create_oam_link(self, sink_arn: str) -> str: if error.response["Error"]["Code"] == "ConflictException": self.LOGGER.info(f"Observability access manager link for {sink_arn} already exists") return self.find_oam_link(sink_arn)[1] - else: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {error}") from None - + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + def delete_oam_link(self, link_arn: str) -> None: """Delete the Observability Access Manager link for SRA in the organization. Args: link_arn (str): ARN of the link - Returns: - None + Raises: + ValueError: unexpected error """ try: self.CWOAM_CLIENT.delete_link(Identifier=link_arn) @@ -397,6 +496,9 @@ def find_dashboard(self, dashboard_name: str) -> tuple[bool, str]: Args: dashboard_name (str): name of the dashboard + Raises: + ValueError: unexpected error + Returns: tuple[bool, str]: True if the dashboard is found, False if not, and the dashboard ARN """ @@ -412,16 +514,18 @@ def find_dashboard(self, dashboard_name: str) -> tuple[bool, str]: if error.response["Error"]["Code"] == "ResourceNotFoundException": self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} not found. Error code: {error.response['Error']['Code']}") return False, "" - else: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None def create_dashboard(self, dashboard_name: str, dashboard_body: dict) -> str: """Create the CloudWatch dashboard for SRA in the organization. Args: dashboard_name (str): name of the dashboard - dashboard_body (str): body of the dashboard + dashboard_body (dict): body of the dashboard + + Raises: + ValueError: unexpected error Returns: str: ARN of the created dashboard @@ -439,9 +543,8 @@ def create_dashboard(self, dashboard_name: str, dashboard_body: dict) -> str: if error.response["Error"]["Code"] == "ResourceAlreadyExistsException": self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} already exists") return self.find_dashboard(dashboard_name)[1] - else: - self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {error}") from None + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None def delete_dashboard(self, dashboard_name: str) -> None: """Delete the CloudWatch dashboard for SRA in the organization. @@ -449,12 +552,12 @@ def delete_dashboard(self, dashboard_name: str) -> None: Args: dashboard_name (str): Name of the dashboard - Returns: - None + Raises: + ValueError: Unexpected error """ try: self.CLOUDWATCH_CLIENT.delete_dashboards(DashboardNames=[dashboard_name]) self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} deleted") except ClientError as e: self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {e}") from None \ No newline at end of file + raise ValueError(f"Unexpected error executing Lambda function. {e}") from None From df0920fd78e1d1460a7c6bac05a08e69f6b5ddb7 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 08:10:48 -0700 Subject: [PATCH 302/395] fix flake8 errors in config module --- .../genai/bedrock_org/lambda/src/app.py | 2 +- .../bedrock_org/lambda/src/sra_config.py | 73 +++++++++++-------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 732ed3479..f552e60c5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -255,7 +255,7 @@ def load_sra_cloudwatch_dashboard() -> dict: s3 = sra_s3.sra_s3() lambdas = sra_lambda.sra_lambda() sns = sra_sns.sra_sns() -config = sra_config.sra_config() +config = sra_config.SRAConfig() cloudwatch = sra_cloudwatch.SRACloudWatch() kms = sra_kms.sra_kms() diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 8f22fb1d1..95828de84 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -12,30 +12,25 @@ import logging import os -from time import sleep -from typing import TYPE_CHECKING, Literal, Optional -from typing import cast +from typing import TYPE_CHECKING, Literal import boto3 from botocore.config import Config from botocore.exceptions import ClientError -import urllib.parse import json -import cfnresponse if TYPE_CHECKING: - from mypy_boto3_cloudformation import CloudFormationClient from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_config import ConfigServiceClient - from mypy_boto3_config.type_defs import DescribeConfigRulesResponseTypeDef, ConfigRuleTypeDef, ScopeTypeDef - from mypy_boto3_iam.client import IAMClient - from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + from mypy_boto3_config.type_defs import DescribeConfigRulesResponseTypeDef -class sra_config: +class SRAConfig: + """Class to setup SRA Config resources in the organization.""" + # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") @@ -53,48 +48,56 @@ class sra_config: raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def get_organization_config_rules(self) -> dict: - """Get Organization Config Rules.""" + """Get Organization Config Rules. + + Returns: + dict: Organization Config Rules + """ # Get the Organization ID org_id: str = self.ORG_CLIENT.describe_organization()["Organization"]["Id"] # Get the Organization Config Rules - response = self.ORG_CLIENT.describe_organization_config_rules( # type: ignore + response = self.ORG_CLIENT.describe_organization_config_rules( # type: ignore OrganizationConfigRuleNames=["sra_config_rule"], OrganizationId=org_id, ) # Log the response - sra_config.LOGGER.info(response) + self.LOGGER.info(response) # Return the response return response def put_organization_config_rule(self) -> dict: - """Put Organization Config Rule.""" + """Put Organization Config Rule. + + Returns: + dict: Organization Config Rule + """ # Get the Organization ID org_id: str = self.ORG_CLIENT.describe_organization()["Organization"]["Id"] # Put the Organization Config Rule - response = self.ORG_CLIENT.put_organization_config_rule( # type: ignore + response = self.ORG_CLIENT.put_organization_config_rule( # type: ignore OrganizationConfigRuleName="sra_config_rule", OrganizationId=org_id, ConfigRuleName="sra_config_rule", ) # Log the response - sra_config.LOGGER.info(response) + self.LOGGER.info(response) # Return the response return response def find_config_rule(self, rule_name: str) -> tuple[bool, dict | DescribeConfigRulesResponseTypeDef]: - """Get config rule + """Get config rule. Args: rule_name (str): Config rule name Raises: - ValueError: If the config rule is not found + ValueError: Unexpected error executing Lambda function. Review CloudWatch logs for details. Returns: tuple[bool, dict | DescribeConfigRulesResponseTypeDef]: True if the config rule is found, False if not, and the response @@ -110,19 +113,27 @@ def find_config_rule(self, rule_name: str) -> tuple[bool, dict | DescribeConfigR if e.response["Error"]["Code"] == "NoSuchConfigRuleException": self.LOGGER.info(f"No such config rule: {rule_name}") return False, {} - else: - self.LOGGER.info(f"Unexpected error: {e}") - raise e - # Log the response + self.LOGGER.info(f"Unexpected error: {e}") + raise ValueError(f"Unexpected error executing Lambda function. Review CloudWatch logs for details. {e}") from None self.LOGGER.info(f"Config rule {rule_name} exists: {response}") return True, response - - def create_config_rule(self, rule_name: str, lambda_arn: str, - max_frequency: Literal["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"], - owner: Literal["CUSTOM_LAMBDA", "AWS"], description: str, input_params: dict, + def create_config_rule(self, rule_name: str, lambda_arn: str, # noqa: CFQ002 + max_frequency: Literal["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"], + owner: Literal["CUSTOM_LAMBDA", "AWS"], description: str, input_params: dict, eval_mode: Literal["DETECTIVE", "PROACTIVE"], solution_name: str) -> None: - """Create Config Rule.""" + """Create Config Rule. + + Args: + rule_name (str): Config rule name + lambda_arn (str): Lambda ARN + max_frequency (Literal["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"]): Config rule max frequency + owner (Literal["CUSTOM_LAMBDA", "AWS"]): Config rule owner + description (str): Config rule description + input_params (dict): Config rule input parameters + eval_mode (Literal["DETECTIVE", "PROACTIVE"]): Config rule evaluation mode + solution_name (str): SRA solution name + """ self.CONFIG_CLIENT.put_config_rule( ConfigRule={ "ConfigRuleName": rule_name, @@ -153,7 +164,11 @@ def create_config_rule(self, rule_name: str, lambda_arn: str, self.LOGGER.info(f"{rule_name} config rule created...") def delete_config_rule(self, rule_name: str) -> None: - """Delete Config Rule.""" + """Delete Config Rule. + + Args: + rule_name (str): Config rule name + """ # Delete the Config Rule try: self.CONFIG_CLIENT.delete_config_rule( @@ -166,4 +181,4 @@ def delete_config_rule(self, rule_name: str) -> None: if e.response["Error"]["Code"] == "NoSuchConfigRuleException": self.LOGGER.info(f"No such config rule: {rule_name}") else: - self.LOGGER.info(f"Unexpected error: {e}") \ No newline at end of file + self.LOGGER.info(f"Unexpected error: {e}") From 3adce3a74b0ab980beb066a27b705ea9222d0471 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 08:30:07 -0700 Subject: [PATCH 303/395] reverting some flake8 updates temporarily --- .../genai/bedrock_org/lambda/src/app.py | 103 ++++-------------- 1 file changed, 23 insertions(+), 80 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index f552e60c5..ec16b151d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -161,88 +161,31 @@ def load_sra_cloudwatch_dashboard() -> dict: # Parameter validation rules PARAMETER_VALIDATION_RULES: dict = { - "SRA_REPO_ZIP_URL": r"^https://.*\.zip$", - "DRY_RUN": r"^true|false$", - "EXECUTION_ROLE_NAME": r"^sra-execution$", - "LOG_GROUP_DEPLOY": r"^true|false$", - "LOG_GROUP_RETENTION": ( - r"^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$" - ), - "LOG_LEVEL": r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", - "SOLUTION_NAME": r"^sra-bedrock-org$", - "SOLUTION_VERSION": r"^[0-9]+\.[0-9]+\.[0-9]+$", - "SRA_ALARM_EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + "SRA_REPO_ZIP_URL": r'^https://.*\.zip$', + "DRY_RUN": r'^true|false$', + "EXECUTION_ROLE_NAME": r'^sra-execution$', + "LOG_GROUP_DEPLOY": r'^true|false$', + "LOG_GROUP_RETENTION": r'^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$', + "LOG_LEVEL": r'^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$', + "SOLUTION_NAME": r'^sra-bedrock-org$', + "SOLUTION_VERSION": r'^[0-9]+\.[0-9]+\.[0-9]+$', + "SRA_ALARM_EMAIL": r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', - "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"' - + r'\s*)?})\}$' - ), - "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"' - + r'\s*)?})\}$' - ), - "SRA-BEDROCK-CHECK-GUARDRAILS": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?' - + r'(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?' - + r'(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?' - + r'\s*\}\}$' - ), - "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?' - + r'(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false")' - + r')?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$' - ), - "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?' - + r'(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$' - ), - "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?' - + r'(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?' - + r'(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$' - ), - "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$' - ), - "SRA-BEDROCK-CHECK-S3-ENDPOINTS": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$' - ), - "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$' - ), - "SRA-BEDROCK-FILTER-SERVICE-CHANGES": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$' - ), - "SRA-BEDROCK-FILTER-BUCKET-CHANGES": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*' - + r'"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$' - ), - "SRA-BEDROCK-FILTER-PROMPT-INJECTION": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*' - + r'"input_path"\s*:\s*"[^"\s]+"\}\}$' - ), - "SRA-BEDROCK-FILTER-SENSITIVE-INFO": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*' - + r'"input_path"\s*:\s*"[^"\s]+"\}\}$' - ), - "SRA-BEDROCK-CENTRAL-OBSERVABILITY": ( - r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*' - + r':\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$' - ), + "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$', + "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-CHECK-S3-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-FILTER-SERVICE-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-FILTER-BUCKET-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$', + "SRA-BEDROCK-FILTER-PROMPT-INJECTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-FILTER-SENSITIVE-INFO": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-CENTRAL-OBSERVABILITY": r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$', } # Instantiate sra class objects From c2a18f8b6284b4df28cd0716723f2cf5dbb9a9dd Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 09:52:41 -0700 Subject: [PATCH 304/395] fix flake8 issues in dynamodb module --- .../genai/bedrock_org/lambda/src/app.py | 2 +- .../bedrock_org/lambda/src/sra_config.py | 2 +- .../bedrock_org/lambda/src/sra_dynamodb.py | 133 +++++++++++++++--- 3 files changed, 115 insertions(+), 22 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ec16b151d..8b1452ce5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -192,7 +192,7 @@ def load_sra_cloudwatch_dashboard() -> dict: # TODO(liamschn): can these files exist in some central location to be shared with other solutions? ssm_params = sra_ssm_params.sra_ssm_params() iam = sra_iam.sra_iam() -dynamodb = sra_dynamodb.sra_dynamodb() +dynamodb = sra_dynamodb.SRADynamoDB() sts = sra_sts.sra_sts() repo = sra_repo.sra_repo() s3 = sra_s3.sra_s3() diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 95828de84..8e98e1cba 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -1,4 +1,4 @@ -"""Custom Resource to setup SRA Config resources in the organization. +"""Lambda module to setup SRA Config resources in the organization. Version: 1.0 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index e8b5640bf..ff2e52a83 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -1,27 +1,37 @@ +"""Lambda module to setup SRA DynamoDB resources in the organization. + +Version: 1.0 + +DynamoDb module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + import logging import boto3 -from boto3.dynamodb.conditions import Key, Attr import os import random import string from datetime import datetime from time import sleep -import botocore from boto3.session import Session -from typing import TYPE_CHECKING, Any, Dict, Sequence, cast +from typing import TYPE_CHECKING, Any, Dict, Sequence if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource - from mypy_boto3_dynamodb.type_defs import UpdateItemOutputTableTypeDef, AttributeDefinitionTypeDef, DeleteItemOutputTableTypeDef, KeySchemaElementTypeDef, ProvisionedThroughputTypeDef + from mypy_boto3_dynamodb.type_defs import AttributeDefinitionTypeDef, KeySchemaElementTypeDef, ProvisionedThroughputTypeDef -class sra_dynamodb: +class SRADynamoDB: + """Class for DynamoDB functions for SRA.""" + PROFILE = "default" UNEXPECTED = "Unexpected!" - LOGGER = logging.getLogger(__name__) + LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") - LOGGER.setLevel(log_level) + LOGGER.setLevel(log_level) try: MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() @@ -37,8 +47,15 @@ class sra_dynamodb: LOGGER.info(f"Error creating boto3 dymanodb resource and/or client: {error}") raise ValueError(f"Error creating boto3 dymanodb resource and/or client: {error}") from None + def __init__(self, profile: str = "default") -> None: + """Initialize class object. + + Args: + profile (str): AWS profile name. Defaults to "default". - def __init__(self, profile: str="default") -> None: + Raises: + ValueError: Unexpected error executing Lambda function. Review CloudWatch logs for details. + """ self.PROFILE = profile try: if self.PROFILE != "default": @@ -53,6 +70,11 @@ def __init__(self, profile: str="default") -> None: raise ValueError("Unexpected error!") from None def create_table(self, table_name: str) -> None: + """Create DynamoDB table. + + Args: + table_name (str): DynamoDB table name + """ # Define table schema key_schema: Sequence[KeySchemaElementTypeDef] = [ {"AttributeName": "solution_name", "KeyType": "HASH"}, @@ -84,6 +106,14 @@ def create_table(self, table_name: str) -> None: sleep(5) def table_exists(self, table_name: str) -> bool: + """Check if DynamoDB table exists. + + Args: + table_name (str): DynamoDB table name + + Returns: + bool: True if table exists, False if not + """ # Check if table exists try: self.DYNAMODB_CLIENT.describe_table(TableName=table_name) @@ -94,18 +124,44 @@ def table_exists(self, table_name: str) -> bool: return False def generate_id(self) -> str: - new_record_id = str("".join(random.choice(string.ascii_letters + string.digits + "-_") for ch in range(8))) - return new_record_id + """Generate a random string of 8 characters. + + Args: + None + + Returns: + str: random string of 8 characters + """ + return str("".join(random.choice(string.ascii_letters + string.digits + "-_") for ch in range(8))) # noqa: S311, DUO102 def get_date_time(self) -> str: + """Get current date and time. + + Args: + None + + Returns: + str: current date and time in format YYYYMMDDHHMMSS + """ now = datetime.now() return now.strftime("%Y%m%d%H%M%S") def insert_item(self, table_name: str, solution_name: str) -> tuple[str, str]: + """Insert an item into the dynamodb table. + + Args: + table_name: dynamodb table name + solution_name: solution name + + Returns: + record_id: record id + date_time: date time + """ + self.LOGGER.info(f"Inserting {solution_name} into {table_name} dynamodb table") table = self.DYNAMODB_RESOURCE.Table(table_name) record_id = self.generate_id() date_time = self.get_date_time() - response = table.put_item( + table.put_item( Item={ "solution_name": solution_name, "record_id": record_id, @@ -115,6 +171,17 @@ def insert_item(self, table_name: str, solution_name: str) -> tuple[str, str]: return record_id, date_time def update_item(self, table_name: str, solution_name: str, record_id: str, attributes_and_values: dict) -> Any: + """Update an item in the dynamodb table. + + Args: + table_name: dynamodb table name + solution_name: solution name + record_id: record id + attributes_and_values: attributes and values to update + + Returns: + dynamodb response + """ self.LOGGER.info(f"Updating {table_name} dynamodb table with {attributes_and_values}") table = self.DYNAMODB_RESOURCE.Table(table_name) update_expression = "" @@ -125,7 +192,7 @@ def update_item(self, table_name: str, solution_name: str, record_id: str, attri else: update_expression = update_expression + ", " + attribute + "=:" + attribute expression_attribute_values[":" + attribute] = attributes_and_values[attribute] - response = table.update_item( + return table.update_item( Key={ "solution_name": solution_name, "record_id": record_id, @@ -134,14 +201,12 @@ def update_item(self, table_name: str, solution_name: str, record_id: str, attri ExpressionAttributeValues=expression_attribute_values, ReturnValues="UPDATED_NEW", ) - return response def find_item(self, table_name: str, solution_name: str, additional_attributes: dict) -> tuple[bool, dict]: """Find an item in the dynamodb table based on the solution name and additional attributes. Args: table_name: dynamodb table name - dynamodb_resource: dynamodb resource solution_name: solution name additional_attributes: additional attributes to search for @@ -168,7 +233,8 @@ def find_item(self, table_name: str, solution_name: str, additional_attributes: if len(response["Items"]) > 1: self.LOGGER.info( - f"Found more than one record that matched solution name {solution_name}: {additional_attributes} Review {table_name} dynamodb table to determine cause." + f"Found more than one record that matched solution name {solution_name}: {additional_attributes}." + + f"Review {table_name} dynamodb table to determine cause." ) elif len(response["Items"]) < 1: return False, {} @@ -176,6 +242,15 @@ def find_item(self, table_name: str, solution_name: str, additional_attributes: return True, response["Items"][0] def get_unique_values_from_list(self, list_of_values: list) -> list: + """Get unique values from a list. + + Args: + list_of_values: list of values + + Returns: + list of unique values + """ + self.LOGGER.info(f"Getting unique values from {list_of_values}") unique_values = [] for value in list_of_values: if value not in unique_values: @@ -183,6 +258,15 @@ def get_unique_values_from_list(self, list_of_values: list) -> list: return unique_values def get_distinct_solutions_and_accounts(self, table_name: str) -> tuple[list, list]: + """Get distinct solutions and accounts from the dynamodb table. + + Args: + table_name: dynamodb table name + + Returns: + list of distinct solutions and accounts + """ + self.LOGGER.info(f"Getting distinct solutions and accounts from {table_name} dynamodb table") table = self.DYNAMODB_RESOURCE.Table(table_name) response = table.scan() solution_names = [item["solution_name"] for item in response["Items"]] @@ -192,6 +276,17 @@ def get_distinct_solutions_and_accounts(self, table_name: str) -> tuple[list, li return solution_names, accounts def get_resources_for_solutions_by_account(self, table_name: str, solutions: list, account: str) -> dict: + """Get resources for solutions by account from the dynamodb table. + + Args: + table_name: dynamodb table name + solutions: list of solutions + account: account id + + Returns: + dict of resources for solutions by account + """ + self.LOGGER.info(f"Getting resources for solutions by account from {table_name} dynamodb table") table = self.DYNAMODB_RESOURCE.Table(table_name) query_results = {} for solution in solutions: @@ -206,18 +301,16 @@ def get_resources_for_solutions_by_account(self, table_name: str, solutions: lis return query_results def delete_item(self, table_name: str, solution_name: str, record_id: str) -> Any: - """Delete an item from the dynamodb table + """Delete an item from the dynamodb table. Args: table_name (str): dynamodb table name - dynamodb_resource (dynamodb_resource): dynamodb resource solution_name (str): solution name record_id (str): record id Returns: - response: response from dynamodb delete_item + response from dynamodb delete_item """ self.LOGGER.info(f"Deleting {record_id} from {table_name} dynamodb table") table = self.DYNAMODB_RESOURCE.Table(table_name) - response = table.delete_item(Key={"solution_name": solution_name, "record_id": record_id}) - return response \ No newline at end of file + return table.delete_item(Key={"solution_name": solution_name, "record_id": record_id}) From 46ccafc34fd15e232e24cc81ec32a0b9d040145e Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 11:29:52 -0700 Subject: [PATCH 305/395] fixing flake8 issues in iam module --- .../genai/bedrock_org/lambda/src/app.py | 5 +- .../genai/bedrock_org/lambda/src/sra_iam.py | 277 ++++-------------- 2 files changed, 60 insertions(+), 222 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 8b1452ce5..a7a230146 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -126,6 +126,7 @@ def load_sra_cloudwatch_dashboard() -> dict: SRA_ALARM_EMAIL: str = "" SRA_ALARM_TOPIC_ARN: str = "" STATE_TABLE: str = "sra_state" # for saving resource info +CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" LAMBDA_RECORD_ID: str = "" LAMBDA_START: str = "" @@ -191,7 +192,7 @@ def load_sra_cloudwatch_dashboard() -> dict: # Instantiate sra class objects # TODO(liamschn): can these files exist in some central location to be shared with other solutions? ssm_params = sra_ssm_params.sra_ssm_params() -iam = sra_iam.sra_iam() +iam = sra_iam.SRAIAM() dynamodb = sra_dynamodb.SRADynamoDB() sts = sra_sts.sra_sts() repo = sra_repo.sra_repo() @@ -1434,7 +1435,7 @@ def create_event(event: dict, context: Any) -> str: f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") LOGGER.info(f"Dry run data file uploaded to s3://{s3.STAGING_BUCKET}/dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") - if RESOURCE_TYPE == iam.CFN_CUSTOM_RESOURCE: + if RESOURCE_TYPE == CFN_CUSTOM_RESOURCE: LOGGER.info("Resource type is a custom resource") cfnresponse.send(event, context, cfnresponse.SUCCESS, CFN_RESPONSE_DATA, CFN_RESOURCE_ID) else: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 1697ba579..843e4d5cc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -1,4 +1,4 @@ -"""Custom Resource to setup SRA IAM resources in the management account. +"""Lambda module to setup SRA IAM resources in the management account. Version: 1.0 @@ -23,8 +23,6 @@ import urllib.parse import json -# import cfnresponse - if TYPE_CHECKING: from mypy_boto3_cloudformation import CloudFormationClient from mypy_boto3_organizations import OrganizationsClient @@ -32,7 +30,9 @@ from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef -class sra_iam: +class SRAIAM: + """Class to setup SRA IAM resources in the management account.""" + # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") @@ -41,9 +41,6 @@ class sra_iam: # Global Variables UNEXPECTED = "Unexpected!" BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) - CFN_CUSTOM_RESOURCE: str = "Custom::LambdaCustomResource" - SRA_EXECUTION_ROLE: str = "sra-execution" # todo(liamschn): parameterize this role name - # SRA_EXECUTION_ROLE_STACKSET_ID: str = "" try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() @@ -61,27 +58,14 @@ class sra_iam: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - # SRA_EXECUTION_TRUST: dict = { - # "Version": "2012-10-17", - # "Statement": [ - # {"Effect": "Allow", "Principal": {"AWS": "arn:" + PARTITION + ":iam::" + MANAGEMENT_ACCOUNT + ":root"}, "Action": "sts:AssumeRole"} - # ], - # } - - # SRA_STACKSET_POLICY: dict = { - # "Version": "2012-10-17", - # "Statement": [ - # {"Action": "sts:AssumeRole", "Resource": "arn:aws:iam::*:role/" + SRA_EXECUTION_ROLE, "Effect": "Allow", "Sid": "AssumeExecutionRole"} - # ], - # } - SRA_POLICY_DOCUMENTS: dict = { "sra-lambda-basic-execution": { "Version": "2012-10-17", "Statement": [ - {"Sid":"CreateLogGroup", "Effect": "Allow", "Action": "logs:CreateLogGroup", "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:*"}, + {"Sid": "CreateLogGroup", "Effect": "Allow", "Action": "logs:CreateLogGroup", + "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:*"}, { - "Sid":"CreateStreamPutEvents", "Effect": "Allow", + "Sid": "CreateStreamPutEvents", "Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:log-group:/aws/lambda/CONFIG_RULE_NAME:*", }, @@ -89,12 +73,6 @@ class sra_iam: }, } - # # TODO(liamschn): move stackset trust document to SRA_TRUST_DOCUMENTS variable - # SRA_STACKSET_TRUST: dict = { - # "Version": "2012-10-17", - # "Statement": [{"Effect": "Allow", "Principal": {"Service": "cloudformation.amazonaws.com"}, "Action": "sts:AssumeRole"}], - # } - SRA_TRUST_DOCUMENTS: dict = { "sra-config-rule": { "Version": "2012-10-17", @@ -112,197 +90,46 @@ class sra_iam: }, } - # Configuration - # TODO(liamschn): move CFN params to cfn module - # CFN_CAPABILITIES = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"] - # CFN_PARAMETERS = [ - # {"ParameterKey": "pManagementAccountId", "ParameterValue": MANAGEMENT_ACCOUNT}, - # # Add more parameters as needed - # ] - # ACCOUNT_IDS: list = [] # Will be filled with accounts in the root OU - # ROOT_OU: str = "" - # REGION_NAMES = ["us-east-1"] # only global region for iam - - # # Organization service functions - # def get_accounts_in_root_ou(self): - # self.ACCOUNT_IDS = [] - # self.ROOT_OU = self.ORG_CLIENT.list_roots()["Roots"][0]["Id"] - # # root_ous = self.ORG_CLIENT.list_roots()["Roots"] - # # for root_ou in root_ous: - # # paginator = self.ORG_CLIENT.get_paginator("list_accounts_for_parent") - # # for page in paginator.paginate(ParentId=root_ou["Id"]): - # # for account in page["Accounts"]: - # # self.ACCOUNT_IDS.append(account["Id"]) - # for account in self.ORG_CLIENT.list_accounts()["Accounts"]: - # if account["Status"] == "ACTIVE": - # self.ACCOUNT_IDS.append(account["Id"]) - - # CloudFormation service functions - # TODO(liamschn): Move cloudformation functions into its own class module - # def create_stack(self, parameters, capabilities, template_url, stack_name): - # # todo(liamschn): instead of building via stack, build in python boto3 (both admin and execution roles) - # response = self.CFN_CLIENT.create_stack( - # StackName=stack_name, - # TemplateURL=template_url, - # Parameters=parameters, - # Capabilities=capabilities, - # ) - # self.LOGGER.info(f"Stack {stack_name} creation initiated.") - # return response - - # def create_stack_set(self, parameters, capabilities, template_url, stack_set_name): - # response = self.CFN_CLIENT.create_stack_set( - # StackSetName=stack_set_name, - # TemplateURL=template_url, - # Parameters=parameters, - # Capabilities=capabilities, - # PermissionModel="SERVICE_MANAGED", - # AutoDeployment={"Enabled": True, "RetainStacksOnAccountRemoval": False}, - # ) - # self.LOGGER.info(f"StackSet {stack_set_name} creation initiated.") - # return response - - # def create_stack_instances(self, root_ou_id, stack_set_name): - # response = self.CFN_CLIENT.create_stack_instances( - # StackSetName=stack_set_name, - # DeploymentTargets={"OrganizationalUnitIds": [root_ou_id]}, - # Regions=self.REGION_NAMES, - # OperationPreferences={ - # "FailureToleranceCount": 0, - # "MaxConcurrentCount": 1, - # }, - # ) - # self.LOGGER.info(f"Stack instances creation initiated for regions: {self.REGION_NAMES}.") - # return response - - # def list_stack_instances(self, stack_set_name): - # response = self.CFN_CLIENT.list_stack_instances( - # StackSetName=stack_set_name, - # ) - # return response - - # def check_for_stack_set(self, stack_set_name) -> bool: - # try: - # response = self.CFN_CLIENT.describe_stack_set(StackSetName=stack_set_name) - # self.SRA_EXECUTION_ROLE_STACKSET_ID = response["StackSet"]["StackSetId"] - # return True - # except self.CFN_CLIENT.exceptions.StackSetNotFoundException as error: - # self.LOGGER.info(f"CloudFormation StackSet: {stack_set_name} not found.") - # return False - - # def wait_for_stack_instances(self, stack_set_name, retries: int = 30): # todo(liamschn): parameterize retries - # self.LOGGER.info(f"Waiting for stack instances to complete for {stack_set_name} stackset...") - # self.LOGGER.info({"Accounts": self.ACCOUNT_IDS}) - # found_accounts = [] - # while True: - # self.LOGGER.info("Getting stack instances...") - # paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") - # found_all_accounts = True - # response_iterator = paginator.paginate( - # StackSetName=stack_set_name, - # ) - # for page in response_iterator: - # self.LOGGER.info("Iterating through stack instances...") - # for instance in page["Summaries"]: - # if instance["Account"] in found_accounts: - # continue - # else: - # found_accounts.append(instance["Account"]) - # for account in self.ACCOUNT_IDS: - # self.LOGGER.info("Checking for stack instance for all member accounts...") - # if account != self.MANAGEMENT_ACCOUNT: - # self.LOGGER.info(f"Checking for stack instance for {account} account...") - # if account in found_accounts: - # self.LOGGER.info(f"Stack instance for {account} account found.") - # else: - # self.LOGGER.info(f"Stack instance for {account} account not found.") - # found_all_accounts = False - # if found_all_accounts is True: - # break - # else: - # self.LOGGER.info("All accounts not found. Waiting 10 seconds before retrying...") - # # TODO(liamschn): need to add a maximum retry mechanism here - # sleep(10) - # ready = False - # i = 0 - # while ready is False: - # ready = True - # paginator = self.CFN_CLIENT.get_paginator("list_stack_instances") - # response_iterator = paginator.paginate( - # StackSetName=stack_set_name, - # ) - # for page in response_iterator: - # for instance in page["Summaries"]: - # if instance["StackInstanceStatus"]["DetailedStatus"] != "SUCCEEDED": - # self.LOGGER.info(f"Stack instance in {instance['Account']} shows {instance['StackInstanceStatus']['DetailedStatus']}") - # ready = False - # i += 1 - # if i > retries: - # self.LOGGER.info("Timed out! Please check cloudformation stackset and try again.") - # raise Exception("Timed out waiting for stackset!") - # if ready is False: - # self.LOGGER.info("Waiting 10 seconds before retrying...") - # sleep(10) - # return - - # IAM service functions def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> CreateRoleResponseTypeDef: """Create IAM role. Args: - session: boto3 session used by boto3 API calls role_name: Name of the role to be created trust_policy: Trust policy relationship for the role + solution_name: Name of the solution to be created Returns: Dictionary output of a successful CreateRole request """ self.LOGGER.info("Creating role %s.", role_name) - return self.IAM_CLIENT.create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", "Value": solution_name}]) + return self.IAM_CLIENT.create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", + "Value": solution_name}]) def create_policy(self, policy_name: str, policy_document: dict, solution_name: str) -> CreatePolicyResponseTypeDef: """Create IAM policy. Args: - session: boto3 session used by boto3 API calls policy_name: Name of the policy to be created policy_document: IAM policy document for the role + solution_name: Name of the solution to be created Returns: Dictionary output of a successful CreatePolicy request """ self.LOGGER.info(f"Creating {policy_name} IAM policy") - return self.IAM_CLIENT.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", "Value": solution_name}]) - - # def attach_policy(self, role_name: str, policy_name: str, policy_document: str) -> EmptyResponseMetadataTypeDef: - # """Attach policy to IAM role. - - # Args: - # session: boto3 session used by boto3 API calls - # role_name: Name of the role for policy to be attached to - # policy_name: Name of the policy to be attached - # policy_document: IAM policy document to be attached - - # Returns: - # Empty response metadata - # """ - - # self.LOGGER.info("Attaching policy to %s.", role_name) - # return self.IAM_CLIENT.put_role_policy(RoleName=role_name, PolicyName=policy_name, PolicyDocument=policy_document) + return self.IAM_CLIENT.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", + "Value": solution_name}]) def attach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadataTypeDef: """Attach policy to IAM role. Args: - session: boto3 session used by boto3 API calls role_name: Name of the role for policy to be attached to - policy_name: Name of the policy to be attached - policy_document: IAM policy document to be attached + policy_arn: The Amazon Resource Name (ARN) of the policy to be attached Returns: Empty response metadata """ - self.LOGGER.info("Attaching policy to %s.", role_name) return self.IAM_CLIENT.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn) @@ -310,9 +137,8 @@ def detach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadat """Detach IAM policy. Args: - session: boto3 session used by boto3 API calls role_name: Name of the role for which the policy is removed from - policy_name: Name of the policy to be removed (detached) + policy_arn: The Amazon Resource Name (ARN) of the policy to be detached Returns: Empty response metadata @@ -324,7 +150,6 @@ def delete_policy(self, policy_arn: str) -> EmptyResponseMetadataTypeDef: """Delete IAM Policy. Args: - session: boto3 session used by boto3 API calls policy_arn: The Amazon Resource Name (ARN) of the policy to be deleted Returns: @@ -347,7 +172,6 @@ def delete_role(self, role_name: str) -> EmptyResponseMetadataTypeDef: """Delete IAM role. Args: - session: boto3 session used by boto3 API calls role_name: Name of the role to be deleted Returns: @@ -357,14 +181,16 @@ def delete_role(self, role_name: str) -> EmptyResponseMetadataTypeDef: return self.IAM_CLIENT.delete_role(RoleName=role_name) def check_iam_role_exists(self, role_name: str) -> tuple[bool, str | None]: - """ - Checks if an IAM role exists. + """Check if an IAM role exists. - Parameters: - - role_name (str): The name of the IAM role to check. + Args: + role_name: Name of the role to check + + Raises: + ValueError: If an unexpected error occurs during the operation. Returns: - bool: True if the role exists, False otherwise. + Tuple of boolean and role ARN if the role exists, otherwise False and None. """ try: response = self.IAM_CLIENT.get_role(RoleName=role_name) @@ -374,18 +200,19 @@ def check_iam_role_exists(self, role_name: str) -> tuple[bool, str | None]: if error.response["Error"]["Code"] == "NoSuchEntity": self.LOGGER.info(f"The role '{role_name}' does not exist.") return False, None - else: - raise ValueError(f"Error performing get_role operation: {error}") from None + raise ValueError(f"Error performing get_role operation: {error}") from None def check_iam_policy_exists(self, policy_arn: str) -> bool: - """ - Checks if an IAM policy exists. + """Check if an IAM policy exists. - Parameters: - - policy_arn (str): The Amazon Resource Name (ARN) of the IAM policy to check. + Args: + policy_arn: The Amazon Resource Name (ARN) of the policy to check. + + Raises: + ValueError: If an unexpected error occurs during the operation. Returns: - bool: True if the policy exists, False otherwise. + bool: True if the policy exists, False otherwise. """ self.LOGGER.info(f"Checking if policy '{policy_arn}' exists.") try: @@ -398,19 +225,20 @@ def check_iam_policy_exists(self, policy_arn: str) -> bool: if error.response["Error"]["Code"] == "NoSuchEntity": self.LOGGER.info(f"The policy '{policy_arn}' does not exist.") return False - else: - raise ValueError(f"Unexpected error: {error}") from None + raise ValueError(f"Unexpected error: {error}") from None def check_iam_policy_attached(self, role_name: str, policy_arn: str) -> bool: - """ - Checks if an IAM policy is attached to an IAM role. + """Check if an IAM policy is attached to an IAM role. - Parameters: - - role_name (str): The name of the IAM role. - - policy_arn (str): The Amazon Resource Name (ARN) of the IAM policy. + Args: + role_name (str): The name of the IAM role. + policy_arn (str): The ARN of the IAM policy. + + Raises: + ValueError: If an unexpected error occurs during the operation. Returns: - bool: True if the policy is attached, False otherwise. + bool: True if the policy is attached to the role, False otherwise. """ try: response = self.IAM_CLIENT.list_attached_role_policies(RoleName=role_name) @@ -425,19 +253,20 @@ def check_iam_policy_attached(self, role_name: str, policy_arn: str) -> bool: if error.response["Error"]["Code"] == "NoSuchEntity": self.LOGGER.info(f"The role '{role_name}' does not exist.") return False - else: - self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") - raise ValueError(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") from None + self.LOGGER.error(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") + raise ValueError(f"Error checking if policy '{policy_arn}' is attached to role '{role_name}': {error}") from None def list_attached_iam_policies(self, role_name: str) -> list: - """ - Lists all IAM policies attached to an IAM role. + """List all IAM policies attached to an IAM role. - Parameters: - - role_name (str): The name of the IAM role. + Args: + role_name (str): The name of the IAM role. + + Raises: + ValueError: If an unexpected error occurs during the operation. Returns: - list: A list of dictionaries containing information about the attached policies. + list: List of attached IAM policies """ try: response = self.IAM_CLIENT.list_attached_role_policies(RoleName=role_name) @@ -452,9 +281,17 @@ def list_attached_iam_policies(self, role_name: str) -> list: raise ValueError(f"Error listing attached policies for role '{role_name}': {error}") from None def get_iam_global_region(self) -> str: + """Get the region name for the global region. + + Args: + None + + Returns: + str: The region name for the global region + """ partition_to_region = { 'aws': 'us-east-1', 'aws-cn': 'cn-north-1', 'aws-us-gov': 'us-gov-west-1' } - return partition_to_region.get(self.PARTITION, 'us-east-1') # Default to us-east-1 if partition is unknown \ No newline at end of file + return partition_to_region.get(self.PARTITION, 'us-east-1') # Default to us-east-1 if partition is unknown From 818bd5adaa215364e9263b7b562dbdd77066c2bf Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 12:17:16 -0700 Subject: [PATCH 306/395] fix flake8 issues in kms module --- .../genai/bedrock_org/lambda/src/app.py | 2 +- .../genai/bedrock_org/lambda/src/sra_kms.py | 74 ++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a7a230146..5426a5e99 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -201,7 +201,7 @@ def load_sra_cloudwatch_dashboard() -> dict: sns = sra_sns.sra_sns() config = sra_config.SRAConfig() cloudwatch = sra_cloudwatch.SRACloudWatch() -kms = sra_kms.sra_kms() +kms = sra_kms.SRAKMS() # propagate solution name to class objects cloudwatch.SOLUTION_NAME = SOLUTION_NAME diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index f8b6fa0a1..bb786412e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -1,4 +1,4 @@ -"""Custom Resource to setup SRA IAM resources in the management account. +"""Lambda module to setup SRA KMS resources in the management account. Version: 1.0 @@ -29,7 +29,9 @@ import json -class sra_kms: +class SRAKMS: + """Class to represent SRA KMS resources.""" + # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") @@ -55,12 +57,12 @@ class sra_kms: raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None def create_kms_key(self, kms_client: KMSClient, key_policy: str, description: str = "Key description") -> str: - """Create KMS key + """Create KMS key. Args: kms_client (KMSClient): KMS boto3 client - key_policy (dict): key policy - description (str, optional): Description of KMS key. Defaults to "Key description". + key_policy (str): key policy + description (str): Description of KMS key. Defaults to "Key description". Returns: str: KMS key id @@ -75,18 +77,47 @@ def create_kms_key(self, kms_client: KMSClient, key_policy: str, description: st return key_response["KeyMetadata"]["KeyId"] def create_alias(self, kms_client: KMSClient, alias_name: str, target_key_id: str) -> None: + """Create KMS alias. + + Args: + kms_client (KMSClient): KMS boto3 client + alias_name (str): KMS alias name + target_key_id (str): KMS key id + """ self.LOGGER.info(f"Create KMS alias: {alias_name}") kms_client.create_alias(AliasName=alias_name, TargetKeyId=target_key_id) def delete_alias(self, kms_client: KMSClient, alias_name: str) -> None: + """Delete KMS alias. + + Args: + kms_client (KMSClient): KMS boto3 client + alias_name (str): KMS alias name + """ self.LOGGER.info(f"Delete KMS alias: {alias_name}") kms_client.delete_alias(AliasName=alias_name) def schedule_key_deletion(self, kms_client: KMSClient, key_id: str, pending_window_in_days: int = 30) -> None: + """Schedule KMS key deletion. + + Args: + kms_client (KMSClient): KMS boto3 client + key_id (str): KMS key id + pending_window_in_days (int): Number of days to wait before deleting the key. Defaults to 30. + """ self.LOGGER.info(f"Schedule deletion of key: {key_id} in {pending_window_in_days} days") kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=pending_window_in_days) def search_key_policies(self, kms_client: KMSClient, key_policy: str) -> tuple[bool, str]: + """Search KMS keys for a specific policy. + + Args: + kms_client (KMSClient): KMS boto3 client + key_policy (str): key policy + + Returns: + tuple[bool, str]: True if policy is found, False if not found + """ for key in self.list_all_keys(kms_client): self.LOGGER.info(f"Examining state of key: {key['KeyId']}") if kms_client.describe_key(KeyId=key["KeyId"])["KeyMetadata"]["KeyState"] != "Enabled": @@ -103,20 +134,45 @@ def search_key_policies(self, kms_client: KMSClient, key_policy: str) -> tuple[b self.LOGGER.info(f"Key policy match found for key {key['KeyId']} policy {policy}: {policy_body}") self.LOGGER.info(f"Attempted to match to: {expected_key_policy}") return True, key["KeyId"] - else: - self.LOGGER.info(f"No key policy match found for key {key['KeyId']} policy {policy}: {policy_body}") - self.LOGGER.info(f"Attempted to match to: {expected_key_policy}") + self.LOGGER.info(f"No key policy match found for key {key['KeyId']} policy {policy}: {policy_body}") + self.LOGGER.info(f"Attempted to match to: {expected_key_policy}") return False, "None" def list_key_policies(self, kms_client: KMSClient, key_id: str) -> list: + """List KMS key policies. + + Args: + kms_client (KMSClient): KMS boto3 client + key_id (str): KMS key id + + Returns: + list: list of KMS key policies + """ response = kms_client.list_key_policies(KeyId=key_id) return response["PolicyNames"] def list_all_keys(self, kms_client: KMSClient) -> list: + """List all KMS keys. + + Args: + kms_client (KMSClient): KMS boto3 client + + Returns: + list: list of KMS keys + """ response = kms_client.list_keys() return response["Keys"] def check_key_exists(self, kms_client: KMSClient, key_id: str) -> tuple[bool, DescribeKeyResponseTypeDef]: + """Check if a KMS key exists. + + Args: + kms_client (KMSClient): KMS boto3 client + key_id (str): KMS key id + + Returns: + tuple[bool, DescribeKeyResponseTypeDef]: True if key exists, False otherwise, and key description + """ try: response: DescribeKeyResponseTypeDef = kms_client.describe_key(KeyId=key_id) return True, response @@ -127,7 +183,7 @@ def check_alias_exists(self, kms_client: KMSClient, alias_name: str) -> tuple[bo """Check if an alias exists in KMS. Args: - kms_client (kms_client): KMS boto3 client + kms_client (KMSClient): KMS boto3 client alias_name (str): alias name to check for Returns: From f83042ef4fcb630c3e247c925ecf14e8249462a6 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 14:32:50 -0700 Subject: [PATCH 307/395] fixes for flake8 in lambda module --- .../genai/bedrock_org/lambda/src/app.py | 2 +- .../bedrock_org/lambda/src/sra_lambda.py | 130 +++++++++++++----- 2 files changed, 96 insertions(+), 36 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 5426a5e99..358524587 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -197,7 +197,7 @@ def load_sra_cloudwatch_dashboard() -> dict: sts = sra_sts.sra_sts() repo = sra_repo.sra_repo() s3 = sra_s3.sra_s3() -lambdas = sra_lambda.sra_lambda() +lambdas = sra_lambda.SRALambda() sns = sra_sns.sra_sns() config = sra_config.SRAConfig() cloudwatch = sra_cloudwatch.SRACloudWatch() diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index c5b43cd83..3c60642ce 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -14,7 +14,7 @@ import os from time import sleep -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import boto3 from botocore.config import Config @@ -24,7 +24,9 @@ from mypy_boto3_lambda.client import LambdaClient -class sra_lambda: +class SRALambda: + """Class to setup SRA Lambda resources in the organization.""" + # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") @@ -42,7 +44,7 @@ class sra_lambda: def find_lambda_function(self, function_name: str) -> str: """Find Lambda Function. - + Args: function_name: Lambda function name @@ -55,12 +57,29 @@ def find_lambda_function(self, function_name: str) -> str: except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": return "None" - else: - self.LOGGER.error(f"Error encountered searching for lambda function: {e}") - return "None" + self.LOGGER.error(f"Error encountered searching for lambda function: {e}") + return "None" + + def create_lambda_function(self, code_zip_file: str, role_arn: str, function_name: str, handler: str, runtime: str, # noqa: CFQ002, CCR001 + timeout: int, memory_size: int, solution_name: str) -> str: + """Create Lambda Function. + + Args: + code_zip_file: Lambda function code zip file + role_arn: Lambda function role arn + function_name: Lambda function name + handler: Lambda function handler + runtime: Lambda function runtime + timeout: Lambda function timeout + memory_size: Lambda function memory size + solution_name: SRA solution name + + Raises: + ValueError: Unexpected error executing Lambda function - def create_lambda_function(self, code_zip_file: str, role_arn: str, function_name: str, handler: str, runtime: str, timeout: int, memory_size: int, solution_name: str) -> str: - """Create Lambda Function.""" + Returns: + Lambda function arn if created, else "None" + """ self.LOGGER.info(f"Role ARN passed to create_lambda_function: {role_arn}...") max_retries = 10 retries = 0 @@ -70,10 +89,10 @@ def create_lambda_function(self, code_zip_file: str, role_arn: str, function_nam try: create_response = self.LAMBDA_CLIENT.create_function( FunctionName=function_name, - Runtime=runtime, + Runtime=runtime, # type: ignore Handler=handler, Role=role_arn, - Code={"ZipFile": open(code_zip_file, "rb").read()}, + Code={"ZipFile": open(code_zip_file, "rb").read()}, # noqa: SIM115 Timeout=timeout, MemorySize=memory_size, Tags={"sra-solution": solution_name}, @@ -86,7 +105,7 @@ def create_lambda_function(self, code_zip_file: str, role_arn: str, function_nam self.LOGGER.info(f"{function_name} function already exists. Updating...") update_response = self.LAMBDA_CLIENT.update_function_code( FunctionName=function_name, - ZipFile=open(code_zip_file, "rb").read(), + ZipFile=open(code_zip_file, "rb").read(), # noqa: SIM115 ) self.LOGGER.info(f"Lambda function code updated successfully: {update_response}") break @@ -123,19 +142,36 @@ def create_lambda_function(self, code_zip_file: str, role_arn: str, function_nam return get_response["Configuration"]["FunctionArn"] def get_permissions(self, function_name: str) -> str: - """Get Lambda Function Permissions.""" + """Get Lambda Function Permissions. + + Args: + function_name: Lambda function name + + Returns: + Lambda function permissions if found, else "None" + """ try: response = self.LAMBDA_CLIENT.get_policy(FunctionName=function_name) return response["Policy"] except ClientError as e: if e.response["Error"]["Code"] == "ResourceNotFoundException": return "None" - else: - self.LOGGER.error(e) - return "None" + self.LOGGER.error(e) + return "None" def put_permissions(self, function_name: str, statement_id: str, principal: str, action: str, source_arn: str) -> str: - """Put Lambda Function Permissions.""" + """Put Lambda Function Permissions. + + Args: + function_name (str): Lambda Function Name + statement_id (str): Statement ID + principal (str): Principal + action (str): Action + source_arn (str): Source ARN + + Returns: + str: Lambda function permissions if found, else "None" + """ try: response = self.LAMBDA_CLIENT.add_permission( FunctionName=function_name, @@ -150,12 +186,22 @@ def put_permissions(self, function_name: str, statement_id: str, principal: str, # TODO(liamschn): consider updating the permission here self.LOGGER.info(f"{function_name} permission already exists.") return "None" - else: - self.LOGGER.info(f"Error adding lambda permission: {e}") + self.LOGGER.info(f"Error adding lambda permission: {e}") return "None" def put_permissions_acct(self, function_name: str, statement_id: str, principal: str, action: str, source_acct: str) -> str: - """Put Lambda Function Permissions.""" + """Put Lambda Function Permissions. + + Args: + function_name (str): Lambda Function Name + statement_id (str): Statement ID + principal (str): Principal + action (str): Action + source_acct (str): Source Account + + Returns: + str: Lambda function permissions if found, else "None" + """ try: response = self.LAMBDA_CLIENT.add_permission( FunctionName=function_name, @@ -170,7 +216,12 @@ def put_permissions_acct(self, function_name: str, statement_id: str, principal: return "None" def remove_permissions(self, function_name: str, statement_id: str) -> None: - """Remove Lambda Function Permissions.""" + """Remove Lambda Function Permissions. + + Args: + function_name (str): Lambda Function Name + statement_id (str): Statement ID + """ try: self.LAMBDA_CLIENT.remove_permission(FunctionName=function_name, StatementId=statement_id) return @@ -178,12 +229,15 @@ def remove_permissions(self, function_name: str, statement_id: str) -> None: if e.response["Error"]["Code"] == "ResourceNotFoundException": self.LOGGER.info(f"{function_name} permission not found.") return - else: - self.LOGGER.info(f"Error removing lambda permission: {e}") - return - + self.LOGGER.info(f"Error removing lambda permission: {e}") + return + def delete_lambda_function(self, function_name: str) -> None: - """Delete Lambda Function.""" + """Delete Lambda Function. + + Args: + function_name (str): Lambda Function Name + """ try: self.LAMBDA_CLIENT.delete_function(FunctionName=function_name) return @@ -191,10 +245,9 @@ def delete_lambda_function(self, function_name: str) -> None: if e.response["Error"]["Code"] == "ResourceNotFoundException": self.LOGGER.info(f"{function_name} function not found.") return - else: - self.LOGGER.info(f"Error deleting lambda function: {e}") - return - + self.LOGGER.info(f"Error deleting lambda function: {e}") + return + def get_lambda_execution_role(self, function_name: str) -> str: """Get Lambda Function Execution Role. @@ -205,7 +258,7 @@ def get_lambda_execution_role(self, function_name: str) -> str: str: Execution Role ARN """ self.LOGGER.info(f"Getting execution role for Lambda function: {function_name}") - try: + try: response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) execution_role_arn = response['Configuration']['Role'] self.LOGGER.info(f"Execution Role ARN: {execution_role_arn}") @@ -213,16 +266,23 @@ def get_lambda_execution_role(self, function_name: str) -> str: except ClientError as e: self.LOGGER.error(e) return "Error" - + def find_permission(self, function_name: str, statement_id: str) -> bool: - """Find Lambda Function Permissions.""" + """Find Lambda Function Permissions. + + Args: + function_name (str): Lambda Function Name + statement_id (str): Statement ID + + Returns: + bool: True if found, else False + """ try: response = self.LAMBDA_CLIENT.get_policy(FunctionName=function_name) policy = response["Policy"] if statement_id in policy: return True - else: - return False + return False except ClientError as e: self.LOGGER.error(e) - return False \ No newline at end of file + return False From 1eb62f681f93f995a0f387470664d41d44e6be2b Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 16:31:46 -0700 Subject: [PATCH 308/395] working on flake8 issues in repo module --- .../genai/bedrock_org/lambda/src/app.py | 2 +- .../genai/bedrock_org/lambda/src/sra_repo.py | 204 ++++++++---------- 2 files changed, 94 insertions(+), 112 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 358524587..0e2a17dc2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -195,7 +195,7 @@ def load_sra_cloudwatch_dashboard() -> dict: iam = sra_iam.SRAIAM() dynamodb = sra_dynamodb.SRADynamoDB() sts = sra_sts.sra_sts() -repo = sra_repo.sra_repo() +repo = sra_repo.SRARepo() s3 = sra_s3.sra_s3() lambdas = sra_lambda.SRALambda() sns = sra_sns.sra_sns() diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index a44c40adf..5b8850729 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -1,4 +1,12 @@ -# import json +"""Lambda python module to interact with the SRA code repository. + +Version: 1.0 + +REPO module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" import logging import urllib3 from io import BytesIO @@ -9,35 +17,25 @@ import subprocess # noqa S404 (best practice for calling pip from script) import sys -# import boto3 -# from botocore.exceptions import NoCredentialsError - -# import zipfile -import shutil - -import pip +# TODO(liamschn): need to exclude "inline_" files from the staging process -# todo(liamschn): need to exclude "inline_" files from the staging process +class SRARepo: + """SRA Repo Class.""" -class sra_repo: # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) REPO_ZIP_URL = "https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip" - REPO_BRANCH = REPO_ZIP_URL.split(".")[1].split("/")[len(REPO_ZIP_URL.split(".")[1].split("/")) - 1] - SOLUTIONS_DIR: str = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" - STAGING_UPLOAD_FOLDER = "/tmp/sra_staging_upload" - STAGING_TEMP_FOLDER = "/tmp/sra_temp" + REPO_BRANCH = REPO_ZIP_URL.split(".")[1].split("/")[len(REPO_ZIP_URL.split(".")[1].split("/")) - 1] # noqa: ECE001 + SOLUTIONS_DIR: str = f"/tmp/aws-security-reference-architecture-examples-{REPO_BRANCH}/aws_sra_examples/solutions" # noqa: S108 + STAGING_UPLOAD_FOLDER = "/tmp/sra_staging_upload" # noqa: S108 + STAGING_TEMP_FOLDER = "/tmp/sra_temp" # noqa: S108 CONFIG_RULES: dict = {} - # STAGING_BUCKET: str = "sra-staging-" # todo(liamschn): get from SSM parameter - # PIP_VERSION = pip.__version__ - # URLLIB3_VERSION = urllib3.__version__ - # class methods def pip_install(self, requirements: str, package_temp_directory: str, individual: bool = False) -> None: """Use pip to install package. @@ -88,65 +86,66 @@ def zip_folder(self, path: str, zip_file: ZipFile, layer: bool = False) -> None: zip_file: zipped file handle layer: true if lambda layer, false otherwise """ - self.LOGGER.info(f"Creating code zip file") - for root, dirs, files in os.walk(path): # noqa B007 (dirs variable required & unused) + self.LOGGER.info("Creating code zip file...") + for root, dirs, files in os.walk(path): # noqa B007 for discovered_file in files: if layer is False: - # LOGGER.info("Adding lambda code to zip file") zip_file.write( os.path.join(root, discovered_file), os.path.relpath(os.path.join(root, discovered_file), path), ) else: - # LOGGER.info("Adding layer code to zip file") zip_file.write( os.path.join(root, discovered_file), os.path.relpath(os.path.join(root, discovered_file), os.path.join(path, "..")), ) - # def download_file(self, repo_url_prefix, repo_file, local_folder): - # self.LOGGER.info(f"Downloading {repo_file} file from {repo_url_prefix}") - # http = urllib3.PoolManager() - # # repo_code_file = http.request("GET", - # with open(f"/tmp/{local_folder}{repo_file}", 'wb') as out: - # repo_code_file = http.request("GET", repo_url_prefix + repo_file) - # self.LOGGER.info(f"HTTP status code: {repo_code_file.status}") - # shutil.copyfileobj(repo_code_file, out) - # self.LOGGER.info(f"/tmp/{local_folder} directory listing: {os.listdir('/tmp/' + local_folder)}") - def download_code_library(self, repo_zip_url: str) -> None: + """Download the code library from the repository. + + Args: + repo_zip_url: URL to the repository zip file + """ self.LOGGER.info(f"Downloading code library from {repo_zip_url}") http = urllib3.PoolManager() repo_zip_file = http.request("GET", repo_zip_url) self.LOGGER.info(f"HTTP status code: {repo_zip_file.status}") zipfile = ZipFile(BytesIO(repo_zip_file.data)) - zipfile.extractall("/tmp") + zipfile.extractall("/tmp") # noqa: S108, DUO112 self.LOGGER.info("Files extracted to /tmp") - self.LOGGER.info(f"tmp directory listing: {os.listdir('/tmp')}") + self.LOGGER.info(f"tmp directory listing: {os.listdir('/tmp')}") # noqa: S108 - def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_temp_folder: str, solutions_dir: str) -> None: - # self.LOGGER.info(f"listing config rules for {solution}") - if os.path.exists(staging_upload_folder): + def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_temp_folder: str, # noqa: CCR001, C901 + solutions_dir: str) -> None: + """Prepare config rules for staging. + + Args: + staging_upload_folder: staging upload folder + staging_temp_folder: staging temp folder + solutions_dir: solutions directory + """ + self.LOGGER.info("Preparing config rules for staging...") + if os.path.exists(staging_upload_folder): # noqa: PL110 shutil.rmtree(staging_upload_folder) - if os.path.exists(staging_temp_folder): + if os.path.exists(staging_temp_folder): # noqa: PL110 shutil.rmtree(staging_temp_folder) - os.mkdir(staging_upload_folder) - os.mkdir(staging_temp_folder) + os.mkdir(staging_upload_folder) # noqa: PL102 + os.mkdir(staging_temp_folder) # noqa: PL102 service_folders = os.listdir(solutions_dir) for service in service_folders: service_dir = solutions_dir + "/" + service - if os.path.isdir(service_dir): + if os.path.isdir(service_dir): # noqa: PL112 service_solutions_folders = sorted(os.listdir(service_dir)) for solution in sorted(service_solutions_folders): - if os.path.isdir(os.path.join(service_dir, solution)): + if os.path.isdir(os.path.join(service_dir, solution)): # noqa: PL112 self.LOGGER.info(f"Solution: {solution}") - if os.path.isdir(os.path.join(service_dir, solution, "lambda/rules")): # config rules folder - solution_config_rules = os.path.join(service_dir, solution, "lambda/rules") + if os.path.isdir(os.path.join(service_dir, solution, "lambda/rules")): # noqa: PL112 # config rules folder + solution_config_rules = os.path.join(service_dir, solution, "lambda/rules") # noqa: PL118 config_rule_folders = sorted(os.listdir(solution_config_rules)) for config_rule in sorted(config_rule_folders): self.LOGGER.info(f"config rule: {config_rule} (in the {solution} solution)") - config_rule_source_files = os.path.join(solution_config_rules, config_rule) + config_rule_source_files = os.path.join(solution_config_rules, config_rule) # noqa: PL118 solution_name = "sra-" + solution.replace("_", "-") upload_folder_name = "/" + solution_name rule_name = config_rule.replace("_", "-") @@ -155,32 +154,32 @@ def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_t self.CONFIG_RULES[solution_name].append(config_rule) else: self.CONFIG_RULES[solution_name] = [config_rule] - if not os.path.exists(staging_temp_folder + upload_folder_name): - os.mkdir(staging_temp_folder + upload_folder_name) - if not os.path.exists(staging_temp_folder + upload_folder_name + "/rules"): - os.mkdir(staging_temp_folder + upload_folder_name + "/rules") + if not os.path.exists(staging_temp_folder + upload_folder_name): # noqa: PL110 + os.mkdir(staging_temp_folder + upload_folder_name) # noqa: PL102 + if not os.path.exists(staging_temp_folder + upload_folder_name + "/rules"): # noqa: PL110 + os.mkdir(staging_temp_folder + upload_folder_name + "/rules") # noqa: PL102 config_rule_staging_folder_path = ( staging_temp_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name ) - if not os.path.exists(config_rule_staging_folder_path): + if not os.path.exists(config_rule_staging_folder_path): # noqa: PL110 self.LOGGER.info(f"Creating {config_rule_staging_folder_path} folder") - os.mkdir(config_rule_staging_folder_path) - if not os.path.exists(staging_upload_folder + upload_folder_name): + os.mkdir(config_rule_staging_folder_path) # noqa: PL102 + if not os.path.exists(staging_upload_folder + upload_folder_name): # noqa: PL110 self.LOGGER.info(f"Creating {staging_upload_folder + upload_folder_name} folder") - os.mkdir(staging_upload_folder + upload_folder_name) - if not os.path.exists(staging_upload_folder + upload_folder_name + "/rules"): + os.mkdir(staging_upload_folder + upload_folder_name) # noqa: PL102 + if not os.path.exists(staging_upload_folder + upload_folder_name + "/rules"): # noqa: PL110 self.LOGGER.info(f"Creating {staging_upload_folder + upload_folder_name + '/rules'} folder") - os.mkdir(staging_upload_folder + upload_folder_name + "/rules") + os.mkdir(staging_upload_folder + upload_folder_name + "/rules") # noqa: PL102 config_rule_upload_folder_path = ( staging_upload_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name ) - if not os.path.exists(config_rule_upload_folder_path): + if not os.path.exists(config_rule_upload_folder_path): # noqa: PL110 self.LOGGER.info(f"Creating {config_rule_upload_folder_path} folder") - os.mkdir(config_rule_upload_folder_path) + os.mkdir(config_rule_upload_folder_path) # noqa: PL102 self.LOGGER.info(f"DEBUG: config_rule_staging_folder_path: {config_rule_staging_folder_path}") self.LOGGER.info(f"DEBUG: config_rule_upload_folder_path: {config_rule_upload_folder_path}") # lambda code - if os.path.exists(config_rule_source_files) and os.path.exists( + if os.path.exists(config_rule_source_files) and os.path.exists( # noqa: PL110 os.path.join(config_rule_source_files, "requirements.txt") ): self.LOGGER.info(f"Downloading required packages for {solution} lambda...") @@ -190,7 +189,7 @@ def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_t ) for source_file in os.listdir(config_rule_source_files): self.LOGGER.info(f"source_file: {source_file}") - if os.path.isdir(os.path.join(config_rule_source_files, source_file)): + if os.path.isdir(os.path.join(config_rule_source_files, source_file)): # noqa: PL112 self.LOGGER.info(f"{source_file} is a directory, skipping...") else: shutil.copy( @@ -198,118 +197,101 @@ def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_t config_rule_staging_folder_path, ) self.LOGGER.info(f"DEBUG: Copied {source_file} to {config_rule_staging_folder_path}") - # DEBUG code: self.LOGGER.info(f"DEBUG: listdir = {os.listdir(config_rule_staging_folder_path)}") self.LOGGER.info(f"DEBUG: isdir = {os.path.isdir(config_rule_staging_folder_path)}") for dest_file in os.listdir(config_rule_staging_folder_path): self.LOGGER.info(f"DEBUG: listing {dest_file} in {config_rule_staging_folder_path}") lambda_target_folder = config_rule_upload_folder_path self.LOGGER.info( - f"Zipping config rule code for {solution} / {config_rule} lambda to {lambda_target_folder}{config_rule_upload_folder_name}.zip..." + f"Zipping config rule code for {solution} / {config_rule} lambda to" + + f"{lambda_target_folder}{config_rule_upload_folder_name}.zip..." ) - # os.mkdir(lambda_target_folder) + zip_file = ZipFile(f"{lambda_target_folder}/{config_rule_upload_folder_name}.zip", "w", ZIP_DEFLATED) self.LOGGER.info( - f"DEBUG: Zipping {config_rule_staging_folder_path} folder in to {lambda_target_folder}/{config_rule_upload_folder_name}.zip" + f"DEBUG: Zipping {config_rule_staging_folder_path} folder in to" + + f"{lambda_target_folder}/{config_rule_upload_folder_name}.zip" ) self.zip_folder(f"{config_rule_staging_folder_path}", zip_file) zip_file.close() - self.LOGGER.info(f"{lambda_target_folder}{config_rule_upload_folder_name}.zip file size is {os.path.getsize(f'{lambda_target_folder}{config_rule_upload_folder_name}.zip')}") + self.LOGGER.info(f"{lambda_target_folder}{config_rule_upload_folder_name}.zip file size is" + + f"{os.path.getsize(f'{lambda_target_folder}{config_rule_upload_folder_name}.zip')}") # debug stuff: else: self.LOGGER.info(f"{os.path.join(service_dir, solution, 'rules')} does not exist!") - # if solution == "bedrock_org": - # self.LOGGER.info(f"bedrock_org solution does not have config rules!") - # self.LOGGER.info(f"bedrock_org directory listing: {os.listdir('/tmp/aws-security-reference-architecture-examples-sra-genai/aws_sra_examples/solutions/genai/bedrock_org/lambda')}") self.LOGGER.info(f"All config rules: {self.CONFIG_RULES}") - def prepare_code_for_staging(self, staging_upload_folder: str, staging_temp_folder: str, solutions_dir: str) -> None: - if os.path.exists(staging_upload_folder): + def prepare_code_for_staging(self, staging_upload_folder: str, staging_temp_folder: str, solutions_dir: str) -> None: # noqa: CCR001 + """Prepare code for staging. + + Args: + staging_upload_folder: staging upload folder + staging_temp_folder: staging temp folder + solutions_dir: solutions directory + """ + self.LOGGER.info("Preparing code for staging...") + if os.path.exists(staging_upload_folder): # noqa: PL110 shutil.rmtree(staging_upload_folder) - if os.path.exists(staging_temp_folder): + if os.path.exists(staging_temp_folder): # noqa: PL110 shutil.rmtree(staging_temp_folder) - os.mkdir(staging_upload_folder) - os.mkdir(staging_temp_folder) + os.mkdir(staging_upload_folder) # noqa: PL102 + os.mkdir(staging_temp_folder) # noqa: PL102 service_folders = os.listdir(solutions_dir) for service in service_folders: service_dir = solutions_dir + "/" + service - if os.path.isdir(service_dir): + if os.path.isdir(service_dir): # noqa: PL112 service_solutions_folders = sorted(os.listdir(service_dir)) for solution in sorted(service_solutions_folders): - if os.path.isdir(os.path.join(service_dir, solution)): + if os.path.isdir(os.path.join(service_dir, solution)): # noqa: PL112 self.LOGGER.info(f"Solution: {solution}") - # if solution != "inspector_org": # for debugging - # continue - source_files = os.path.join(service_dir, solution, "lambda/src") + source_files = os.path.join(service_dir, solution, "lambda/src") # noqa: PL118 upload_folder_name = "/sra-" + solution.replace("_", "-") - os.mkdir(staging_temp_folder + upload_folder_name) - os.mkdir(staging_upload_folder + upload_folder_name) + os.mkdir(staging_temp_folder + upload_folder_name) # noqa: PL102 + os.mkdir(staging_upload_folder + upload_folder_name) # noqa: PL102 # lambda code - if os.path.exists(source_files) and os.path.exists(os.path.join(source_files, "requirements.txt")): + if os.path.exists(source_files) and os.path.exists(os.path.join(source_files, "requirements.txt")): # noqa: PL110 self.LOGGER.info(f"Downloading required packages for {solution} lambda...") self.pip_install( os.path.join(service_dir, solution, "lambda/src/requirements.txt"), staging_temp_folder + upload_folder_name + "/lambda", ) for source_file in os.listdir(source_files): - if os.path.isdir(os.path.join(source_files, source_file)): + if os.path.isdir(os.path.join(source_files, source_file)): # noqa: PL112 self.LOGGER.info(f"{source_file} is a directory, skipping...") else: shutil.copy(os.path.join(source_files, source_file), staging_temp_folder + upload_folder_name + "/lambda") lambda_target_folder = staging_upload_folder + upload_folder_name + "/lambda_code" self.LOGGER.info(f"Zipping lambda code for {solution} lambda to {lambda_target_folder}{upload_folder_name}.zip...") - os.mkdir(lambda_target_folder) + os.mkdir(lambda_target_folder) # noqa: PL102 zip_file = ZipFile(f"{lambda_target_folder}/{upload_folder_name}.zip", "w", ZIP_DEFLATED) self.zip_folder(f"{staging_temp_folder + upload_folder_name}/lambda", zip_file) zip_file.close() # layer code - layer_files = os.path.join(service_dir, solution, "layer") - if os.path.exists(layer_files): + layer_files = os.path.join(service_dir, solution, "layer") # noqa: PL118 + if os.path.exists(layer_files): # noqa: PL110 for package in os.listdir(layer_files): self.LOGGER.info(f"Downloading required package ({package}) for {solution} lambda...") self.pip_install(package, staging_temp_folder + upload_folder_name + "/layer/python", True) layer_target_folder = staging_upload_folder + upload_folder_name + "/layer_code" self.LOGGER.info(f"Zipping layer code for {solution} to {layer_target_folder}{upload_folder_name}.zip...") - os.mkdir(layer_target_folder) + os.mkdir(layer_target_folder) # noqa: PL102 zip_file = ZipFile(f"{layer_target_folder}/{upload_folder_name}-layer.zip", "w", ZIP_DEFLATED) self.zip_folder(f"{staging_temp_folder + upload_folder_name}/layer/python", zip_file, True) zip_file.close() # CloudFormation template code - cfn_template_files = os.path.join(service_dir, solution, "templates") - if os.path.exists(cfn_template_files): + cfn_template_files = os.path.join(service_dir, solution, "templates") # noqa: PL118 + if os.path.exists(cfn_template_files): # noqa: PL110 cfn_templates_target_folder = staging_upload_folder + upload_folder_name + "/templates" self.LOGGER.info(f"Copying CloudFormation templates for {solution} to {cfn_templates_target_folder}...") - os.mkdir(cfn_templates_target_folder) + os.mkdir(cfn_templates_target_folder) # noqa: PL102 for cfn_template_file in os.listdir(cfn_template_files): - if os.path.isdir(os.path.join(cfn_template_files, cfn_template_file)): + if os.path.isdir(os.path.join(cfn_template_files, cfn_template_file)): # noqa: PL112 self.LOGGER.info(f"{cfn_template_file} is a directory, skipping...") else: shutil.copy(os.path.join(cfn_template_files, cfn_template_file), cfn_templates_target_folder) - - # def stage_code_to_s3(self, directory_path, bucket_name, s3_path): - # """ - # Uploads the prepared code directory to the staging S3 bucket. - - # :param directory_path: Local path to directory - # :param bucket_name: Name of the S3 bucket - # :param s3_path: S3 path where the directory will be uploaded - # """ - # s3_client = boto3.client("s3") - - # for root, dirs, files in os.walk(directory_path): - # for file in files: - # local_path = os.path.join(root, file) - - # relative_path = os.path.relpath(local_path, directory_path) - # s3_file_path = relative_path - # try: - # s3_client.upload_file(local_path, bucket_name, s3_file_path) - # except NoCredentialsError: - # self.LOGGER.info("Credentials not available") - # return From 5e561f204a72e06de94ed414cf23311c53b5c32e Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 17:00:44 -0700 Subject: [PATCH 309/395] fix mypy and flake8 issues in s3 module --- .../genai/bedrock_org/lambda/src/app.py | 4 +- .../genai/bedrock_org/lambda/src/sra_s3.py | 112 +++++++++++------- 2 files changed, 71 insertions(+), 45 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0e2a17dc2..45d75c3a4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -196,7 +196,7 @@ def load_sra_cloudwatch_dashboard() -> dict: dynamodb = sra_dynamodb.SRADynamoDB() sts = sra_sts.sra_sts() repo = sra_repo.SRARepo() -s3 = sra_s3.sra_s3() +s3 = sra_s3.SRAS3() lambdas = sra_lambda.SRALambda() sns = sra_sns.sra_sns() config = sra_config.SRAConfig() @@ -698,7 +698,7 @@ def deploy_stage_config_rule_lambda_code() -> None: repo.prepare_config_rules_for_staging(repo.STAGING_UPLOAD_FOLDER, repo.STAGING_TEMP_FOLDER, repo.SOLUTIONS_DIR) CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 LIVE_RUN_DATA["CodePrep"] = "Prepared config rule code for staging" - s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET, "/") + s3.stage_code_to_s3(repo.STAGING_UPLOAD_FOLDER, s3.STAGING_BUCKET) LIVE_RUN_DATA["CodeStaging"] = "Staged config rule code to staging s3 bucket" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 else: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 45195e048..68f875e1e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -1,5 +1,8 @@ -# type: ignore -"""Custom Resource to check to see if a resource exists. +"""Lambda python module to setup SRA S3 resources in the organization. + +Version: 1.0 + +S3 module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 @@ -12,7 +15,9 @@ import json -class sra_s3: +class SRAS3: + """Class to setup SRA S3 resources in the organization.""" + S3_CLIENT = boto3.client("s3") S3_RESOURCE = boto3.resource("s3") @@ -20,11 +25,11 @@ class sra_s3: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - REGION: str = os.environ.get("AWS_REGION") + REGION: str = os.environ.get("AWS_REGION", "us-east-1") ORG_ID: str = boto3.client("organizations").describe_organization()["Organization"]["Id"] - PARTITION: str = boto3.session.Session().get_partition_for_region(REGION) + PARTITION = boto3.session.Session().get_partition_for_region(REGION) STAGING_BUCKET: str = "" - BUCKET_POLICY_TEMPLATE: dict = { + BUCKET_POLICY_TEMPLATE: dict = { # noqa: ECE001 "Version": "2012-10-17", "Statement": [ { @@ -61,24 +66,43 @@ class sra_s3: ], } - def query_for_s3_bucket(self, bucket): + def query_for_s3_bucket(self, bucket: str) -> bool: + """Query for S3 bucket. + + Args: + bucket (str): Name of the S3 bucket to query + + Returns: + bool: True if the bucket exists, False otherwise + """ try: self.S3_RESOURCE.meta.client.head_bucket(Bucket=bucket) return True except ClientError: return False - def create_s3_bucket(self, bucket): + def create_s3_bucket(self, bucket: str) -> None: + """Create S3 bucket. + + Args: + bucket (str): Name of the S3 bucket to create + """ if self.REGION != "us-east-1": create_bucket = self.S3_CLIENT.create_bucket( - ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, ObjectOwnership="BucketOwnerPreferred" + ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, # type: ignore + ObjectOwnership="BucketOwnerPreferred" ) else: create_bucket = self.S3_CLIENT.create_bucket(ACL="private", Bucket=bucket, ObjectOwnership="BucketOwnerPreferred") self.LOGGER.info(f"Bucket created: {create_bucket}") self.apply_bucket_policy(bucket) - def apply_bucket_policy(self, bucket): + def apply_bucket_policy(self, bucket: str) -> None: + """Apply bucket policy to S3 bucket. + + Args: + bucket (str): Name of the S3 bucket to apply the policy to + """ self.LOGGER.info(self.BUCKET_POLICY_TEMPLATE) for sid in self.BUCKET_POLICY_TEMPLATE["Statement"]: if isinstance(sid["Resource"], list): @@ -92,51 +116,53 @@ def apply_bucket_policy(self, bucket): ) self.LOGGER.info(bucket_policy_response) - def s3_resource_check(self, bucket): + def s3_resource_check(self, bucket: str) -> None: + """Check for S3 bucket and create if it doesn't exist. + + Args: + bucket (str): Name of the S3 bucket to check and create if it doesn't exist + """ self.LOGGER.info(f"Checking for {bucket} s3 bucket...") if self.query_for_s3_bucket(bucket) is False: self.LOGGER.info(f"Bucket not found, creating {bucket} s3 bucket...") self.create_s3_bucket(bucket) - # todo(liamschn): parameter formatting validation + # TODO(liamschn): parameter formatting validation (done in App module?) - def stage_code_to_s3(self, directory_path, bucket_name, s3_path): - """ - Uploads the prepared code directory to the staging S3 bucket. + def stage_code_to_s3(self, directory_path: str, bucket_name: str) -> None: + """Upload the prepared code directory to the staging S3 bucket. - :param directory_path: Local path to directory - :param bucket_name: Name of the S3 bucket - :param s3_path: S3 path where the directory will be uploaded + Args: + directory_path (str): Path to the directory to be uploaded + bucket_name (str): Name of the S3 bucket """ - # s3_client = boto3.client("s3") - - for root, dirs, files in os.walk(directory_path): - for file in files: - local_path = os.path.join(root, file) + for root, dirs, files in os.walk(directory_path): # noqa: B007 + for single_file in files: + local_path = os.path.join(root, single_file) # noqa: PL118 relative_path = os.path.relpath(local_path, directory_path) s3_file_path = relative_path try: self.S3_CLIENT.upload_file(local_path, bucket_name, s3_file_path) - except NoCredentialsError: - self.LOGGER.info("Credentials not available") + except ClientError as e: + self.LOGGER.info(f"Error uploading file: {e}") return self.LOGGER.info(f"Uploaded {local_path} to {bucket_name} {s3_file_path}") - def download_s3_file(self, local_file_path, s3_key, bucket_name): - """ - Downloads the rule code from the staging S3 bucket. + def download_s3_file(self, local_file_path: str, s3_key: str, bucket_name: str) -> None: + """Download the rule code from the staging S3 bucket. - :param local_file_path: Local path to save the downloaded file - :param s3_key: Name of the S3 bucket key - :param bucket_name: Name of the S3 bucket + Args: + local_file_path (str): Local path to download the file to + s3_key (str): S3 key (path) of the file to download + bucket_name (str): Name of the S3 bucket """ - self.LOGGER.info(f"Downloading file from s3...") - + self.LOGGER.info("Downloading file from s3...") + # Ensure local directories exist self.LOGGER.info(f"Creating local directories ({os.path.dirname(local_file_path)}) if they don't exist...") - os.makedirs(os.path.dirname(local_file_path), exist_ok=True) - + os.makedirs(os.path.dirname(local_file_path), exist_ok=True) # noqa: PL103 + try: # Download the file from S3 self.LOGGER.info(f"Downloading file from {bucket_name} {s3_key} to {local_file_path}") @@ -145,24 +171,24 @@ def download_s3_file(self, local_file_path, s3_key, bucket_name): self.LOGGER.info(f"Error downloading file: {e}") # Check if the file was downloaded successfully - if os.path.exists(local_file_path): + if os.path.exists(local_file_path): # noqa: PL110 self.LOGGER.info(f"File downloaded successfully to {local_file_path}") # list the directory contents self.LOGGER.info(f"Listing directory contents: {os.listdir(os.path.dirname(local_file_path))}") else: self.LOGGER.info(f"File not found: {local_file_path}") - def upload_file_to_s3(self, local_file_path, bucket_name, s3_key): - """ - Uploads a file to an S3 bucket. + def upload_file_to_s3(self, local_file_path: str, bucket_name: str, s3_key: str) -> None: + """Upload a file to an S3 bucket. - :param local_file_path: Local path to the file to be uploaded - :param bucket_name: Name of the S3 bucket - :param s3_key: S3 key (path) where the file will be uploaded + Args: + local_file_path (str): Local path of the file to upload + bucket_name (str): Name of the S3 bucket + s3_key (str): S3 key (path) to upload the file to """ try: # Upload the file to S3 self.S3_CLIENT.upload_file(local_file_path, bucket_name, s3_key) self.LOGGER.info(f"File uploaded successfully to {bucket_name}/{s3_key}") except ClientError as e: - self.LOGGER.info(f"Error uploading file: {e}") \ No newline at end of file + self.LOGGER.info(f"Error uploading file: {e}") From 537d5b466b7a9d4afefff9863e9e1f8d7675f73d Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 17:13:41 -0700 Subject: [PATCH 310/395] fixing flake8 issues in sns module --- .../genai/bedrock_org/lambda/src/app.py | 2 +- .../genai/bedrock_org/lambda/src/sra_sns.py | 101 ++++++++++++++---- 2 files changed, 79 insertions(+), 24 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 45d75c3a4..af017de82 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -198,7 +198,7 @@ def load_sra_cloudwatch_dashboard() -> dict: repo = sra_repo.SRARepo() s3 = sra_s3.SRAS3() lambdas = sra_lambda.SRALambda() -sns = sra_sns.sra_sns() +sns = sra_sns.SRASNS() config = sra_config.SRAConfig() cloudwatch = sra_cloudwatch.SRACloudWatch() kms = sra_kms.SRAKMS() diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 2420aec97..0d35d7af7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -1,4 +1,4 @@ -"""Custom Resource to setup SRA Lambda resources in the organization. +"""Lambda module to setup SRA SNS resources in the organization. Version: 0.1 @@ -29,7 +29,9 @@ from mypy_boto3_sns.type_defs import PublishBatchResponseTypeDef -class sra_sns: +class SRASNS: + """Class to setup SRA SNS resources in the organization.""" + # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") @@ -40,7 +42,6 @@ class sra_sns: SNS_PUBLISH_BATCH_MAX = 10 - try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() SNS_CLIENT: SNSClient = MANAGEMENT_ACCOUNT_SESSION.client("sns", config=BOTO3_CONFIG) @@ -51,11 +52,23 @@ class sra_sns: sts = sra_sts.sra_sts() def find_sns_topic(self, topic_name: str, region: str = "default", account: str = "default") -> str | None: - """Find SNS Topic ARN.""" + """Find SNS Topic ARN. + + Args: + topic_name (str): SNS Topic Name + region (str): AWS Region + account (str): AWS Account + + Raises: + ValueError: Error finding SNS topic + + Returns: + str: SNS Topic ARN + """ if region == "default": region = self.sts.HOME_REGION if account == "default": - account= self.sts.MANAGEMENT_ACCOUNT + account = self.sts.MANAGEMENT_ACCOUNT try: response = self.SNS_CLIENT.get_topic_attributes( TopicArn=f"arn:{self.sts.PARTITION}:sns:{region}:{account}:{topic_name}" @@ -65,14 +78,25 @@ def find_sns_topic(self, topic_name: str, region: str = "default", account: str if e.response["Error"]["Code"] == "NotFoundException": self.LOGGER.info(f"SNS Topic '{topic_name}' not found exception.") return None - elif e.response["Error"]["Code"] == "NotFound": + if e.response["Error"]["Code"] == "NotFound": self.LOGGER.info(f"SNS Topic '{topic_name}' not found.") return None - else: - raise ValueError(f"Error finding SNS topic: {e}") from None + raise ValueError(f"Error finding SNS topic: {e}") from None def create_sns_topic(self, topic_name: str, solution_name: str, kms_key: str = "default") -> str: - """Create SNS Topic.""" + """Create SNS Topic. + + Args: + topic_name (str): SNS Topic Name + solution_name (str): Solution Name + kms_key (str): KMS Key ARN + + Raises: + ValueError: Error creating SNS topic + + Returns: + str: SNS Topic ARN + """ if kms_key == "default": self.LOGGER.info("Using default KMS key for SNS topic.") kms_key = f"arn:{self.sts.PARTITION}:kms:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:alias/aws/sns" @@ -80,9 +104,8 @@ def create_sns_topic(self, topic_name: str, solution_name: str, kms_key: str = " self.LOGGER.info(f"Using provided KMS key '{kms_key}' for SNS topic.") try: response = self.SNS_CLIENT.create_topic( - Name=topic_name, - Attributes={"DisplayName": topic_name, - "KmsMasterKeyId": kms_key}, + Name=topic_name, + Attributes={"DisplayName": topic_name, "KmsMasterKeyId": kms_key}, Tags=[{"Key": "sra-solution", "Value": solution_name}] ) topic_arn = response["TopicArn"] @@ -92,18 +115,36 @@ def create_sns_topic(self, topic_name: str, solution_name: str, kms_key: str = " raise ValueError(f"Error creating SNS topic: {e}") from None def delete_sns_topic(self, topic_arn: str) -> None: - """Delete SNS Topic.""" + """Delete SNS Topic. + + Args: + topic_arn (str): SNS Topic ARN + + Raises: + ValueError: Error deleting SNS topic + """ try: self.SNS_CLIENT.delete_topic(TopicArn=topic_arn) self.LOGGER.info(f"SNS Topic '{topic_arn}' deleted") - return None except ClientError as e: raise ValueError(f"Error deleting SNS topic: {e}") from None def find_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) -> bool: - """Find SNS Subscription.""" + """Find SNS Subscription. + + Args: + topic_arn (str): SNS Topic ARN + protocol (str): SNS Subscription Protocol + endpoint (str): SNS Subscription Endpoint + + Raises: + ValueError: Error finding SNS subscription + + Returns: + bool: True if SNS Subscription exists, False otherwise. + """ try: - response = self.SNS_CLIENT.get_subscription_attributes( + self.SNS_CLIENT.get_subscription_attributes( SubscriptionArn=f"arn:{self.sts.PARTITION}:sns:{self.sts.HOME_REGION}:{self.sts.MANAGEMENT_ACCOUNT}:{topic_arn}:{protocol}:{endpoint}" ) return True @@ -111,23 +152,38 @@ def find_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) -> if e.response["Error"]["Code"] == "NotFoundException": self.LOGGER.info(f"SNS Subscription for {endpoint} not found on topic {topic_arn}.") return False - else: - raise ValueError(f"Error finding SNS subscription: {e}") from None + raise ValueError(f"Error finding SNS subscription: {e}") from None def create_sns_subscription(self, topic_arn: str, protocol: str, endpoint: str) -> None: - """Create SNS Subscription.""" + """Create SNS Subscription. + + Args: + topic_arn (str): SNS Topic ARN + protocol (str): SNS Subscription Protocol + endpoint (str): SNS Subscription Endpoint + + Raises: + ValueError: Error creating SNS subscription + """ try: self.SNS_CLIENT.subscribe(TopicArn=topic_arn, Protocol=protocol, Endpoint=endpoint) self.LOGGER.info(f"SNS Subscription created for {endpoint} on topic {topic_arn}") sleep(5) # Wait for subscription to be created - return None except ClientError as e: raise ValueError(f"Error creating SNS subscription: {e}") from None def set_topic_access_for_alarms(self, topic_arn: str, source_account: str) -> None: - """Set SNS Topic Policy to allow access for alarm.""" + """Set SNS Topic Policy to allow access for alarm. + + Args: + topic_arn (str): SNS Topic ARN + source_account (str): Source AWS Account + + Raises: + ValueError: Error setting SNS topic policy + """ try: - policy = { + policy = { # noqa: ECE001 "Version": "2012-10-17", "Statement": [ { @@ -151,7 +207,6 @@ def set_topic_access_for_alarms(self, topic_arn: str, source_account: str) -> No AttributeValue=json.dumps(policy) ) self.LOGGER.info(f"SNS Topic Policy set for {topic_arn} to allow access for CloudWatch alarms in the {source_account} account") - return None except ClientError as e: raise ValueError(f"Error setting SNS topic policy: {e}") from None From 4f39f1430d3791c594009340546bdb9d45ddda8e Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 17:16:52 -0700 Subject: [PATCH 311/395] fixing flake8 issues in ssm params module --- .../solutions/genai/bedrock_org/lambda/src/app.py | 2 +- .../genai/bedrock_org/lambda/src/sra_ssm_params.py | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index af017de82..8ce88f007 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -191,7 +191,7 @@ def load_sra_cloudwatch_dashboard() -> dict: # Instantiate sra class objects # TODO(liamschn): can these files exist in some central location to be shared with other solutions? -ssm_params = sra_ssm_params.sra_ssm_params() +ssm_params = sra_ssm_params.SRASSMParams() iam = sra_iam.SRAIAM() dynamodb = sra_dynamodb.SRADynamoDB() sts = sra_sts.sra_sts() diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py index ceba96cc6..96e07b421 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -14,14 +14,13 @@ import os import re from time import sleep -from typing import TYPE_CHECKING, Any, List, Literal, Optional, Sequence, Union, cast +from typing import TYPE_CHECKING, Any, List, Literal, Optional, Sequence, Union import boto3 from botocore.config import Config from botocore.exceptions import ClientError, EndpointConnectionError if TYPE_CHECKING: - from aws_lambda_typing.context import Context from aws_lambda_typing.events import CloudFormationCustomResourceEvent from mypy_boto3_cloudformation import CloudFormationClient from mypy_boto3_organizations import OrganizationsClient @@ -29,7 +28,9 @@ from mypy_boto3_ssm.type_defs import TagTypeDef -class sra_ssm_params: +class SRASSMParams: + """Class to manage SSM Parameters.""" + # Setup Default Logger LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") @@ -60,7 +61,6 @@ class sra_ssm_params: "/sra/regions/customer-control-tower-regions-without-home-region", "/sra/staging-s3-bucket-name", ] - # todo(liamschn): in the common prerequisite solution add an sra execution/configuration role parameter SRA_STAGING_BUCKET: str = "" UNEXPECTED = "Unexpected!" @@ -565,8 +565,7 @@ def get_ssm_parameter(self, session: Any, region: str, parameter: str) -> tuple[ if e.response["Error"]["Code"] == "ParameterNotFound": self.LOGGER.info(f"SSM parameter '{parameter}' not found.") return False, "" - else: - self.LOGGER.info(f"Error getting SSM parameter '{parameter}': {e.response['Error']['Message']}") - return False, "" + self.LOGGER.info(f"Error getting SSM parameter '{parameter}': {e.response['Error']['Message']}") + return False, "" self.LOGGER.info(f"SSM parameter '{parameter}' found.") return True, response["Parameter"]["Value"] From 82487101070175086536222ef368bacc4aff346a Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 17:35:10 -0700 Subject: [PATCH 312/395] fixing flake8 issues in sts module --- .../genai/bedrock_org/lambda/src/app.py | 3 +- .../bedrock_org/lambda/src/sra_ssm_params.py | 2 +- .../genai/bedrock_org/lambda/src/sra_sts.py | 70 +++++++++++++------ 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 8ce88f007..9ef2bc759 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -194,7 +194,7 @@ def load_sra_cloudwatch_dashboard() -> dict: ssm_params = sra_ssm_params.SRASSMParams() iam = sra_iam.SRAIAM() dynamodb = sra_dynamodb.SRADynamoDB() -sts = sra_sts.sra_sts() +sts = sra_sts.SRASTS() repo = sra_repo.SRARepo() s3 = sra_s3.SRAS3() lambdas = sra_lambda.SRALambda() @@ -235,6 +235,7 @@ def get_resource_parameters(event: dict) -> None: repo.REPO_BRANCH = repo.REPO_ZIP_URL.split(".")[1].split("/")[len(repo.REPO_ZIP_URL.split(".")[1].split("/")) - 1] # noqa: ECE001 repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" # noqa: S108 + # TODO(liamschn): the CONFIGURATION_ROLE needs to be a resource parameter sts.CONFIGURATION_ROLE = "sra-execution" governed_regions_param = ssm_params.get_ssm_parameter( ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/regions/customer-control-tower-regions" diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py index 96e07b421..c6906abb4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -2,7 +2,7 @@ Version: 1.0 -'common_prerequisites' solution in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples +SSM Params module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index 470b1947f..0483a334d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -1,3 +1,12 @@ +"""Lambda module to use SRA STS service resources in the organization. + +Version: 0.1 + +STS module for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" import logging import os from typing import Any @@ -7,11 +16,13 @@ from botocore.config import Config import botocore.exceptions -class sra_sts: + +class SRASTS: + """Class to manage STS resources.""" + PROFILE = "default" UNEXPECTED = "Unexpected!" - # TODO(liamschn): this needs to be made into an SSM parameter CONFIGURATION_ROLE: str = "" BOTO3_CONFIG = Config(retries={"max_attempts": 10, "mode": "standard"}) PARTITION: str = "" @@ -22,16 +33,24 @@ class sra_sts: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - def __init__(self, profile: str="default") -> None: + def __init__(self, profile: str = "default") -> None: + """Initialize class object. + + Args: + profile (str): AWS credentials profile name. Defaults to "default". + + Raises: + ValueError: Error message + """ self.PROFILE = profile - print(f"STS PROFILE INFO: {self.PROFILE}") + self.LOGGER.info(f"Initial PROFILE: {self.PROFILE}") try: if self.PROFILE != "default": self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session(profile_name=self.PROFILE) - print(f"STS INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") + self.LOGGER.info(f"STS INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") else: - print(f"STS PROFILE AGAIN: {self.PROFILE}") + self.LOGGER.info(f"Subsequent PROFILE: {self.PROFILE}") self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") @@ -48,7 +67,7 @@ def __init__(self, profile: str="default") -> None: else: self.LOGGER.info(f"Error: {error}") - raise error + raise ValueError(f"Error: {error}") from None try: self.MANAGEMENT_ACCOUNT = self.STS_CLIENT.get_caller_identity().get("Account") @@ -59,18 +78,19 @@ def __init__(self, profile: str="default") -> None: self.LOGGER.info("Token has expired, please re-run with proper credentials set.") else: self.LOGGER.info(f"Error: {error}") - raise error + raise ValueError(f"Error: {error}") from None def assume_role(self, account: str, role_name: str, service: str, region_name: str) -> Any: """Get boto3 client assumed into an account for a specified service. Args: account: aws account id + role_name: aws role name service: aws service region_name: aws region Returns: - client: boto3 client + Any: boto3 client """ self.LOGGER.info(f"ASSUME ROLE CALLER ID INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") self.LOGGER.info(f"ASSUME ROLE ACCOUNT (CLIENT): {account}; ROLE NAME: {role_name}; SERVICE: {service}; REGION: {region_name}") @@ -81,32 +101,29 @@ def assume_role(self, account: str, role_name: str, service: str, region_name: s RoleSessionName="SRA-AssumeCrossAccountRole", DurationSeconds=900, ) - assumed_client = self.MANAGEMENT_ACCOUNT_SESSION.client( - service, # type: ignore + return self.MANAGEMENT_ACCOUNT_SESSION.client( + service, # type: ignore region_name=region_name, aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], aws_session_token=sts_response["Credentials"]["SessionToken"], ) - return assumed_client - else: - assumed_client = self.MANAGEMENT_ACCOUNT_SESSION.client( - service, # type: ignore - region_name=region_name, - config=self.BOTO3_CONFIG) - return assumed_client - + return self.MANAGEMENT_ACCOUNT_SESSION.client( + service, # type: ignore + region_name=region_name, + config=self.BOTO3_CONFIG) def assume_role_resource(self, account: str, role_name: str, service: str, region_name: str) -> Any: """Get boto3 resource assumed into an account for a specified service. Args: account: aws account id + role_name: aws role name service: aws service region_name: aws region Returns: - client: boto3 client + Any: boto3 client """ self.LOGGER.info(f"ASSUME ROLE CALLER ID INFO: {self.MANAGEMENT_ACCOUNT_SESSION.client('sts').get_caller_identity()}") self.LOGGER.info(f"ASSUME ROLE ACCOUNT (RESOURCE): {account}; ROLE NAME: {role_name}; SERVICE: {service}; REGION: {region_name}") @@ -116,16 +133,23 @@ def assume_role_resource(self, account: str, role_name: str, service: str, regio RoleSessionName="SRA-AssumeCrossAccountRole", DurationSeconds=900, ) - assumed_resource = self.MANAGEMENT_ACCOUNT_SESSION.resource( - service, # type: ignore + return self.MANAGEMENT_ACCOUNT_SESSION.resource( + service, # type: ignore region_name=region_name, aws_access_key_id=sts_response["Credentials"]["AccessKeyId"], aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], aws_session_token=sts_response["Credentials"]["SessionToken"], ) - return assumed_resource def get_lambda_execution_role(self) -> str: + """Get the current lambda execution role arn. + + Raises: + ValueError: Unexpected error getting caller identity + + Returns: + str: lambda execution role arn + """ try: response = self.STS_CLIENT.get_caller_identity() return response["Arn"] From 58488429328cb88740b6bee27ed5f8a393da5a3f Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 17:40:39 -0700 Subject: [PATCH 313/395] fixing mypy errors --- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 2 +- .../solutions/genai/bedrock_org/lambda/src/sra_s3.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 3c60642ce..93810cf61 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -89,7 +89,7 @@ def create_lambda_function(self, code_zip_file: str, role_arn: str, function_nam try: create_response = self.LAMBDA_CLIENT.create_function( FunctionName=function_name, - Runtime=runtime, # type: ignore + Runtime=runtime, Handler=handler, Role=role_arn, Code={"ZipFile": open(code_zip_file, "rb").read()}, # noqa: SIM115 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 68f875e1e..17a34bc8d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -89,7 +89,7 @@ def create_s3_bucket(self, bucket: str) -> None: """ if self.REGION != "us-east-1": create_bucket = self.S3_CLIENT.create_bucket( - ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, # type: ignore + ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, ObjectOwnership="BucketOwnerPreferred" ) else: From ec522c03da13c6189825d0f326ecd7161b32f228 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 18:58:15 -0700 Subject: [PATCH 314/395] fix flake8 issues for config rules --- .../app.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py index 6e1603914..ed0862edd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py @@ -1,3 +1,12 @@ +"""Config rule to check CloudWatch endpoints for Bedrock environemts. + +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any import boto3 import json @@ -17,8 +26,16 @@ ec2_client = boto3.client('ec2', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) + def evaluate_compliance(vpc_id: str) -> tuple[str, str]: - """Evaluates if a CloudWatch gateway endpoint is in place for the given VPC""" + """Evaluate if a CloudWatch gateway endpoint is in place for the given VPC. + + Args: + vpc_id: The ID of the VPC to evaluate + + Returns: + A tuple containing the compliance status and annotation message + """ try: response = ec2_client.describe_vpc_endpoints( Filters=[ @@ -28,18 +45,24 @@ def evaluate_compliance(vpc_id: str) -> tuple[str, str]: ) endpoints = response['VpcEndpoints'] - + if endpoints: endpoint_id = endpoints[0]['VpcEndpointId'] return 'COMPLIANT', f"CloudWatch gateway endpoint is in place for VPC {vpc_id}. Endpoint ID: {endpoint_id}" - else: - return 'NON_COMPLIANT', f"No CloudWatch gateway endpoint found for VPC {vpc_id}" + return 'NON_COMPLIANT', f"No CloudWatch gateway endpoint found for VPC {vpc_id}" except Exception as e: LOGGER.error(f"Error evaluating CloudWatch gateway endpoint for VPC {vpc_id}: {str(e)}") return 'ERROR', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event: dict, context: Any) -> None: + +def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 + """Lambda handler. This function is triggered by AWS Config when evaluating compliance. + + Args: + event (dict): Lambda event object + context (Any): Lambda context object + """ LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") From ec20c30982b22feea6b246ae154f6caec7b3ff4b Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 19:25:51 -0700 Subject: [PATCH 315/395] fix flake8 issues in config rules --- .../sra_bedrock_check_eval_job_bucket/app.py | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index f65bcd260..cc754dfb1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -1,10 +1,18 @@ +"""Config rule to check the eval job S3 bucket for Bedrock environemts. + +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any import boto3 -import json from botocore.exceptions import ClientError from datetime import datetime import logging -import os # maybe not needed for logging +import os import ast # Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). @@ -22,11 +30,19 @@ SERVICE_NAME = "bedrock.amazonaws.com" -def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: +def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: U100, CCR001, C901 + """Evaluate the S3 bucket for the compliance. + + Args: + event (dict): The AWS Config event + context (Any): The AWS Lambda context + + Returns: + tuple[str, str]: The compliance status and annotation + """ LOGGER.info(f"Evaluate Compliance Event: {event}") # Initialize AWS clients s3 = boto3.client('s3') - config = boto3.client('config') # Get rule parameters params = ast.literal_eval(event['ruleParameters']) @@ -39,9 +55,6 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: check_versioning = params.get('CheckVersioning', 'true').lower() != 'false' # Check if the bucket exists - # try: - # s3.head_bucket(Bucket=bucket_name) - # except ClientError as e: if not check_bucket_exists(bucket_name): return build_evaluation('NOT_APPLICABLE', f"Bucket {bucket_name} does not exist or is not accessible") @@ -98,17 +111,36 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: annotation_str = '; '.join(annotation) if annotation else "All checked features are compliant" return build_evaluation(compliance_type, annotation_str) + def check_bucket_exists(bucket_name: str) -> Any: + """Check if the bucket exists and is accessible. + + Args: + bucket_name (str): The name of the bucket to check + + Returns: + Any: True if the bucket exists and is accessible, False otherwise + """ s3 = boto3.client('s3') try: response = s3.list_buckets() buckets = [bucket['Name'] for bucket in response['Buckets']] return bucket_name in buckets except ClientError as e: - print(f"An error occurred: {e}") + LOGGER.info(f"An error occurred: {e}") return False + def build_evaluation(compliance_type: str, annotation: str) -> Any: + """Build the evaluation compliance type and annotation. + + Args: + compliance_type (str): The compliance type + annotation (str): the annotation + + Returns: + Any: The evaluation compliance type and annotation + """ LOGGER.info(f"Build Evaluation Compliance Type: {compliance_type} Annotation: {annotation}") return { 'ComplianceType': compliance_type, @@ -116,7 +148,15 @@ def build_evaluation(compliance_type: str, annotation: str) -> Any: 'OrderingTimestamp': datetime.now().isoformat() } + def lambda_handler(event: dict, context: Any) -> None: + """Lambda handler. + + Args: + event (dict): The AWS Config event + context (Any): The AWS Lambda context + """ + LOGGER.info(f"Lambda Handler Context: {context}") LOGGER.info(f"Lambda Handler Event: {event}") evaluation = evaluate_compliance(event, context) config = boto3.client('config') @@ -126,10 +166,10 @@ def lambda_handler(event: dict, context: Any) -> None: { 'ComplianceResourceType': 'AWS::S3::Bucket', 'ComplianceResourceId': params.get('BucketName'), - 'ComplianceType': evaluation['ComplianceType'], # type: ignore - 'Annotation': evaluation['Annotation'], # type: ignore - 'OrderingTimestamp': evaluation['OrderingTimestamp'] # type: ignore + 'ComplianceType': evaluation['ComplianceType'], # type: ignore + 'Annotation': evaluation['Annotation'], # type: ignore + 'OrderingTimestamp': evaluation['OrderingTimestamp'] # type: ignore } ], ResultToken=event['resultToken'] - ) \ No newline at end of file + ) From 6896d23e6de2c34daf7fbb0e43d86bbce257fdc2 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 19:51:27 -0700 Subject: [PATCH 316/395] fix flake8 issues in config rules --- .../app.py | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index bad0d0c79..d26e628fe 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -1,3 +1,12 @@ +"""Config rule to check the guardrail encryption for Bedrock environemts. + +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any import boto3 import json @@ -17,9 +26,17 @@ bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) -def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: - """Evaluates if Bedrock guardrails are encrypted with a KMS key""" - + +def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ004 + """Evaluate if Bedrock guardrails are encrypted with a KMS key. + + Args: + rule_parameters (dict): The rule parameters + + Returns: + tuple[str, str]: The compliance type and annotation + """ + LOGGER.info(f"Rule parameters: {json.dumps(rule_parameters)}") try: response = bedrock_client.list_guardrails() guardrails = response.get('guardrails', []) @@ -32,20 +49,26 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: guardrail_id = guardrail['id'] guardrail_name = guardrail['name'] guardrail_detail = bedrock_client.get_guardrail(guardrailIdentifier=guardrail_id) - + if 'kmsKeyArn' not in guardrail_detail: unencrypted_guardrails.append(guardrail_name) if unencrypted_guardrails: return 'NON_COMPLIANT', f"The following Bedrock guardrails are not encrypted with a KMS key: {', '.join(unencrypted_guardrails)}" - else: - return 'COMPLIANT', "All Bedrock guardrails are encrypted with a KMS key" + return 'COMPLIANT', "All Bedrock guardrails are encrypted with a KMS key" except Exception as e: LOGGER.error(f"Error evaluating Bedrock guardrails encryption: {str(e)}") return 'ERROR', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event: dict, context: Any) -> None: + +def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 + """Lambda handler. + + Args: + event (dict): Lambda event object + context (Any): Lambda context object + """ LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -53,7 +76,7 @@ def lambda_handler(event: dict, context: Any) -> None: rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} compliance_type, annotation = evaluate_compliance(rule_parameters) - + evaluation = { 'ComplianceResourceType': 'AWS::::Account', 'ComplianceResourceId': event['accountId'], From dafd9dd1dac216f2a02d16a520546993e2acd3d3 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 20:14:35 -0700 Subject: [PATCH 317/395] fix flake8 issues with config rules --- .../rules/sra_bedrock_check_guardrails/app.py | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 12bac5d7c..ba6654d78 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -1,3 +1,12 @@ +"""Config rule to check for the existence of guardrails for Bedrock environemts. + +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any import boto3 import json @@ -20,10 +29,20 @@ 'contextual_grounding': True } -# def evaluate_compliance(configuration_item: str, rule_parameters: dict) -> str: -# return 'NOT_APPLICABLE' -def lambda_handler(event: dict, context: Any) -> dict: +def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U100 + """Lambda handler. + + Args: + event (dict): The AWS Config event + context (Any): Lambda context object + + Raises: + Exception: Any exception thrown by the lambda function + + Returns: + dict: The evaluation results + """ LOGGER.info("Starting lambda_handler function") bedrock = boto3.client('bedrock') @@ -89,7 +108,7 @@ def lambda_handler(event: dict, context: Any) -> dict: else: compliance_type = 'NON_COMPLIANT' annotation = 'No Bedrock guardrails contain all required features. Missing features per guardrail:\n' - for guardrail, missing in non_compliant_guardrails.items(): # type: ignore + for guardrail, missing in non_compliant_guardrails.items(): # type: ignore annotation += f"- {guardrail}: missing {', '.join(missing)}\n" LOGGER.info(f"Account is NON_COMPLIANT. {annotation}") From 2ae958286a42d4ee00fb169a1e8784600357e909 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 20:31:42 -0700 Subject: [PATCH 318/395] fix flake8 errors in config rules --- .../sra_bedrock_check_iam_user_access/app.py | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py index 452f7a0e4..75e847ca2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -1,10 +1,17 @@ +"""Config rule to check iam user access for Bedrock environemts. + +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any -import botocore import boto3 import json -import datetime import logging -import os # maybe not needed for logging +import os # Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). ASSUME_ROLE_MODE = False @@ -25,9 +32,16 @@ iam_client = session.client("iam") -def evaluate_compliance(event: dict, context: Any) -> dict: +def evaluate_compliance(event: dict, context: Any) -> dict: # noqa: CCR001, U100 """ - Evaluates compliance for the given AWS Config event. + Evaluate compliance for the given AWS Config event. + + Args: + event (dict): AWS Config event data + context (Any): Lambda context object + + Returns: + dict: Compliance evaluation result """ LOGGER.info(f"eval compliance event: {event}") # Fetch IAM users @@ -47,7 +61,7 @@ def evaluate_compliance(event: dict, context: Any) -> dict: for policy in user_policies: LOGGER.info(f"policy: {policy}") policy_document = iam_client.get_user_policy(UserName=user_name, PolicyName=policy)["PolicyDocument"] - if check_policy_document(policy_document): # type: ignore + if check_policy_document(policy_document): # type: ignore LOGGER.info("User policy has access") has_access = True break @@ -56,7 +70,7 @@ def evaluate_compliance(event: dict, context: Any) -> dict: group_policies = iam_client.list_group_policies(GroupName=group["GroupName"])["PolicyNames"] for policy in group_policies: policy_document = iam_client.get_group_policy(GroupName=group["GroupName"], PolicyName=policy)["PolicyDocument"] - if check_policy_document(policy_document): # type: ignore + if check_policy_document(policy_document): # type: ignore LOGGER.info("Group policy has access") has_access = True break @@ -64,8 +78,9 @@ def evaluate_compliance(event: dict, context: Any) -> dict: for managed_policy in managed_policies: LOGGER.info(f"managed policy: {managed_policy}") managed_policy_version = iam_client.get_policy(PolicyArn=managed_policy["PolicyArn"])["Policy"]["DefaultVersionId"] - managed_policy_document = iam_client.get_policy_version(PolicyArn=managed_policy["PolicyArn"], VersionId=managed_policy_version)["PolicyVersion"]["Document"] - if check_policy_document(managed_policy_document): # type: ignore + managed_policy_document = iam_client.get_policy_version(PolicyArn=managed_policy["PolicyArn"], VersionId=managed_policy_version)[ + "PolicyVersion"]["Document"] + if check_policy_document(managed_policy_document): # type: ignore LOGGER.info("Managed policy has access") has_access = True break @@ -82,18 +97,22 @@ def evaluate_compliance(event: dict, context: Any) -> dict: annotation = "No IAM users have access to the Amazon Bedrock service." LOGGER.info(f"account id: {event['awsAccountId']}") - evaluation_result = { + return { "ComplianceType": compliance_type, "Annotation": annotation, "EvaluationResultIdentifier": {"EvaluationResultQualifier": {"ResourceId": event["awsAccountId"]}}, } - return evaluation_result - def check_policy_document(policy_document: dict) -> bool: """ - Checks if the given policy document allows access to the Bedrock service. + Check if the given policy document allows access to the Bedrock service. + + Args: + policy_document (dict): The policy document to check + + Returns: + bool: True if the policy document allows access to the Bedrock service, False otherwise """ statements = policy_document["Statement"] for statement in statements: @@ -112,6 +131,13 @@ def check_policy_document(policy_document: dict) -> bool: def lambda_handler(event: dict, context: Any) -> dict: """ AWS Lambda function entry point. + + Args: + event (dict): AWS Lambda event data + context (Any): AWS Lambda context object + + Returns: + dict: Compliance evaluation result """ LOGGER.info(f"Event: {event}") # Parse the event From f7d3ddebbc255d342df94dc009c0d13ab9196f73 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 20:42:30 -0700 Subject: [PATCH 319/395] fix flake8 issues in config rules --- .../app.py | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index 80e973a06..418e34cbc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -1,3 +1,12 @@ +"""Config rule to check invocation log for Bedrock environemts. + +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any import boto3 import json @@ -18,9 +27,16 @@ config_client = boto3.client('config', region_name=AWS_REGION) logs_client = boto3.client('logs', region_name=AWS_REGION) -def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: - """Evaluates if Bedrock Model Invocation Logging is properly configured for CloudWatch""" - + +def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ004 + """Evaluate if Bedrock Model Invocation Logging is properly configured for CloudWatch. + + Args: + rule_parameters (dict): Rule parameters from AWS Config rule. + + Returns: + tuple[str, str]: Compliance type and annotation message. + """ # Parse rule parameters params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} check_retention = params.get('check_retention', 'true').lower() == 'true' @@ -31,7 +47,6 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: LOGGER.info(f"Bedrock get_model_invocation_logging_configuration response: {response}") logging_config = response.get('loggingConfig', {}) LOGGER.info(f"Bedrock Model Invocation Logging Configuration: {logging_config}") - cloudwatch_config = logging_config.get('cloudWatchConfig', {}) LOGGER.info(f"Bedrock Model Invocation config: {cloudwatch_config}") log_group_name = cloudwatch_config.get('logGroupName', "") @@ -54,14 +69,20 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: if issues: return 'NON_COMPLIANT', f"CloudWatch logging enabled but {', '.join(issues)}" - else: - return 'COMPLIANT', f"CloudWatch logging properly configured for Bedrock Model Invocation Logging. Log Group: {log_group_name}" + return 'COMPLIANT', f"CloudWatch logging properly configured for Bedrock Model Invocation Logging. Log Group: {log_group_name}" except Exception as e: LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") return 'INSUFFICIENT_DATA', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event: dict, context: Any) -> None: + +def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 + """Lambda handler. + + Args: + event (dict): Lambda event object + context (Any): Lambda context object + """ LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -69,7 +90,7 @@ def lambda_handler(event: dict, context: Any) -> None: rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} compliance_type, annotation = evaluate_compliance(rule_parameters) - + evaluation = { 'ComplianceResourceType': 'AWS::::Account', 'ComplianceResourceId': event['accountId'], @@ -86,4 +107,4 @@ def lambda_handler(event: dict, context: Any) -> None: ResultToken=event['resultToken'] ) - LOGGER.info("Compliance evaluation complete.") \ No newline at end of file + LOGGER.info("Compliance evaluation complete.") From eb55bd19f027fb645fae23528f4293dbf954e3d3 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 21:04:22 -0700 Subject: [PATCH 320/395] fix flake8 config issues --- .../app.py | 2 +- .../app.py | 41 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index 418e34cbc..b055a74c1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -1,4 +1,4 @@ -"""Config rule to check invocation log for Bedrock environemts. +"""Config rule to check invocation log cloudwatch enabled for Bedrock environemts. Version: 1.0 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index bfbb1f780..dc2df7581 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -1,3 +1,12 @@ +"""Config rule to check invocation log s3 enabled for Bedrock environemts. + +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any import boto3 import json @@ -20,9 +29,17 @@ config_client = boto3.client('config', region_name=AWS_REGION) s3_client = boto3.client('s3', region_name=AWS_REGION) -def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: - """Evaluates if Bedrock Model Invocation Logging is properly configured for S3""" - + +def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ004, CCR001, C901 + """Evaluate if Bedrock Model Invocation Logging is properly configured for S3. + + Args: + rule_parameters (dict): Rule parameters from AWS Config. + + Returns: + tuple[str, str]: Compliance status and annotation message. + + """ # Parse rule parameters params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} check_retention = params.get('check_retention', 'true').lower() == 'true' @@ -34,7 +51,7 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: try: response = bedrock_client.get_model_invocation_logging_configuration() logging_config = response.get('loggingConfig', {}) - + s3_config = logging_config.get('s3Config', {}) LOGGER.info(f"Bedrock Model Invocation S3 config: {s3_config}") bucket_name = s3_config.get('bucketName', "") @@ -81,14 +98,20 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: if issues: return 'NON_COMPLIANT', f"S3 logging enabled but {', '.join(issues)}" - else: - return 'COMPLIANT', f"S3 logging properly configured for Bedrock Model Invocation Logging. Bucket: {bucket_name}" + return 'COMPLIANT', f"S3 logging properly configured for Bedrock Model Invocation Logging. Bucket: {bucket_name}" except Exception as e: LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") return 'INSUFFICIENT_DATA', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event: dict, context: Any) -> None: + +def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 + """Lambda handler. + + Args: + event (dict): Config event data + context (Any): Lambda event object + """ LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -96,7 +119,7 @@ def lambda_handler(event: dict, context: Any) -> None: rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} compliance_type, annotation = evaluate_compliance(rule_parameters) - + evaluation = { 'ComplianceResourceType': 'AWS::::Account', 'ComplianceResourceId': event['accountId'], @@ -113,4 +136,4 @@ def lambda_handler(event: dict, context: Any) -> None: ResultToken=event['resultToken'] ) - LOGGER.info("Compliance evaluation complete.") \ No newline at end of file + LOGGER.info("Compliance evaluation complete.") From 7530e0ea771eea85b58ae1d63e56f500868cf664 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 21:26:35 -0700 Subject: [PATCH 321/395] fix flake8 issues with config rules --- .../sra_bedrock_check_s3_endpoints/app.py | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py index d3a159355..07fbf6bd8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py @@ -1,4 +1,13 @@ +"""Config rule to check s3 endpoints for Bedrock environemts. + +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any import boto3 import json @@ -18,14 +27,22 @@ ec2_client = boto3.client('ec2', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) -def evaluate_compliance(configuration_item: dict) -> tuple[str, str]: - """Evaluates if an S3 Gateway Endpoint is in place for the VPC""" - + +def evaluate_compliance(configuration_item: dict) -> tuple[str, str]: # noqa: CFQ004 + """Evaluate if an S3 Gateway Endpoint is in place for the VPC. + + Args: + configuration_item (dict): The AWS Config rule configuration item. + + Returns: + tuple[str, str]: Compliance type and annotation message. + + """ if configuration_item['resourceType'] != 'AWS::EC2::VPC': return 'NOT_APPLICABLE', "Resource is not a VPC" vpc_id = configuration_item['configuration']['vpcId'] - + try: response = ec2_client.describe_vpc_endpoints( Filters=[ @@ -38,14 +55,20 @@ def evaluate_compliance(configuration_item: dict) -> tuple[str, str]: if response['VpcEndpoints']: endpoint_id = response['VpcEndpoints'][0]['VpcEndpointId'] return 'COMPLIANT', f"S3 Gateway Endpoint is in place for VPC {vpc_id}. Endpoint ID: {endpoint_id}" - else: - return 'NON_COMPLIANT', f"S3 Gateway Endpoint is not in place for VPC {vpc_id}" + return 'NON_COMPLIANT', f"S3 Gateway Endpoint is not in place for VPC {vpc_id}" except Exception as e: LOGGER.error(f"Error evaluating S3 Gateway Endpoint configuration: {str(e)}") return 'ERROR', f"Error evaluating compliance: {str(e)}" -def lambda_handler(event: dict, context: Any) -> None: + +def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 + """Lambda handler. + + Args: + event (dict): Config event object + context (Any): Lambda context object + """ LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -88,4 +111,4 @@ def lambda_handler(event: dict, context: Any) -> None: ResultToken=event['resultToken'] ) - LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") \ No newline at end of file + LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") From 0399c3a7adb037335ad2768ec9256f8f78651061 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 22:04:19 -0700 Subject: [PATCH 322/395] fix flake8 issues with config rules --- .../sra_bedrock_check_vpc_endpoints/app.py | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py index 9e863cffd..55dbc3555 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -1,4 +1,12 @@ +"""Config rule to check vpc endpoints for Bedrock environemts. +Version: 1.0 + +Config rule for SRA in the repo, https://github.com/aws-samples/aws-security-reference-architecture-examples + +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" from typing import Any import boto3 import json @@ -18,9 +26,17 @@ ec2_client = boto3.client('ec2', region_name=AWS_REGION) config_client = boto3.client('config', region_name=AWS_REGION) + def evaluate_compliance(vpc_id: str, rule_parameters: dict) -> tuple[str, str]: - """Evaluates if the required VPC endpoints are in place""" - + """Evaluate if the required VPC endpoints are in place. + + Args: + vpc_id (str): VPC ID + rule_parameters (dict): Rule parameters + + Returns: + tuple[str, str]: Compliance type and annotation + """ # Parse rule parameters params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} check_bedrock = params.get('check_bedrock', 'true').lower() == 'true' @@ -40,9 +56,7 @@ def evaluate_compliance(vpc_id: str, rule_parameters: dict) -> tuple[str, str]: # Get VPC endpoints response = ec2_client.describe_vpc_endpoints(Filters=[{'Name': 'vpc-id', 'Values': [vpc_id]}]) - existing_endpoints = [endpoint['ServiceName'] for endpoint in response['VpcEndpoints']] - LOGGER.info(f"Checking VPC {vpc_id} for endpoints: {required_endpoints}") LOGGER.info(f"Existing endpoints: {existing_endpoints}") @@ -51,11 +65,17 @@ def evaluate_compliance(vpc_id: str, rule_parameters: dict) -> tuple[str, str]: if missing_endpoints: LOGGER.info(f"Missing endpoints for VPC {vpc_id}: {missing_endpoints}") return 'NON_COMPLIANT', f"VPC {vpc_id} is missing the following Bedrock endpoints: {', '.join(missing_endpoints)}" - else: - LOGGER.info(f"All required endpoints are in place for VPC {vpc_id}") - return 'COMPLIANT', f"VPC {vpc_id} has all required Bedrock endpoints: {', '.join(required_endpoints)}" + LOGGER.info(f"All required endpoints are in place for VPC {vpc_id}") + return 'COMPLIANT', f"VPC {vpc_id} has all required Bedrock endpoints: {', '.join(required_endpoints)}" + + +def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 + """Lambda handler. -def lambda_handler(event: dict, context: Any) -> None: + Args: + event (dict): Config event object + context (Any): Lambda context object + """ LOGGER.info('Evaluating compliance for AWS Config rule') LOGGER.info(f"Event: {json.dumps(event)}") @@ -100,4 +120,4 @@ def lambda_handler(event: dict, context: Any) -> None: ResultToken=event['resultToken'] ) - LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") \ No newline at end of file + LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") From 92e9d06e6ec55381adde7d1bc4798973a1da2051 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 11 Dec 2024 22:46:20 -0700 Subject: [PATCH 323/395] fix code for new sts class name --- .../solutions/genai/bedrock_org/lambda/src/sra_sns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 0d35d7af7..9f94e2a12 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -49,7 +49,7 @@ class SRASNS: LOGGER.exception(UNEXPECTED) raise ValueError("Unexpected error executing Lambda function. Review CloudWatch logs for details.") from None - sts = sra_sts.sra_sts() + sts = sra_sts.SRASTS() def find_sns_topic(self, topic_name: str, region: str = "default", account: str = "default") -> str | None: """Find SNS Topic ARN. From a094cfae81c93dfc6c59d139ee90527abbf19440 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 12 Dec 2024 06:58:59 -0700 Subject: [PATCH 324/395] update test params in template --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index b0bc5d29e..9871b0c37 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -98,7 +98,7 @@ Parameters: pBedrockModelEvalBucketRuleParams: Type: String # TODO(liamschn): update default value of pBedrockModelEvalBucketRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"BucketName": "model-invocation-log-bucket-1-221082195774"}}' + Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"BucketName": "model-invocation-log-bucket-221082195774"}}' Description: Bedrock Model Evaluation Job Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ ConstraintDescription: From f60401c177e93c4e6d3bba182663aac72c45e8e0 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 12 Dec 2024 07:24:51 -0700 Subject: [PATCH 325/395] fix flake8 issues in app --- .../genai/bedrock_org/lambda/src/app.py | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 9ef2bc759..0ca0c87f7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -173,20 +173,41 @@ def load_sra_cloudwatch_dashboard() -> dict: "SRA_ALARM_EMAIL": r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', - "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', - "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', - "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$', - "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$', - "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-CHECK-S3-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', - "SRA-BEDROCK-FILTER-SERVICE-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-FILTER-BUCKET-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$', - "SRA-BEDROCK-FILTER-PROMPT-INJECTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-FILTER-SENSITIVE-INFO": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', - "SRA-BEDROCK-CENTRAL-OBSERVABILITY": r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$', + "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*' + + r'"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*' + + r'"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-VPC-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*' + + r'"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-INVOCATION-LOG-CLOUDWATCH": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:' + + r'\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*' + + r'\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$', + "SRA-BEDROCK-CHECK-INVOCATION-LOG-S3": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*' + + r'"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*' + + r'"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$', + "SRA-BEDROCK-CHECK-CLOUDWATCH-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*' + + r'\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-CHECK-S3-ENDPOINTS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-CHECK-GUARDRAIL-ENCRYPTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*' + + r'\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', + "SRA-BEDROCK-FILTER-SERVICE-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-FILTER-BUCKET-CHANGES": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*' + + r'\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$', + "SRA-BEDROCK-FILTER-PROMPT-INJECTION": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-FILTER-SENSITIVE-INFO": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$', + "SRA-BEDROCK-CENTRAL-OBSERVABILITY": r'^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*' + + r'\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$', } # Instantiate sra class objects From 09ae60873c713f456650526e62e5a50d42305f7e Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 12 Dec 2024 07:42:10 -0700 Subject: [PATCH 326/395] updating log message --- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 93810cf61..ac02c75b3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -284,5 +284,5 @@ def find_permission(self, function_name: str, statement_id: str) -> bool: return True return False except ClientError as e: - self.LOGGER.error(e) + self.LOGGER.info(f"Error finding lambda permissions: {e}") return False From 18c65f83ada61e3c488278b75b995ae3e18bb704 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 12 Dec 2024 14:54:58 -0700 Subject: [PATCH 327/395] fix for checkov errors; added DLQ and concurrency --- .../templates/sra-bedrock-org-main.yaml | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 9871b0c37..4d744eb0c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -412,6 +412,14 @@ Resources: Action: - 'sts:AssumeRole' Policies: + - PolicyName: !Sub '${pSRASolutionName}-DLQAccess' + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - sqs:SendMessage + Resource: !GetAtt rBedrockOrgDLQ.Arn - PolicyDocument: Version: '2012-10-17' Statement: @@ -615,6 +623,11 @@ Resources: rBedrockOrgLambdaFunction: Type: AWS::Lambda::Function + Metadata: + checkov: + skip: + - id: CKV_AWS_117 + comment: "This Lambda does not require VPC access as it only interacts with public AWS services." Properties: FunctionName: !Ref pSRASolutionName Runtime: python3.12 @@ -628,7 +641,10 @@ Resources: S3Key: !Sub ${pSRASolutionName}/lambda_code/${pSRASolutionName}.zip Timeout: 900 MemorySize: 512 - + ReservedConcurrentExecutions: 10 + DeadLetterConfig: + TargetArn: !GetAtt rBedrockOrgDLQ.Arn + rBedrockOrgLambdaCustomResource: Type: Custom::LambdaCustomResource Properties: @@ -667,6 +683,16 @@ Resources: Principal: cloudformation.amazonaws.com SourceArn: !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stackSet/${AWS::StackName}/*' + rBedrockOrgDLQ: + Type: AWS::SQS::Queue + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Properties: + QueueName: !Sub "${pSRASolutionName}-DLQ" + KmsMasterKeyId: alias/aws/sqs + MessageRetentionPeriod: 1209600 # 14 days + + Outputs: BedrockOrgLambdaFunctionArn: Description: ARN of the Lambda function From f905c8903fb0a09b84f1f2ec040fa4886341385a Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 12 Dec 2024 15:30:23 -0700 Subject: [PATCH 328/395] fix issues for isort linting --- .../app.py | 7 ++--- .../sra_bedrock_check_eval_job_bucket/app.py | 9 ++++--- .../app.py | 7 ++--- .../rules/sra_bedrock_check_guardrails/app.py | 9 ++++--- .../sra_bedrock_check_iam_user_access/app.py | 5 ++-- .../app.py | 7 ++--- .../app.py | 7 ++--- .../sra_bedrock_check_s3_endpoints/app.py | 7 ++--- .../sra_bedrock_check_vpc_endpoints/app.py | 7 ++--- .../genai/bedrock_org/lambda/src/app.py | 27 +++++++++---------- .../bedrock_org/lambda/src/cfnresponse.py | 4 ++- .../bedrock_org/lambda/src/sra_cloudwatch.py | 4 +-- .../bedrock_org/lambda/src/sra_config.py | 7 ++--- .../bedrock_org/lambda/src/sra_dynamodb.py | 6 +++-- .../genai/bedrock_org/lambda/src/sra_iam.py | 9 +++---- .../genai/bedrock_org/lambda/src/sra_kms.py | 12 +++------ .../bedrock_org/lambda/src/sra_lambda.py | 2 -- .../genai/bedrock_org/lambda/src/sra_repo.py | 8 +++--- .../genai/bedrock_org/lambda/src/sra_s3.py | 2 +- .../genai/bedrock_org/lambda/src/sra_sns.py | 8 ++---- .../bedrock_org/lambda/src/sra_ssm_params.py | 1 - .../genai/bedrock_org/lambda/src/sra_sts.py | 2 +- 22 files changed, 75 insertions(+), 82 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py index ed0862edd..4075644f5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py @@ -7,11 +7,12 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ -from typing import Any -import boto3 import json -import os import logging +import os +from typing import Any + +import boto3 # Setup Default Logger LOGGER = logging.getLogger(__name__) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index cc754dfb1..23dad9c3d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -7,13 +7,14 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ +import ast +import logging +import os +from datetime import datetime from typing import Any + import boto3 from botocore.exceptions import ClientError -from datetime import datetime -import logging -import os -import ast # Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). ASSUME_ROLE_MODE = False diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index d26e628fe..513d6b7c0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -7,11 +7,12 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ -from typing import Any -import boto3 import json -import os import logging +import os +from typing import Any + +import boto3 # Setup Default Logger LOGGER = logging.getLogger(__name__) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index ba6654d78..084887201 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -7,13 +7,14 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ -from typing import Any -import boto3 -import json -from datetime import datetime import ast +import json import logging import os +from datetime import datetime +from typing import Any + +import boto3 # Setup Default Logger LOGGER = logging.getLogger(__name__) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py index 75e847ca2..18420a5b6 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -7,11 +7,12 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ -from typing import Any -import boto3 import json import logging import os +from typing import Any + +import boto3 # Set to True to get the lambda to assume the Role attached on the Config Service (useful for cross-account). ASSUME_ROLE_MODE = False diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index b055a74c1..a3d242587 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -7,11 +7,12 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ -from typing import Any -import boto3 import json -import os import logging +import os +from typing import Any + +import boto3 # Setup Default Logger LOGGER = logging.getLogger(__name__) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index dc2df7581..31a4932cc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -7,11 +7,12 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ -from typing import Any -import boto3 import json -import os import logging +import os +from typing import Any + +import boto3 import botocore import botocore.exceptions diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py index 07fbf6bd8..ef27eee1f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py @@ -8,11 +8,12 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ -from typing import Any -import boto3 import json -import os import logging +import os +from typing import Any + +import boto3 # Setup Default Logger LOGGER = logging.getLogger(__name__) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py index 55dbc3555..c9de5e05a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -7,11 +7,12 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ -from typing import Any -import boto3 import json -import os import logging +import os +from typing import Any + +import boto3 # Setup Default Logger LOGGER = logging.getLogger(__name__) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 0ca0c87f7..d868a46a6 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -9,28 +9,27 @@ SPDX-License-Identifier: MIT-0 """ import copy -from datetime import datetime import json -import os import logging -from pathlib import Path +import os import re +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Literal, Optional + import boto3 import cfnresponse - -import sra_s3 -import sra_repo -import sra_ssm_params -import sra_iam +import sra_cloudwatch +import sra_config import sra_dynamodb -import sra_sts +import sra_iam +import sra_kms import sra_lambda +import sra_repo +import sra_s3 import sra_sns -import sra_config -import sra_cloudwatch -import sra_kms - -from typing import Dict, Any, List, Literal, Optional +import sra_ssm_params +import sra_sts # TODO(liamschn): deploy example bedrock guardrail # TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority/not necessary? diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py index 485f2e31e..21d176d48 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py @@ -3,9 +3,11 @@ # SPDX-License-Identifier: MIT-0 from __future__ import print_function -import urllib3 + import json +import urllib3 + SUCCESS = "SUCCESS" FAILED = "FAILED" diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 119299c95..cc3141117 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -10,17 +10,15 @@ from __future__ import annotations +import json import logging import os - from typing import TYPE_CHECKING, Literal import boto3 from botocore.config import Config from botocore.exceptions import ClientError -import json - if TYPE_CHECKING: from mypy_boto3_cloudwatch import CloudWatchClient from mypy_boto3_logs import CloudWatchLogsClient diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 8e98e1cba..38570c371 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -10,22 +10,19 @@ from __future__ import annotations +import json import logging import os - from typing import TYPE_CHECKING, Literal import boto3 from botocore.config import Config from botocore.exceptions import ClientError -import json - - if TYPE_CHECKING: - from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_config import ConfigServiceClient from mypy_boto3_config.type_defs import DescribeConfigRulesResponseTypeDef + from mypy_boto3_organizations import OrganizationsClient class SRAConfig: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index ff2e52a83..de72d807d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -9,14 +9,16 @@ """ import logging -import boto3 import os import random import string from datetime import datetime from time import sleep -from boto3.session import Session from typing import TYPE_CHECKING, Any, Dict, Sequence + +import boto3 +from boto3.session import Session + if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 843e4d5cc..369b19a33 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -7,27 +7,24 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ - from __future__ import annotations +import json import logging import os +import urllib.parse from time import sleep - from typing import TYPE_CHECKING import boto3 from botocore.config import Config from botocore.exceptions import ClientError -import urllib.parse -import json - if TYPE_CHECKING: from mypy_boto3_cloudformation import CloudFormationClient - from mypy_boto3_organizations import OrganizationsClient from mypy_boto3_iam.client import IAMClient from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + from mypy_boto3_organizations import OrganizationsClient class SRAIAM: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py index bb786412e..0322a2bce 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_kms.py @@ -7,27 +7,23 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ - from __future__ import annotations import logging import os - -from typing import TYPE_CHECKING -from typing import cast -from typing import Literal +from typing import TYPE_CHECKING, Literal, cast if TYPE_CHECKING: from mypy_boto3_kms.client import KMSClient from mypy_boto3_kms.type_defs import DescribeKeyResponseTypeDef from boto3 import Session +import json +import urllib.parse + import boto3 from botocore.config import Config -import urllib.parse -import json - class SRAKMS: """Class to represent SRA KMS resources.""" diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index ac02c75b3..405186f39 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -7,13 +7,11 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ - from __future__ import annotations import logging import os from time import sleep - from typing import TYPE_CHECKING import boto3 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 5b8850729..9afc73039 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -8,14 +8,14 @@ SPDX-License-Identifier: MIT-0 """ import logging -import urllib3 -from io import BytesIO -from zipfile import ZipFile -from zipfile import ZIP_DEFLATED import os import shutil import subprocess # noqa S404 (best practice for calling pip from script) import sys +from io import BytesIO +from zipfile import ZIP_DEFLATED, ZipFile + +import urllib3 # TODO(liamschn): need to exclude "inline_" files from the staging process diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 17a34bc8d..ce703d61d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -7,12 +7,12 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ +import json import logging import os import boto3 from botocore.client import ClientError -import json class SRAS3: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 9f94e2a12..6c0ef82f9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -7,23 +7,19 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ - from __future__ import annotations +import json import logging import os -import json - from time import sleep - from typing import TYPE_CHECKING import boto3 +import sra_sts from botocore.config import Config from botocore.exceptions import ClientError -import sra_sts - if TYPE_CHECKING: from mypy_boto3_sns.client import SNSClient from mypy_boto3_sns.type_defs import PublishBatchResponseTypeDef diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py index c6906abb4..185d9235a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -7,7 +7,6 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ - from __future__ import annotations import logging diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index 0483a334d..c9aef48ce 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -13,8 +13,8 @@ import boto3 import botocore -from botocore.config import Config import botocore.exceptions +from botocore.config import Config class SRASTS: From aa2d1fa0035a97c1cc2013d1410a027cee656d71 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 12 Dec 2024 16:32:15 -0700 Subject: [PATCH 329/395] remove/update/eval/defer todos --- .../app.py | 76 +++++---- .../sra_bedrock_check_eval_job_bucket/app.py | 74 +++++---- .../app.py | 45 +++--- .../rules/sra_bedrock_check_guardrails/app.py | 76 +++++---- .../sra_bedrock_check_iam_user_access/app.py | 3 +- .../app.py | 51 +++--- .../app.py | 67 ++++---- .../sra_bedrock_check_s3_endpoints/app.py | 76 +++++---- .../sra_bedrock_check_vpc_endpoints/app.py | 85 +++++----- .../genai/bedrock_org/lambda/src/app.py | 145 ++++++++---------- .../bedrock_org/lambda/src/cfnresponse.py | 21 +-- .../bedrock_org/lambda/src/requirements.txt | 2 - .../bedrock_org/lambda/src/sra_cloudwatch.py | 39 +++-- .../bedrock_org/lambda/src/sra_config.py | 28 ++-- .../bedrock_org/lambda/src/sra_dynamodb.py | 11 +- .../genai/bedrock_org/lambda/src/sra_iam.py | 29 ++-- .../bedrock_org/lambda/src/sra_lambda.py | 16 +- .../genai/bedrock_org/lambda/src/sra_repo.py | 18 +-- .../genai/bedrock_org/lambda/src/sra_s3.py | 5 +- .../genai/bedrock_org/lambda/src/sra_sns.py | 22 +-- .../bedrock_org/lambda/src/sra_ssm_params.py | 5 +- .../genai/bedrock_org/lambda/src/sra_sts.py | 5 +- .../templates/sra-bedrock-org-main.yaml | 49 ++---- 23 files changed, 453 insertions(+), 495 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py index 4075644f5..a92e6785e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py @@ -21,11 +21,11 @@ LOGGER.info(f"boto3 version: {boto3.__version__}") # Get AWS region from environment variable -AWS_REGION = os.environ.get('AWS_REGION') +AWS_REGION = os.environ.get("AWS_REGION") # Initialize AWS clients -ec2_client = boto3.client('ec2', region_name=AWS_REGION) -config_client = boto3.client('config', region_name=AWS_REGION) +ec2_client = boto3.client("ec2", region_name=AWS_REGION) +config_client = boto3.client("config", region_name=AWS_REGION) def evaluate_compliance(vpc_id: str) -> tuple[str, str]: @@ -39,22 +39,19 @@ def evaluate_compliance(vpc_id: str) -> tuple[str, str]: """ try: response = ec2_client.describe_vpc_endpoints( - Filters=[ - {'Name': 'vpc-id', 'Values': [vpc_id]}, - {'Name': 'service-name', 'Values': [f'com.amazonaws.{AWS_REGION}.logs']} - ] + Filters=[{"Name": "vpc-id", "Values": [vpc_id]}, {"Name": "service-name", "Values": [f"com.amazonaws.{AWS_REGION}.logs"]}] ) - endpoints = response['VpcEndpoints'] + endpoints = response["VpcEndpoints"] if endpoints: - endpoint_id = endpoints[0]['VpcEndpointId'] - return 'COMPLIANT', f"CloudWatch gateway endpoint is in place for VPC {vpc_id}. Endpoint ID: {endpoint_id}" - return 'NON_COMPLIANT', f"No CloudWatch gateway endpoint found for VPC {vpc_id}" + endpoint_id = endpoints[0]["VpcEndpointId"] + return "COMPLIANT", f"CloudWatch gateway endpoint is in place for VPC {vpc_id}. Endpoint ID: {endpoint_id}" + return "NON_COMPLIANT", f"No CloudWatch gateway endpoint found for VPC {vpc_id}" except Exception as e: LOGGER.error(f"Error evaluating CloudWatch gateway endpoint for VPC {vpc_id}: {str(e)}") - return 'ERROR', f"Error evaluating compliance: {str(e)}" + return "ERROR", f"Error evaluating compliance: {str(e)}" def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 @@ -64,48 +61,49 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 event (dict): Lambda event object context (Any): Lambda context object """ - LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info("Evaluating compliance for AWS Config rule") LOGGER.info(f"Event: {json.dumps(event)}") - invoking_event = json.loads(event['invokingEvent']) + invoking_event = json.loads(event["invokingEvent"]) evaluations = [] - if invoking_event['messageType'] == 'ScheduledNotification': + if invoking_event["messageType"] == "ScheduledNotification": # This is a scheduled run, evaluate all VPCs vpcs = ec2_client.describe_vpcs() - for vpc in vpcs['Vpcs']: - vpc_id = vpc['VpcId'] + for vpc in vpcs["Vpcs"]: + vpc_id = vpc["VpcId"] compliance_type, annotation = evaluate_compliance(vpc_id) - evaluations.append({ - 'ComplianceResourceType': 'AWS::EC2::VPC', - 'ComplianceResourceId': vpc_id, - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': invoking_event['notificationCreationTime'] - }) + evaluations.append( + { + "ComplianceResourceType": "AWS::EC2::VPC", + "ComplianceResourceId": vpc_id, + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": invoking_event["notificationCreationTime"], + } + ) else: # This is a configuration change event - configuration_item = invoking_event['configurationItem'] - if configuration_item['resourceType'] != 'AWS::EC2::VPC': + configuration_item = invoking_event["configurationItem"] + if configuration_item["resourceType"] != "AWS::EC2::VPC": LOGGER.info(f"Skipping non-VPC resource: {configuration_item['resourceType']}") return - vpc_id = configuration_item['resourceId'] + vpc_id = configuration_item["resourceId"] compliance_type, annotation = evaluate_compliance(vpc_id) - evaluations.append({ - 'ComplianceResourceType': configuration_item['resourceType'], - 'ComplianceResourceId': vpc_id, - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] - }) + evaluations.append( + { + "ComplianceResourceType": configuration_item["resourceType"], + "ComplianceResourceId": vpc_id, + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": configuration_item["configurationItemCaptureTime"], + } + ) # Submit compliance evaluations if evaluations: - config_client.put_evaluations( - Evaluations=evaluations, - ResultToken=event['resultToken'] - ) + config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) - LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") \ No newline at end of file + LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index 23dad9c3d..ab64cfadd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -43,73 +43,73 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: """ LOGGER.info(f"Evaluate Compliance Event: {event}") # Initialize AWS clients - s3 = boto3.client('s3') + s3 = boto3.client("s3") # Get rule parameters - params = ast.literal_eval(event['ruleParameters']) + params = ast.literal_eval(event["ruleParameters"]) LOGGER.info(f"Parameters: {params}") - bucket_name = params.get('BucketName', '') - check_retention = params.get('CheckRetention', 'true').lower() != 'false' - check_encryption = params.get('CheckEncryption', 'true').lower() != 'false' - check_logging = params.get('CheckLogging', 'true').lower() != 'false' - check_object_locking = params.get('CheckObjectLocking', 'true').lower() != 'false' - check_versioning = params.get('CheckVersioning', 'true').lower() != 'false' + bucket_name = params.get("BucketName", "") + check_retention = params.get("CheckRetention", "true").lower() != "false" + check_encryption = params.get("CheckEncryption", "true").lower() != "false" + check_logging = params.get("CheckLogging", "true").lower() != "false" + check_object_locking = params.get("CheckObjectLocking", "true").lower() != "false" + check_versioning = params.get("CheckVersioning", "true").lower() != "false" # Check if the bucket exists if not check_bucket_exists(bucket_name): - return build_evaluation('NOT_APPLICABLE', f"Bucket {bucket_name} does not exist or is not accessible") + return build_evaluation("NOT_APPLICABLE", f"Bucket {bucket_name} does not exist or is not accessible") - compliance_type = 'COMPLIANT' + compliance_type = "COMPLIANT" annotation = [] # Check retention if check_retention: try: retention = s3.get_bucket_lifecycle_configuration(Bucket=bucket_name) - if not any(rule.get('Expiration') for rule in retention.get('Rules', [])): - compliance_type = 'NON_COMPLIANT' + if not any(rule.get("Expiration") for rule in retention.get("Rules", [])): + compliance_type = "NON_COMPLIANT" annotation.append("Retention policy not set") except ClientError: - compliance_type = 'NON_COMPLIANT' + compliance_type = "NON_COMPLIANT" annotation.append("Retention policy not set") # Check encryption if check_encryption: try: encryption = s3.get_bucket_encryption(Bucket=bucket_name) - if 'ServerSideEncryptionConfiguration' not in encryption: - compliance_type = 'NON_COMPLIANT' + if "ServerSideEncryptionConfiguration" not in encryption: + compliance_type = "NON_COMPLIANT" annotation.append("KMS CMK encryption not enabled") except ClientError: - compliance_type = 'NON_COMPLIANT' + compliance_type = "NON_COMPLIANT" annotation.append("KMS CMK encryption not enabled") # Check logging if check_logging: logging = s3.get_bucket_logging(Bucket=bucket_name) - if 'LoggingEnabled' not in logging: - compliance_type = 'NON_COMPLIANT' + if "LoggingEnabled" not in logging: + compliance_type = "NON_COMPLIANT" annotation.append("Server access logging not enabled") # Check object locking if check_object_locking: try: object_locking = s3.get_object_lock_configuration(Bucket=bucket_name) - if 'ObjectLockConfiguration' not in object_locking: - compliance_type = 'NON_COMPLIANT' + if "ObjectLockConfiguration" not in object_locking: + compliance_type = "NON_COMPLIANT" annotation.append("Object locking not enabled") except ClientError: - compliance_type = 'NON_COMPLIANT' + compliance_type = "NON_COMPLIANT" annotation.append("Object locking not enabled") # Check versioning if check_versioning: versioning = s3.get_bucket_versioning(Bucket=bucket_name) - if versioning.get('Status') != 'Enabled': - compliance_type = 'NON_COMPLIANT' + if versioning.get("Status") != "Enabled": + compliance_type = "NON_COMPLIANT" annotation.append("Versioning not enabled") - annotation_str = '; '.join(annotation) if annotation else "All checked features are compliant" + annotation_str = "; ".join(annotation) if annotation else "All checked features are compliant" return build_evaluation(compliance_type, annotation_str) @@ -122,10 +122,10 @@ def check_bucket_exists(bucket_name: str) -> Any: Returns: Any: True if the bucket exists and is accessible, False otherwise """ - s3 = boto3.client('s3') + s3 = boto3.client("s3") try: response = s3.list_buckets() - buckets = [bucket['Name'] for bucket in response['Buckets']] + buckets = [bucket["Name"] for bucket in response["Buckets"]] return bucket_name in buckets except ClientError as e: LOGGER.info(f"An error occurred: {e}") @@ -143,11 +143,7 @@ def build_evaluation(compliance_type: str, annotation: str) -> Any: Any: The evaluation compliance type and annotation """ LOGGER.info(f"Build Evaluation Compliance Type: {compliance_type} Annotation: {annotation}") - return { - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': datetime.now().isoformat() - } + return {"ComplianceType": compliance_type, "Annotation": annotation, "OrderingTimestamp": datetime.now().isoformat()} def lambda_handler(event: dict, context: Any) -> None: @@ -160,17 +156,17 @@ def lambda_handler(event: dict, context: Any) -> None: LOGGER.info(f"Lambda Handler Context: {context}") LOGGER.info(f"Lambda Handler Event: {event}") evaluation = evaluate_compliance(event, context) - config = boto3.client('config') - params = ast.literal_eval(event['ruleParameters']) + config = boto3.client("config") + params = ast.literal_eval(event["ruleParameters"]) config.put_evaluations( Evaluations=[ { - 'ComplianceResourceType': 'AWS::S3::Bucket', - 'ComplianceResourceId': params.get('BucketName'), - 'ComplianceType': evaluation['ComplianceType'], # type: ignore - 'Annotation': evaluation['Annotation'], # type: ignore - 'OrderingTimestamp': evaluation['OrderingTimestamp'] # type: ignore + "ComplianceResourceType": "AWS::S3::Bucket", + "ComplianceResourceId": params.get("BucketName"), + "ComplianceType": evaluation["ComplianceType"], # type: ignore + "Annotation": evaluation["Annotation"], # type: ignore + "OrderingTimestamp": evaluation["OrderingTimestamp"], # type: ignore } ], - ResultToken=event['resultToken'] + ResultToken=event["resultToken"], ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index 513d6b7c0..186e4d327 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -21,11 +21,11 @@ LOGGER.info(f"boto3 version: {boto3.__version__}") # Get AWS region from environment variable -AWS_REGION = os.environ.get('AWS_REGION') +AWS_REGION = os.environ.get("AWS_REGION") # Initialize AWS clients -bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) -config_client = boto3.client('config', region_name=AWS_REGION) +bedrock_client = boto3.client("bedrock", region_name=AWS_REGION) +config_client = boto3.client("config", region_name=AWS_REGION) def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ004 @@ -40,27 +40,27 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 LOGGER.info(f"Rule parameters: {json.dumps(rule_parameters)}") try: response = bedrock_client.list_guardrails() - guardrails = response.get('guardrails', []) + guardrails = response.get("guardrails", []) if not guardrails: - return 'NON_COMPLIANT', "No Bedrock guardrails found" + return "NON_COMPLIANT", "No Bedrock guardrails found" unencrypted_guardrails: list[str] = [] for guardrail in guardrails: - guardrail_id = guardrail['id'] - guardrail_name = guardrail['name'] + guardrail_id = guardrail["id"] + guardrail_name = guardrail["name"] guardrail_detail = bedrock_client.get_guardrail(guardrailIdentifier=guardrail_id) - if 'kmsKeyArn' not in guardrail_detail: + if "kmsKeyArn" not in guardrail_detail: unencrypted_guardrails.append(guardrail_name) if unencrypted_guardrails: - return 'NON_COMPLIANT', f"The following Bedrock guardrails are not encrypted with a KMS key: {', '.join(unencrypted_guardrails)}" - return 'COMPLIANT', "All Bedrock guardrails are encrypted with a KMS key" + return "NON_COMPLIANT", f"The following Bedrock guardrails are not encrypted with a KMS key: {', '.join(unencrypted_guardrails)}" + return "COMPLIANT", "All Bedrock guardrails are encrypted with a KMS key" except Exception as e: LOGGER.error(f"Error evaluating Bedrock guardrails encryption: {str(e)}") - return 'ERROR', f"Error evaluating compliance: {str(e)}" + return "ERROR", f"Error evaluating compliance: {str(e)}" def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 @@ -70,28 +70,25 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 event (dict): Lambda event object context (Any): Lambda context object """ - LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info("Evaluating compliance for AWS Config rule") LOGGER.info(f"Event: {json.dumps(event)}") - invoking_event = json.loads(event['invokingEvent']) - rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} + invoking_event = json.loads(event["invokingEvent"]) + rule_parameters = json.loads(event["ruleParameters"]) if "ruleParameters" in event else {} compliance_type, annotation = evaluate_compliance(rule_parameters) evaluation = { - 'ComplianceResourceType': 'AWS::::Account', - 'ComplianceResourceId': event['accountId'], - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': invoking_event['notificationCreationTime'] + "ComplianceResourceType": "AWS::::Account", + "ComplianceResourceId": event["accountId"], + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": invoking_event["notificationCreationTime"], } LOGGER.info(f"Compliance evaluation result: {compliance_type}") LOGGER.info(f"Annotation: {annotation}") - config_client.put_evaluations( - Evaluations=[evaluation], - ResultToken=event['resultToken'] - ) + config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) - LOGGER.info("Compliance evaluation complete.") \ No newline at end of file + LOGGER.info("Compliance evaluation complete.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 084887201..6c83d8d09 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -23,11 +23,11 @@ LOGGER.info(f"boto3 version: {boto3.__version__}") GUARDRAIL_FEATURES = { - 'content_filters': True, - 'denied_topics': True, - 'word_filters': True, - 'sensitive_info_filters': True, - 'contextual_grounding': True + "content_filters": True, + "denied_topics": True, + "word_filters": True, + "sensitive_info_filters": True, + "contextual_grounding": True, } @@ -45,11 +45,11 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 dict: The evaluation results """ LOGGER.info("Starting lambda_handler function") - bedrock = boto3.client('bedrock') + bedrock = boto3.client("bedrock") # Parse rule parameters safely using ast.literal_eval LOGGER.info("Parsing rule parameters") - rule_params = ast.literal_eval(event.get('ruleParameters', '{}')) + rule_params = ast.literal_eval(event.get("ruleParameters", "{}")) LOGGER.info(f"Rule parameters: {rule_params}") for param, default in GUARDRAIL_FEATURES.items(): GUARDRAIL_FEATURES[param] = rule_params.get(param, default) @@ -57,34 +57,34 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 # List all guardrails LOGGER.info("Listing all Bedrock guardrails") - guardrails = bedrock.list_guardrails()['guardrails'] + guardrails = bedrock.list_guardrails()["guardrails"] LOGGER.info(f"Found {len(guardrails)} guardrails") compliant_guardrails = [] non_compliant_guardrails = {} for guardrail in guardrails: - guardrail_id = guardrail['id'] - guardrail_name = guardrail.get('name', guardrail_id) # Use 'name' if available, otherwise use the identifier + guardrail_id = guardrail["id"] + guardrail_name = guardrail.get("name", guardrail_id) # Use 'name' if available, otherwise use the identifier LOGGER.info(f"Checking guardrail: {guardrail_name} (ID: {guardrail_id})") - + try: guardrail_details = bedrock.get_guardrail(guardrailIdentifier=guardrail_id) - + missing_features = [] for feature, required in GUARDRAIL_FEATURES.items(): if required: LOGGER.info(f"Checking feature: {feature}") - if feature == 'content_filters' and not guardrail_details.get('contentPolicy'): - missing_features.append('content_filters') - elif feature == 'denied_topics' and not guardrail_details.get('topicPolicy'): - missing_features.append('denied_topics') - elif feature == 'word_filters' and not guardrail_details.get('wordPolicy'): - missing_features.append('word_filters') - elif feature == 'sensitive_info_filters' and not guardrail_details.get('sensitiveInformationPolicy'): - missing_features.append('sensitive_info_filters') - elif feature == 'contextual_grounding' and not guardrail_details.get('contextualGroundingPolicy'): - missing_features.append('contextual_grounding') + if feature == "content_filters" and not guardrail_details.get("contentPolicy"): + missing_features.append("content_filters") + elif feature == "denied_topics" and not guardrail_details.get("topicPolicy"): + missing_features.append("denied_topics") + elif feature == "word_filters" and not guardrail_details.get("wordPolicy"): + missing_features.append("word_filters") + elif feature == "sensitive_info_filters" and not guardrail_details.get("sensitiveInformationPolicy"): + missing_features.append("sensitive_info_filters") + elif feature == "contextual_grounding" and not guardrail_details.get("contextualGroundingPolicy"): + missing_features.append("contextual_grounding") if not missing_features: LOGGER.info(f"Guardrail {guardrail_name} is compliant") @@ -92,7 +92,7 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 else: LOGGER.info(f"Guardrail {guardrail_name} is missing features: {missing_features}") non_compliant_guardrails[guardrail_name] = missing_features - + except bedrock.exceptions.ResourceNotFoundException: LOGGER.warning(f"Guardrail {guardrail_name} (ID: {guardrail_id}) not found") except Exception as e: @@ -100,42 +100,36 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 LOGGER.info("Determining overall compliance status") if compliant_guardrails: - compliance_type = 'COMPLIANT' + compliance_type = "COMPLIANT" if len(compliant_guardrails) == 1: annotation = f"The following Bedrock guardrail contains all required features: {compliant_guardrails[0]}" else: annotation = f"The following Bedrock guardrails contain all required features: {', '.join(compliant_guardrails)}" LOGGER.info(f"Account is COMPLIANT. {annotation}") else: - compliance_type = 'NON_COMPLIANT' - annotation = 'No Bedrock guardrails contain all required features. Missing features per guardrail:\n' + compliance_type = "NON_COMPLIANT" + annotation = "No Bedrock guardrails contain all required features. Missing features per guardrail:\n" for guardrail, missing in non_compliant_guardrails.items(): # type: ignore annotation += f"- {guardrail}: missing {', '.join(missing)}\n" LOGGER.info(f"Account is NON_COMPLIANT. {annotation}") evaluation = { - 'ComplianceResourceType': 'AWS::::Account', - 'ComplianceResourceId': event['accountId'], - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': datetime.now().isoformat() + "ComplianceResourceType": "AWS::::Account", + "ComplianceResourceId": event["accountId"], + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": datetime.now().isoformat(), } LOGGER.info("Sending evaluation results to AWS Config") - config = boto3.client('config') - + config = boto3.client("config") + try: - response = config.put_evaluations( - Evaluations=[evaluation], - ResultToken=event['resultToken'] - ) + response = config.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) LOGGER.info(f"Evaluation sent successfully: {response}") except Exception as e: LOGGER.error(f"Error sending evaluation: {str(e)}") raise LOGGER.info("Lambda function execution completed") - return { - 'statusCode': 200, - 'body': json.dumps('Evaluation complete') - } + return {"statusCode": 200, "body": json.dumps("Evaluation complete")} diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py index 18420a5b6..3ced52ea9 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -80,7 +80,8 @@ def evaluate_compliance(event: dict, context: Any) -> dict: # noqa: CCR001, U10 LOGGER.info(f"managed policy: {managed_policy}") managed_policy_version = iam_client.get_policy(PolicyArn=managed_policy["PolicyArn"])["Policy"]["DefaultVersionId"] managed_policy_document = iam_client.get_policy_version(PolicyArn=managed_policy["PolicyArn"], VersionId=managed_policy_version)[ - "PolicyVersion"]["Document"] + "PolicyVersion" + ]["Document"] if check_policy_document(managed_policy_document): # type: ignore LOGGER.info("Managed policy has access") has_access = True diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index a3d242587..63ec355c3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -21,12 +21,12 @@ LOGGER.info(f"boto3 version: {boto3.__version__}") # Get AWS region from environment variable -AWS_REGION = os.environ.get('AWS_REGION') +AWS_REGION = os.environ.get("AWS_REGION") # Initialize AWS clients -bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) -config_client = boto3.client('config', region_name=AWS_REGION) -logs_client = boto3.client('logs', region_name=AWS_REGION) +bedrock_client = boto3.client("bedrock", region_name=AWS_REGION) +config_client = boto3.client("config", region_name=AWS_REGION) +logs_client = boto3.client("logs", region_name=AWS_REGION) def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ004 @@ -40,41 +40,41 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 """ # Parse rule parameters params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} - check_retention = params.get('check_retention', 'true').lower() == 'true' - check_encryption = params.get('check_encryption', 'true').lower() == 'true' + check_retention = params.get("check_retention", "true").lower() == "true" + check_encryption = params.get("check_encryption", "true").lower() == "true" try: response = bedrock_client.get_model_invocation_logging_configuration() LOGGER.info(f"Bedrock get_model_invocation_logging_configuration response: {response}") - logging_config = response.get('loggingConfig', {}) + logging_config = response.get("loggingConfig", {}) LOGGER.info(f"Bedrock Model Invocation Logging Configuration: {logging_config}") - cloudwatch_config = logging_config.get('cloudWatchConfig', {}) + cloudwatch_config = logging_config.get("cloudWatchConfig", {}) LOGGER.info(f"Bedrock Model Invocation config: {cloudwatch_config}") - log_group_name = cloudwatch_config.get('logGroupName', "") + log_group_name = cloudwatch_config.get("logGroupName", "") LOGGER.info(f"Bedrock Model Invocation Log Group: {log_group_name}") if not cloudwatch_config or not log_group_name: - return 'NON_COMPLIANT', "CloudWatch logging is not enabled for Bedrock Model Invocation Logging" + return "NON_COMPLIANT", "CloudWatch logging is not enabled for Bedrock Model Invocation Logging" # Check retention and encryption if enabled issues = [] if check_retention: - retention = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)['logGroups'][0].get('retentionInDays') + retention = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)["logGroups"][0].get("retentionInDays") if not retention: issues.append("retention not set") if check_encryption: - encryption = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)['logGroups'][0].get('kmsKeyId') + encryption = logs_client.describe_log_groups(logGroupNamePrefix=log_group_name)["logGroups"][0].get("kmsKeyId") if not encryption: issues.append("encryption not set") if issues: - return 'NON_COMPLIANT', f"CloudWatch logging enabled but {', '.join(issues)}" - return 'COMPLIANT', f"CloudWatch logging properly configured for Bedrock Model Invocation Logging. Log Group: {log_group_name}" + return "NON_COMPLIANT", f"CloudWatch logging enabled but {', '.join(issues)}" + return "COMPLIANT", f"CloudWatch logging properly configured for Bedrock Model Invocation Logging. Log Group: {log_group_name}" except Exception as e: LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") - return 'INSUFFICIENT_DATA', f"Error evaluating compliance: {str(e)}" + return "INSUFFICIENT_DATA", f"Error evaluating compliance: {str(e)}" def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 @@ -84,28 +84,25 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 event (dict): Lambda event object context (Any): Lambda context object """ - LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info("Evaluating compliance for AWS Config rule") LOGGER.info(f"Event: {json.dumps(event)}") - invoking_event = json.loads(event['invokingEvent']) - rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} + invoking_event = json.loads(event["invokingEvent"]) + rule_parameters = json.loads(event["ruleParameters"]) if "ruleParameters" in event else {} compliance_type, annotation = evaluate_compliance(rule_parameters) evaluation = { - 'ComplianceResourceType': 'AWS::::Account', - 'ComplianceResourceId': event['accountId'], - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': invoking_event['notificationCreationTime'] + "ComplianceResourceType": "AWS::::Account", + "ComplianceResourceId": event["accountId"], + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": invoking_event["notificationCreationTime"], } LOGGER.info(f"Compliance evaluation result: {compliance_type}") LOGGER.info(f"Annotation: {annotation}") - config_client.put_evaluations( - Evaluations=[evaluation], - ResultToken=event['resultToken'] - ) + config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) LOGGER.info("Compliance evaluation complete.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index 31a4932cc..2399298cd 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -23,12 +23,12 @@ LOGGER.info(f"boto3 version: {boto3.__version__}") # Get AWS region from environment variable -AWS_REGION = os.environ.get('AWS_REGION') +AWS_REGION = os.environ.get("AWS_REGION") # Initialize AWS clients -bedrock_client = boto3.client('bedrock', region_name=AWS_REGION) -config_client = boto3.client('config', region_name=AWS_REGION) -s3_client = boto3.client('s3', region_name=AWS_REGION) +bedrock_client = boto3.client("bedrock", region_name=AWS_REGION) +config_client = boto3.client("config", region_name=AWS_REGION) +s3_client = boto3.client("s3", region_name=AWS_REGION) def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ004, CCR001, C901 @@ -43,67 +43,67 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 """ # Parse rule parameters params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} - check_retention = params.get('check_retention', 'true').lower() == 'true' - check_encryption = params.get('check_encryption', 'true').lower() == 'true' - check_access_logging = params.get('check_access_logging', 'true').lower() == 'true' - check_object_locking = params.get('check_object_locking', 'true').lower() == 'true' - check_versioning = params.get('check_versioning', 'true').lower() == 'true' + check_retention = params.get("check_retention", "true").lower() == "true" + check_encryption = params.get("check_encryption", "true").lower() == "true" + check_access_logging = params.get("check_access_logging", "true").lower() == "true" + check_object_locking = params.get("check_object_locking", "true").lower() == "true" + check_versioning = params.get("check_versioning", "true").lower() == "true" try: response = bedrock_client.get_model_invocation_logging_configuration() - logging_config = response.get('loggingConfig', {}) + logging_config = response.get("loggingConfig", {}) - s3_config = logging_config.get('s3Config', {}) + s3_config = logging_config.get("s3Config", {}) LOGGER.info(f"Bedrock Model Invocation S3 config: {s3_config}") - bucket_name = s3_config.get('bucketName', "") + bucket_name = s3_config.get("bucketName", "") LOGGER.info(f"Bedrock Model Invocation S3 bucketName: {bucket_name}") if not s3_config or not bucket_name: - return 'NON_COMPLIANT', "S3 logging is not enabled for Bedrock Model Invocation Logging" + return "NON_COMPLIANT", "S3 logging is not enabled for Bedrock Model Invocation Logging" # Check S3 bucket configurations issues = [] if check_retention: lifecycle = s3_client.get_bucket_lifecycle_configuration(Bucket=bucket_name) - if not any(rule.get('Expiration') for rule in lifecycle.get('Rules', [])): + if not any(rule.get("Expiration") for rule in lifecycle.get("Rules", [])): issues.append("retention not set") if check_encryption: encryption = s3_client.get_bucket_encryption(Bucket=bucket_name) - if 'ServerSideEncryptionConfiguration' not in encryption: + if "ServerSideEncryptionConfiguration" not in encryption: issues.append("encryption not set") if check_access_logging: logging = s3_client.get_bucket_logging(Bucket=bucket_name) - if 'LoggingEnabled' not in logging: + if "LoggingEnabled" not in logging: issues.append("server access logging not enabled") if check_versioning: versioning = s3_client.get_bucket_versioning(Bucket=bucket_name) - if versioning.get('Status') != 'Enabled': + if versioning.get("Status") != "Enabled": issues.append("versioning not enabled") try: if check_object_locking: object_lock = s3_client.get_object_lock_configuration(Bucket=bucket_name) - if 'ObjectLockConfiguration' not in object_lock: + if "ObjectLockConfiguration" not in object_lock: issues.append("object locking not enabled") except botocore.exceptions.ClientError as error: - error_code = error.response['Error']['Code'] + error_code = error.response["Error"]["Code"] if error_code == "ObjectLockConfigurationNotFoundError": LOGGER.info(f"Object Lock is not enabled for S3 bucket: {bucket_name}") issues.append("object locking not enabled") else: LOGGER.info(f"Error evaluating Object Lock configuration: {str(error)}") - return 'INSUFFICIENT_DATA', f"Error evaluating Object Lock configuration: {str(error)}" + return "INSUFFICIENT_DATA", f"Error evaluating Object Lock configuration: {str(error)}" if issues: - return 'NON_COMPLIANT', f"S3 logging enabled but {', '.join(issues)}" - return 'COMPLIANT', f"S3 logging properly configured for Bedrock Model Invocation Logging. Bucket: {bucket_name}" + return "NON_COMPLIANT", f"S3 logging enabled but {', '.join(issues)}" + return "COMPLIANT", f"S3 logging properly configured for Bedrock Model Invocation Logging. Bucket: {bucket_name}" except Exception as e: LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") - return 'INSUFFICIENT_DATA', f"Error evaluating compliance: {str(e)}" + return "INSUFFICIENT_DATA", f"Error evaluating compliance: {str(e)}" def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 @@ -113,28 +113,25 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 event (dict): Config event data context (Any): Lambda event object """ - LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info("Evaluating compliance for AWS Config rule") LOGGER.info(f"Event: {json.dumps(event)}") - invoking_event = json.loads(event['invokingEvent']) - rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} + invoking_event = json.loads(event["invokingEvent"]) + rule_parameters = json.loads(event["ruleParameters"]) if "ruleParameters" in event else {} compliance_type, annotation = evaluate_compliance(rule_parameters) evaluation = { - 'ComplianceResourceType': 'AWS::::Account', - 'ComplianceResourceId': event['accountId'], - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': invoking_event['notificationCreationTime'] + "ComplianceResourceType": "AWS::::Account", + "ComplianceResourceId": event["accountId"], + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": invoking_event["notificationCreationTime"], } LOGGER.info(f"Compliance evaluation result: {compliance_type}") LOGGER.info(f"Annotation: {annotation}") - config_client.put_evaluations( - Evaluations=[evaluation], - ResultToken=event['resultToken'] - ) + config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) LOGGER.info("Compliance evaluation complete.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py index ef27eee1f..5b7593b28 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py @@ -1,4 +1,3 @@ - """Config rule to check s3 endpoints for Bedrock environemts. Version: 1.0 @@ -22,11 +21,11 @@ LOGGER.info(f"boto3 version: {boto3.__version__}") # Get AWS region from environment variable -AWS_REGION = os.environ.get('AWS_REGION') +AWS_REGION = os.environ.get("AWS_REGION") # Initialize AWS clients -ec2_client = boto3.client('ec2', region_name=AWS_REGION) -config_client = boto3.client('config', region_name=AWS_REGION) +ec2_client = boto3.client("ec2", region_name=AWS_REGION) +config_client = boto3.client("config", region_name=AWS_REGION) def evaluate_compliance(configuration_item: dict) -> tuple[str, str]: # noqa: CFQ004 @@ -39,28 +38,28 @@ def evaluate_compliance(configuration_item: dict) -> tuple[str, str]: # noqa: C tuple[str, str]: Compliance type and annotation message. """ - if configuration_item['resourceType'] != 'AWS::EC2::VPC': - return 'NOT_APPLICABLE', "Resource is not a VPC" + if configuration_item["resourceType"] != "AWS::EC2::VPC": + return "NOT_APPLICABLE", "Resource is not a VPC" - vpc_id = configuration_item['configuration']['vpcId'] + vpc_id = configuration_item["configuration"]["vpcId"] try: response = ec2_client.describe_vpc_endpoints( Filters=[ - {'Name': 'vpc-id', 'Values': [vpc_id]}, - {'Name': 'service-name', 'Values': [f'com.amazonaws.{AWS_REGION}.s3']}, - {'Name': 'vpc-endpoint-type', 'Values': ['Gateway']} + {"Name": "vpc-id", "Values": [vpc_id]}, + {"Name": "service-name", "Values": [f"com.amazonaws.{AWS_REGION}.s3"]}, + {"Name": "vpc-endpoint-type", "Values": ["Gateway"]}, ] ) - if response['VpcEndpoints']: - endpoint_id = response['VpcEndpoints'][0]['VpcEndpointId'] - return 'COMPLIANT', f"S3 Gateway Endpoint is in place for VPC {vpc_id}. Endpoint ID: {endpoint_id}" - return 'NON_COMPLIANT', f"S3 Gateway Endpoint is not in place for VPC {vpc_id}" + if response["VpcEndpoints"]: + endpoint_id = response["VpcEndpoints"][0]["VpcEndpointId"] + return "COMPLIANT", f"S3 Gateway Endpoint is in place for VPC {vpc_id}. Endpoint ID: {endpoint_id}" + return "NON_COMPLIANT", f"S3 Gateway Endpoint is not in place for VPC {vpc_id}" except Exception as e: LOGGER.error(f"Error evaluating S3 Gateway Endpoint configuration: {str(e)}") - return 'ERROR', f"Error evaluating compliance: {str(e)}" + return "ERROR", f"Error evaluating compliance: {str(e)}" def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 @@ -70,46 +69,45 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 event (dict): Config event object context (Any): Lambda context object """ - LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info("Evaluating compliance for AWS Config rule") LOGGER.info(f"Event: {json.dumps(event)}") - invoking_event = json.loads(event['invokingEvent']) + invoking_event = json.loads(event["invokingEvent"]) - if invoking_event['messageType'] == 'ConfigurationItemChangeNotification': - configuration_item = invoking_event['configurationItem'] + if invoking_event["messageType"] == "ConfigurationItemChangeNotification": + configuration_item = invoking_event["configurationItem"] compliance_type, annotation = evaluate_compliance(configuration_item) evaluation = { - 'ComplianceResourceType': configuration_item['resourceType'], - 'ComplianceResourceId': configuration_item['resourceId'], - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] + "ComplianceResourceType": configuration_item["resourceType"], + "ComplianceResourceId": configuration_item["resourceId"], + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": configuration_item["configurationItemCaptureTime"], } evaluations = [evaluation] - elif invoking_event['messageType'] == 'ScheduledNotification': + elif invoking_event["messageType"] == "ScheduledNotification": # For scheduled evaluations, check all VPCs evaluations = [] vpcs = ec2_client.describe_vpcs() - for vpc in vpcs['Vpcs']: - vpc_id = vpc['VpcId'] - mock_configuration_item = {'resourceType': 'AWS::EC2::VPC', 'configuration': {'vpcId': vpc_id}} + for vpc in vpcs["Vpcs"]: + vpc_id = vpc["VpcId"] + mock_configuration_item = {"resourceType": "AWS::EC2::VPC", "configuration": {"vpcId": vpc_id}} compliance_type, annotation = evaluate_compliance(mock_configuration_item) - evaluations.append({ - 'ComplianceResourceType': 'AWS::EC2::VPC', - 'ComplianceResourceId': vpc_id, - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': invoking_event['notificationCreationTime'] - }) + evaluations.append( + { + "ComplianceResourceType": "AWS::EC2::VPC", + "ComplianceResourceId": vpc_id, + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": invoking_event["notificationCreationTime"], + } + ) else: LOGGER.error(f"Unsupported message type: {invoking_event['messageType']}") return # Submit compliance evaluations if evaluations: - config_client.put_evaluations( - Evaluations=evaluations, - ResultToken=event['resultToken'] - ) + config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py index c9de5e05a..a1b6b2f9a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -21,11 +21,11 @@ LOGGER.info(f"boto3 version: {boto3.__version__}") # Get AWS region from environment variable -AWS_REGION = os.environ.get('AWS_REGION') +AWS_REGION = os.environ.get("AWS_REGION") # Initialize AWS clients -ec2_client = boto3.client('ec2', region_name=AWS_REGION) -config_client = boto3.client('config', region_name=AWS_REGION) +ec2_client = boto3.client("ec2", region_name=AWS_REGION) +config_client = boto3.client("config", region_name=AWS_REGION) def evaluate_compliance(vpc_id: str, rule_parameters: dict) -> tuple[str, str]: @@ -40,24 +40,24 @@ def evaluate_compliance(vpc_id: str, rule_parameters: dict) -> tuple[str, str]: """ # Parse rule parameters params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} - check_bedrock = params.get('check_bedrock', 'true').lower() == 'true' - check_bedrock_agent = params.get('check_bedrock_agent', 'true').lower() == 'true' - check_bedrock_agent_runtime = params.get('check_bedrock_agent_runtime', 'true').lower() == 'true' - check_bedrock_runtime = params.get('check_bedrock_runtime', 'true').lower() == 'true' + check_bedrock = params.get("check_bedrock", "true").lower() == "true" + check_bedrock_agent = params.get("check_bedrock_agent", "true").lower() == "true" + check_bedrock_agent_runtime = params.get("check_bedrock_agent_runtime", "true").lower() == "true" + check_bedrock_runtime = params.get("check_bedrock_runtime", "true").lower() == "true" required_endpoints = [] if check_bedrock: - required_endpoints.append(f'com.amazonaws.{AWS_REGION}.bedrock') + required_endpoints.append(f"com.amazonaws.{AWS_REGION}.bedrock") if check_bedrock_agent: - required_endpoints.append(f'com.amazonaws.{AWS_REGION}.bedrock-agent') + required_endpoints.append(f"com.amazonaws.{AWS_REGION}.bedrock-agent") if check_bedrock_agent_runtime: - required_endpoints.append(f'com.amazonaws.{AWS_REGION}.bedrock-agent-runtime') + required_endpoints.append(f"com.amazonaws.{AWS_REGION}.bedrock-agent-runtime") if check_bedrock_runtime: - required_endpoints.append(f'com.amazonaws.{AWS_REGION}.bedrock-runtime') + required_endpoints.append(f"com.amazonaws.{AWS_REGION}.bedrock-runtime") # Get VPC endpoints - response = ec2_client.describe_vpc_endpoints(Filters=[{'Name': 'vpc-id', 'Values': [vpc_id]}]) - existing_endpoints = [endpoint['ServiceName'] for endpoint in response['VpcEndpoints']] + response = ec2_client.describe_vpc_endpoints(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]) + existing_endpoints = [endpoint["ServiceName"] for endpoint in response["VpcEndpoints"]] LOGGER.info(f"Checking VPC {vpc_id} for endpoints: {required_endpoints}") LOGGER.info(f"Existing endpoints: {existing_endpoints}") @@ -65,9 +65,9 @@ def evaluate_compliance(vpc_id: str, rule_parameters: dict) -> tuple[str, str]: if missing_endpoints: LOGGER.info(f"Missing endpoints for VPC {vpc_id}: {missing_endpoints}") - return 'NON_COMPLIANT', f"VPC {vpc_id} is missing the following Bedrock endpoints: {', '.join(missing_endpoints)}" + return "NON_COMPLIANT", f"VPC {vpc_id} is missing the following Bedrock endpoints: {', '.join(missing_endpoints)}" LOGGER.info(f"All required endpoints are in place for VPC {vpc_id}") - return 'COMPLIANT', f"VPC {vpc_id} has all required Bedrock endpoints: {', '.join(required_endpoints)}" + return "COMPLIANT", f"VPC {vpc_id} has all required Bedrock endpoints: {', '.join(required_endpoints)}" def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 @@ -77,48 +77,49 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 event (dict): Config event object context (Any): Lambda context object """ - LOGGER.info('Evaluating compliance for AWS Config rule') + LOGGER.info("Evaluating compliance for AWS Config rule") LOGGER.info(f"Event: {json.dumps(event)}") - invoking_event = json.loads(event['invokingEvent']) - rule_parameters = json.loads(event['ruleParameters']) if 'ruleParameters' in event else {} + invoking_event = json.loads(event["invokingEvent"]) + rule_parameters = json.loads(event["ruleParameters"]) if "ruleParameters" in event else {} - if invoking_event['messageType'] == 'ScheduledNotification': + if invoking_event["messageType"] == "ScheduledNotification": # This is a scheduled run, evaluate all VPCs evaluations = [] vpcs = ec2_client.describe_vpcs() - for vpc in vpcs['Vpcs']: - vpc_id = vpc['VpcId'] + for vpc in vpcs["Vpcs"]: + vpc_id = vpc["VpcId"] compliance_type, annotation = evaluate_compliance(vpc_id, rule_parameters) - evaluations.append({ - 'ComplianceResourceType': 'AWS::EC2::VPC', - 'ComplianceResourceId': vpc_id, - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': invoking_event['notificationCreationTime'] - }) + evaluations.append( + { + "ComplianceResourceType": "AWS::EC2::VPC", + "ComplianceResourceId": vpc_id, + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": invoking_event["notificationCreationTime"], + } + ) else: # This is a configuration change event - configuration_item = invoking_event['configurationItem'] - if configuration_item['resourceType'] != 'AWS::EC2::VPC': + configuration_item = invoking_event["configurationItem"] + if configuration_item["resourceType"] != "AWS::EC2::VPC": LOGGER.info(f"Skipping non-VPC resource: {configuration_item['resourceType']}") return - vpc_id = configuration_item['resourceId'] + vpc_id = configuration_item["resourceId"] compliance_type, annotation = evaluate_compliance(vpc_id, rule_parameters) - evaluations = [{ - 'ComplianceResourceType': configuration_item['resourceType'], - 'ComplianceResourceId': vpc_id, - 'ComplianceType': compliance_type, - 'Annotation': annotation, - 'OrderingTimestamp': configuration_item['configurationItemCaptureTime'] - }] + evaluations = [ + { + "ComplianceResourceType": configuration_item["resourceType"], + "ComplianceResourceId": vpc_id, + "ComplianceType": compliance_type, + "Annotation": annotation, + "OrderingTimestamp": configuration_item["configurationItemCaptureTime"], + } + ] # Submit compliance evaluations if evaluations: - config_client.put_evaluations( - Evaluations=evaluations, - ResultToken=event['resultToken'] - ) + config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index d868a46a6..c7ea68ad7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -31,20 +31,11 @@ import sra_ssm_params import sra_sts -# TODO(liamschn): deploy example bedrock guardrail -# TODO(liamschn): deploy example iam role(s) and policy(ies) - lower priority/not necessary? -# TODO(liamschn): deploy example bucket policy(ies) - lower priority/not necessary? -# TODO(liamschn): deal with linting failures in pipeline (and deal with typechecking/mypy) -# TODO(liamschn): check for unused parameters (in progress) -# TODO(liamschn): make sure things don't fail (create or delete) if the dynamodb table is deleted/doesn't exist (use case, maybe someone deletes it) - LOGGER = logging.getLogger(__name__) log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) -# TODO(liamschn): change this so that it downloads the sra_config_lambda_iam_permissions.json from the repo -# then loads into the IAM_POLICY_DOCUMENTS variable (make this step 2 in the create function below) def load_iam_policy_documents() -> Dict[str, Any]: """Load IAM Policy Documents from JSON file. @@ -161,15 +152,15 @@ def load_sra_cloudwatch_dashboard() -> dict: # Parameter validation rules PARAMETER_VALIDATION_RULES: dict = { - "SRA_REPO_ZIP_URL": r'^https://.*\.zip$', - "DRY_RUN": r'^true|false$', - "EXECUTION_ROLE_NAME": r'^sra-execution$', - "LOG_GROUP_DEPLOY": r'^true|false$', - "LOG_GROUP_RETENTION": r'^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$', - "LOG_LEVEL": r'^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$', - "SOLUTION_NAME": r'^sra-bedrock-org$', - "SOLUTION_VERSION": r'^[0-9]+\.[0-9]+\.[0-9]+$', - "SRA_ALARM_EMAIL": r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + "SRA_REPO_ZIP_URL": r"^https://.*\.zip$", + "DRY_RUN": r"^true|false$", + "EXECUTION_ROLE_NAME": r"^sra-execution$", + "LOG_GROUP_DEPLOY": r"^true|false$", + "LOG_GROUP_RETENTION": r"^(1|3|5|7|14|30|60|90|120|150|180|365|400|545|731|1096|1827|2192|2557|2922|3288|3653)$", + "LOG_LEVEL": r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", + "SOLUTION_NAME": r"^sra-bedrock-org$", + "SOLUTION_VERSION": r"^[0-9]+\.[0-9]+\.[0-9]+$", + "SRA_ALARM_EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' @@ -210,7 +201,6 @@ def load_sra_cloudwatch_dashboard() -> dict: } # Instantiate sra class objects -# TODO(liamschn): can these files exist in some central location to be shared with other solutions? ssm_params = sra_ssm_params.SRASSMParams() iam = sra_iam.SRAIAM() dynamodb = sra_dynamodb.SRADynamoDB() @@ -255,7 +245,6 @@ def get_resource_parameters(event: dict) -> None: repo.REPO_BRANCH = repo.REPO_ZIP_URL.split(".")[1].split("/")[len(repo.REPO_ZIP_URL.split(".")[1].split("/")) - 1] # noqa: ECE001 repo.SOLUTIONS_DIR = f"/tmp/aws-security-reference-architecture-examples-{repo.REPO_BRANCH}/aws_sra_examples/solutions" # noqa: S108 - # TODO(liamschn): the CONFIGURATION_ROLE needs to be a resource parameter sts.CONFIGURATION_ROLE = "sra-execution" governed_regions_param = ssm_params.get_ssm_parameter( ssm_params.MANAGEMENT_ACCOUNT_SESSION, REGION, "/sra/regions/customer-control-tower-regions" @@ -540,7 +529,6 @@ def deploy_state_table() -> None: if DRY_RUN is False: LOGGER.info("Live run: creating the state table...") - # TODO(liamschn): move the deploy state table function to the dynamo class object/module? dynamodb.DYNAMODB_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) @@ -612,7 +600,6 @@ def add_state_table_record( # noqa: CFQ002 LOGGER.info(f"Add a record to the state table for {component_name}") if account_id is None: account_id = "Unknown" - # TODO(liamschn): check to ensure we got a 200 back from the service API call before inserting the dynamodb records dynamodb.DYNAMODB_RESOURCE = sts.assume_role_resource(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "dynamodb", sts.HOME_REGION) item_found, find_result = dynamodb.find_item( @@ -935,9 +922,13 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info("Customizing key policy...") kms_key_policy = json.loads(json.dumps(KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS])) LOGGER.info(f"kms_key_policy: {kms_key_policy}") - kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0]["Principal"][ # noqa ECE001 + kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ # noqa ECE001 + "Principal" + ][ "AWS" - ].replace("ACCOUNT_ID", acct) + ].replace( + "ACCOUNT_ID", acct + ) kms_key_policy["Statement"][2]["Principal"]["AWS"] = execution_role_arn LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") @@ -1028,7 +1019,6 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Setting access for CloudWatch alarms in {acct} to publish to {SOLUTION_NAME}-alarms SNS topic") - # TODO(liamschn): search for policy on SNS topic before adding the policy sns.set_topic_access_for_alarms(alarm_topic_arn, acct) LIVE_RUN_DATA["SNSAlarmPolicy"] = "Added policy for CloudWatch alarms to publish to SNS topic" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 @@ -1109,9 +1099,9 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope DRY_RUN_DATA[f"{filter_name}_CloudWatch_Alarm"] = "DRY_RUN: Deploy CloudWatch metric alarm" else: LOGGER.info(f"DRY_RUN: Filter deploy parameter is 'false'; Skip {filter_name} CloudWatch metric filter deployment") - DRY_RUN_DATA[f"{filter_name}_CloudWatch"] = ( - "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" - ) + DRY_RUN_DATA[ + f"{filter_name}_CloudWatch" + ] = "DRY_RUN: Filter deploy parameter is 'false'; Skip CloudWatch metric filter deployment" def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR001, CFQ001, C901 @@ -1127,7 +1117,6 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 global CFN_RESPONSE_DATA central_observability_params = json.loads(event["ResourceProperties"]["SRA-BEDROCK-CENTRAL-OBSERVABILITY"]) - # TODO(liamschn): create a parameter to choose to deploy central observability or not: deploy_central_observability = true/false # 5a) OAM Sink in security account cloudwatch.CWOAM_CLIENT = sts.assume_role(ssm_params.SRA_SECURITY_ACCT, sts.CONFIGURATION_ROLE, "oam", sts.HOME_REGION) search_oam_sink = cloudwatch.find_oam_sink() @@ -1196,7 +1185,8 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 iam.IAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) cloudwatch.CROSS_ACCOUNT_TRUST_POLICY = CLOUDWATCH_OAM_TRUST_POLICY[cloudwatch.CROSS_ACCOUNT_ROLE_NAME] cloudwatch.CROSS_ACCOUNT_TRUST_POLICY["Statement"][0]["Principal"]["AWS"] = cloudwatch.CROSS_ACCOUNT_TRUST_POLICY[ # noqa: ECE001 - "Statement"][0]["Principal"]["AWS"].replace("", ssm_params.SRA_SECURITY_ACCT) + "Statement" + ][0]["Principal"]["AWS"].replace("", ssm_params.SRA_SECURITY_ACCT) search_iam_role = iam.check_iam_role_exists(cloudwatch.CROSS_ACCOUNT_ROLE_NAME) if search_iam_role[0] is False: LOGGER.info( @@ -1206,9 +1196,9 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 if DRY_RUN is False: xacct_role = iam.create_role(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, cloudwatch.CROSS_ACCOUNT_TRUST_POLICY, SOLUTION_NAME) xacct_role_arn = xacct_role["Role"]["Arn"] - LIVE_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = ( - f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" - ) + LIVE_RUN_DATA[ + f"OAMCrossAccountRoleCreate_{bedrock_account}" + ] = f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 LOGGER.info(f"Created {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") @@ -1224,9 +1214,9 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 cloudwatch.CROSS_ACCOUNT_ROLE_NAME, ) else: - DRY_RUN_DATA[f"OAMCrossAccountRoleCreate_{bedrock_account}"] = ( - f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" - ) + DRY_RUN_DATA[ + f"OAMCrossAccountRoleCreate_{bedrock_account}" + ] = f"DRY_RUN: Create {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" else: LOGGER.info( f"CloudWatch observability access manager {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} cross-account role found in {bedrock_account}" @@ -1256,17 +1246,17 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 LOGGER.info(f"Attaching {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}...") if DRY_RUN is False: iam.attach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy_arn) - LIVE_RUN_DATA[f"OamXacctRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}"] = ( - f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" - ) + LIVE_RUN_DATA[ + f"OamXacctRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}" + ] = f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Attached {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}") else: - DRY_RUN_DATA[f"OAMCrossAccountRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}"] = ( - f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" - ) + DRY_RUN_DATA[ + f"OAMCrossAccountRolePolicyAttach_{policy_arn.split('/')[1]}_{bedrock_account}" + ] = f"DRY_RUN: Attach {policy_arn} policy to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role in {bedrock_account}" # 5e) OAM link in bedrock account cloudwatch.CWOAM_CLIENT = sts.assume_role(bedrock_account, sts.CONFIGURATION_ROLE, "oam", bedrock_region) @@ -1275,9 +1265,9 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 if DRY_RUN is False: LOGGER.info("CloudWatch observability access manager link not found, creating...") oam_link_arn = cloudwatch.create_oam_link(oam_sink_arn) - LIVE_RUN_DATA[f"OAMLinkCreate_{bedrock_account}_{bedrock_region}"] = ( - f"Created CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" - ) + LIVE_RUN_DATA[ + f"OAMLinkCreate_{bedrock_account}_{bedrock_region}" + ] = f"Created CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["resources_deployed"] += 1 @@ -1286,9 +1276,9 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") else: LOGGER.info("DRY_RUN: CloudWatch observability access manager link not found, creating...") - DRY_RUN_DATA[f"OAMLinkCreate_{bedrock_account}"] = ( - f"DRY_RUN: Create CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" - ) + DRY_RUN_DATA[ + f"OAMLinkCreate_{bedrock_account}" + ] = f"DRY_RUN: Create CloudWatch observability access manager link in {bedrock_account} in {bedrock_region}" # Set link arn to default value (for dry run) oam_link_arn = f"arn:aws:cloudwatch::{bedrock_account}:link/arn" else: @@ -1445,15 +1435,15 @@ def create_event(event: dict, context: Any) -> str: LOGGER.info(f"CFN_RESPONSE_DATA POST deploy_cloudwatch_dashboard: {CFN_RESPONSE_DATA}") # End - # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) create_json_file("dry_run_data.json", DRY_RUN_DATA) LOGGER.info("Dry run data saved to file") - s3.upload_file_to_s3("/tmp/dry_run_data.json", s3.STAGING_BUCKET, # noqa: S108 - f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") + s3.upload_file_to_s3( + "/tmp/dry_run_data.json", s3.STAGING_BUCKET, f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json" # noqa: S108 + ) LOGGER.info(f"Dry run data file uploaded to s3://{s3.STAGING_BUCKET}/dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") if RESOURCE_TYPE == CFN_CUSTOM_RESOURCE: @@ -1476,8 +1466,6 @@ def update_event(event: dict, context: Any) -> str: """ global CFN_RESPONSE_DATA CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 - # TODO(liamschn): handle CFN update events; use case: add additional config rules via new rules in code (i.e. ...\rules\new_rule\app.py) - # TODO(liamschn): handle CFN update events; use case: changing config rule parameters (i.e. deploy, accounts, regions, input_params) global DRY_RUN_DATA LOGGER.info("update event function") create_event(event, context) @@ -1547,15 +1535,15 @@ def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: # noqa: C if DRY_RUN is False: LOGGER.info(f"Detaching {policy['PolicyName']} IAM policy from account {acct} in {region}") iam.detach_policy(rule_name, policy["PolicyArn"]) - LIVE_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDetach"] = ( - f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" - ) + LIVE_RUN_DATA[ + f"{rule_name}_{acct}_{region}_PolicyDetach" + ] = f"Detached {policy['PolicyName']} IAM policy from account {acct} in {region}" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 else: LOGGER.info(f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_Delete"] = ( - f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" - ) + DRY_RUN_DATA[ + f"{rule_name}_{acct}_{region}_Delete" + ] = f"DRY_RUN: Detach {policy['PolicyName']} IAM policy from account {acct} in {region}" else: LOGGER.info(f"No IAM policies attached to {rule_name} for account {acct} in {region}") @@ -1573,9 +1561,9 @@ def delete_custom_config_iam_role(rule_name: str, acct: str) -> None: # noqa: C remove_state_table_record(policy_arn) else: LOGGER.info(f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}") - DRY_RUN_DATA[f"{rule_name}_{acct}_{region}_PolicyDelete"] = ( - f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" - ) + DRY_RUN_DATA[ + f"{rule_name}_{acct}_{region}_PolicyDelete" + ] = f"DRY_RUN: Delete {rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region}" else: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy for account {acct} in {region} does not exist.") @@ -1723,10 +1711,6 @@ def delete_event(event: dict, context: Any) -> None: # noqa: CFQ001, CCR001, C9 event (dict): Lambda event object context (Any): Lambda context object """ - # TODO(liamschn): handle delete error if IAM policy is updated out-of-band - botocore.errorfactory.DeleteConflictException: - # An error occurred (DeleteConflict) when calling the DeletePolicy operation: This policy has more than one version. - # Before you delete a policy, you must delete the policy's versions. The default version is deleted with the policy. - # TODO(liamschn): move re-used delete event operation code to separate functions global DRY_RUN_DATA global LIVE_RUN_DATA global CFN_RESPONSE_DATA @@ -1797,18 +1781,18 @@ def delete_event(event: dict, context: Any) -> None: # noqa: CFQ001, CCR001, C9 for policy in cross_account_policies: LOGGER.info(f"Detaching {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") iam.detach_policy(cloudwatch.CROSS_ACCOUNT_ROLE_NAME, policy["PolicyArn"]) - LIVE_RUN_DATA["OAMCrossAccountRolePolicyDetach"] = ( - f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" - ) + LIVE_RUN_DATA[ + "OAMCrossAccountRolePolicyDetach" + ] = f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" CFN_RESPONSE_DATA["deployment_info"]["action_count"] += 1 CFN_RESPONSE_DATA["deployment_info"]["configuration_changes"] += 1 LOGGER.info(f"Detached {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") else: for policy in cross_account_policies: LOGGER.info(f"DRY_RUN: Detaching {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role...") - DRY_RUN_DATA["OAMCrossAccountRolePolicyDetach"] = ( - f"DRY_RUN: Detach {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" - ) + DRY_RUN_DATA[ + "OAMCrossAccountRolePolicyDetach" + ] = f"DRY_RUN: Detach {policy['PolicyArn']} policy from {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role" else: LOGGER.info(f"No policies attached to {cloudwatch.CROSS_ACCOUNT_ROLE_NAME} IAM role") @@ -1855,8 +1839,6 @@ def delete_event(event: dict, context: Any) -> None: # noqa: CFQ001, CCR001, C9 delete_sns_topic_and_key(acct, region) # 4) Delete config rules - # TODO(liamschn): deal with invalid rule names? - # TODO(liamschn): deal with invalid account IDs? accounts, regions = get_accounts_and_regions(event["ResourceProperties"]) for prop in event["ResourceProperties"]: if prop.startswith("SRA-BEDROCK-CHECK-"): @@ -1878,7 +1860,6 @@ def delete_event(event: dict, context: Any) -> None: # noqa: CFQ001, CCR001, C9 LOGGER.info(f"Removing state table record for lambda function: {context.invoked_function_arn}") remove_state_table_record(context.invoked_function_arn) - # TODO(liamschn): Consider the 256 KB limit for any cloudwatch log message if DRY_RUN is False: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": LIVE_RUN_DATA})) else: @@ -1967,8 +1948,9 @@ def process_sns_records(event: dict) -> None: LOGGER.info(json.dumps({"RUN STATS": CFN_RESPONSE_DATA, "RUN DATA": DRY_RUN_DATA})) create_json_file("dry_run_data.json", DRY_RUN_DATA) LOGGER.info("Dry run data saved to file") - s3.upload_file_to_s3("/tmp/dry_run_data.json", s3.STAGING_BUCKET, # noqa: S108 - f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") + s3.upload_file_to_s3( + "/tmp/dry_run_data.json", s3.STAGING_BUCKET, f"dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json" # noqa: S108 + ) LOGGER.info(f"Dry run data file uploaded to s3://{s3.STAGING_BUCKET}/dry_run_data_{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.json") @@ -2018,11 +2000,14 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: # noqa: CFQ001, CC add_state_table_record("iam", "implemented", "role for config rule", "role", role_arn, account_id, "Global", rule_name) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS[ # noqa: ECE001 - "sra-lambda-basic-execution"]["Statement"][0]["Resource"].replace("ACCOUNT_ID", account_id) + "sra-lambda-basic-execution" + ]["Statement"][0]["Resource"].replace("ACCOUNT_ID", account_id) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS[ # noqa: ECE001 - "sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("ACCOUNT_ID", account_id) + "sra-lambda-basic-execution" + ]["Statement"][1]["Resource"].replace("ACCOUNT_ID", account_id) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][1]["Resource"] = iam.SRA_POLICY_DOCUMENTS[ # noqa: ECE001 - "sra-lambda-basic-execution"]["Statement"][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) + "sra-lambda-basic-execution" + ]["Statement"][1]["Resource"].replace("CONFIG_RULE_NAME", rule_name) LOGGER.info(f"Policy document: {iam.SRA_POLICY_DOCUMENTS['sra-lambda-basic-execution']}") policy_arn = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}-lamdba-basic-execution" iam_policy_search = iam.check_iam_policy_exists(policy_arn) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py index 21d176d48..60d40e54f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/cfnresponse.py @@ -1,3 +1,4 @@ +"""Amazon CFNResponse Module.""" # mypy: ignore-errors # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: MIT-0 @@ -14,14 +15,14 @@ http = urllib3.PoolManager() -def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None): - responseUrl = event["ResponseURL"] +def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None): # noqa: N803, D103 + responseUrl = event["ResponseURL"] # noqa: N806 - print(responseUrl) + print(responseUrl) # noqa: T201 - responseBody = { + responseBody = { # noqa: N806 "Status": responseStatus, - "Reason": reason or "See the details in CloudWatch Log Stream: {}".format(context.log_stream_name), + "Reason": reason or "See the details in CloudWatch Log Stream: {}".format(context.log_stream_name), # noqa: FS002 "PhysicalResourceId": physicalResourceId or context.log_stream_name, "StackId": event["StackId"], "RequestId": event["RequestId"], @@ -30,16 +31,16 @@ def send(event, context, responseStatus, responseData, physicalResourceId=None, "Data": responseData, } - json_responseBody = json.dumps(responseBody) + json_responseBody = json.dumps(responseBody) # noqa: N806 - print("Response body:") - print(json_responseBody) + print("Response body:") # noqa: T201 + print(json_responseBody) # noqa: T201 headers = {"content-type": "", "content-length": str(len(json_responseBody))} try: response = http.request("PUT", responseUrl, headers=headers, body=json_responseBody) - print("Status code:", response.status) + print("Status code:", response.status) # noqa: T201 except Exception as e: - print("send(..) failed executing http.request(..):", e) + print("send(..) failed executing http.request(..):", e) # noqa: T201 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt index 9acb7c1db..bae3f13b0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/requirements.txt @@ -1,3 +1 @@ #install latest -# TODO(liamschn): not using crhelper -crhelper \ No newline at end of file diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index cc3141117..71ab4d680 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -180,12 +180,18 @@ def create_metric_alarm( # noqa: CFQ002 alarm_description: str, metric_name: str, metric_namespace: str, - metric_statistic: Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum'], + metric_statistic: Literal["Average", "Maximum", "Minimum", "SampleCount", "Sum"], metric_period: int, metric_threshold: float, - metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', - 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', 'LessThanOrEqualToThreshold', - 'LessThanThreshold'], + metric_comparison_operator: Literal[ + "GreaterThanOrEqualToThreshold", + "GreaterThanThreshold", + "GreaterThanUpperThreshold", + "LessThanLowerOrGreaterThanUpperThreshold", + "LessThanLowerThreshold", + "LessThanOrEqualToThreshold", + "LessThanThreshold", + ], metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list, @@ -244,12 +250,18 @@ def update_metric_alarm( # noqa: CFQ002 alarm_description: str, metric_name: str, metric_namespace: str, - metric_statistic: Literal['Average', 'Maximum', 'Minimum', 'SampleCount', 'Sum'], + metric_statistic: Literal["Average", "Maximum", "Minimum", "SampleCount", "Sum"], metric_period: int, metric_threshold: float, - metric_comparison_operator: Literal['GreaterThanOrEqualToThreshold', 'GreaterThanThreshold', 'GreaterThanUpperThreshold', - 'LessThanLowerOrGreaterThanUpperThreshold', 'LessThanLowerThreshold', - 'LessThanOrEqualToThreshold', 'LessThanThreshold'], + metric_comparison_operator: Literal[ + "GreaterThanOrEqualToThreshold", + "GreaterThanThreshold", + "GreaterThanUpperThreshold", + "LessThanLowerOrGreaterThanUpperThreshold", + "LessThanLowerThreshold", + "LessThanOrEqualToThreshold", + "LessThanThreshold", + ], metric_evaluation_periods: int, metric_treat_missing_data: str, alarm_actions: list, @@ -452,16 +464,16 @@ def create_oam_link(self, sink_arn: str) -> str: """ try: response = self.CWOAM_CLIENT.create_link( - LabelTemplate='$AccountName', + LabelTemplate="$AccountName", ResourceTypes=[ "AWS::ApplicationInsights::Application", "AWS::InternetMonitor::Monitor", "AWS::Logs::LogGroup", "AWS::CloudWatch::Metric", - "AWS::XRay::Trace" + "AWS::XRay::Trace", ], SinkIdentifier=sink_arn, - Tags={"sra-solution": self.SOLUTION_NAME} + Tags={"sra-solution": self.SOLUTION_NAME}, ) self.LOGGER.info(f"Observability access manager link for {sink_arn} created: {response['Arn']}") return response["Arn"] @@ -531,10 +543,7 @@ def create_dashboard(self, dashboard_name: str, dashboard_body: dict) -> str: try: self.LOGGER.info(f"Creating CloudWatch dashboard {dashboard_name} as: {json.dumps(dashboard_body)}") self.LOGGER.info({"dashboard json": dashboard_body}) - response = self.CLOUDWATCH_CLIENT.put_dashboard( - DashboardName=dashboard_name, - DashboardBody=json.dumps(dashboard_body) - ) + response = self.CLOUDWATCH_CLIENT.put_dashboard(DashboardName=dashboard_name, DashboardBody=json.dumps(dashboard_body)) self.LOGGER.info(f"CloudWatch dashboard {dashboard_name} created: {response['DashboardValidationMessages']}") return self.find_dashboard(dashboard_name)[1] except ClientError as error: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 38570c371..17c2a4144 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -115,10 +115,17 @@ def find_config_rule(self, rule_name: str) -> tuple[bool, dict | DescribeConfigR self.LOGGER.info(f"Config rule {rule_name} exists: {response}") return True, response - def create_config_rule(self, rule_name: str, lambda_arn: str, # noqa: CFQ002 - max_frequency: Literal["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"], - owner: Literal["CUSTOM_LAMBDA", "AWS"], description: str, input_params: dict, - eval_mode: Literal["DETECTIVE", "PROACTIVE"], solution_name: str) -> None: + def create_config_rule( + self, + rule_name: str, + lambda_arn: str, # noqa: CFQ002 + max_frequency: Literal["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"], + owner: Literal["CUSTOM_LAMBDA", "AWS"], + description: str, + input_params: dict, + eval_mode: Literal["DETECTIVE", "PROACTIVE"], + solution_name: str, + ) -> None: """Create Config Rule. Args: @@ -141,7 +148,6 @@ def create_config_rule(self, rule_name: str, lambda_arn: str, # noqa: CFQ002 "SourceDetails": [ { "EventSource": "aws.config", - # TODO(liamschn): does messagetype need to be a parameter? "MessageType": "ScheduledNotification", "MaximumExecutionFrequency": max_frequency, } @@ -149,12 +155,10 @@ def create_config_rule(self, rule_name: str, lambda_arn: str, # noqa: CFQ002 }, "InputParameters": json.dumps(input_params), "EvaluationModes": [ - { - 'Mode': eval_mode - }, - ] + {"Mode": eval_mode}, + ], }, - Tags=[{"Key": "sra-solution", "Value": solution_name}] + Tags=[{"Key": "sra-solution", "Value": solution_name}], ) # Log the response @@ -168,9 +172,7 @@ def delete_config_rule(self, rule_name: str) -> None: """ # Delete the Config Rule try: - self.CONFIG_CLIENT.delete_config_rule( - ConfigRuleName=rule_name - ) + self.CONFIG_CLIENT.delete_config_rule(ConfigRuleName=rule_name) # Log the response self.LOGGER.info(f"Deleted {rule_name} config rule succeeded.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index de72d807d..cae37c7b2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -35,6 +35,7 @@ class SRADynamoDB: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) + try: MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() except Exception as error: @@ -77,6 +78,8 @@ def create_table(self, table_name: str) -> None: Args: table_name (str): DynamoDB table name """ + max_retries = 10 + retries = 0 # Define table schema key_schema: Sequence[KeySchemaElementTypeDef] = [ {"AttributeName": "solution_name", "KeyType": "HASH"}, @@ -89,6 +92,9 @@ def create_table(self, table_name: str) -> None: provisioned_throughput: ProvisionedThroughputTypeDef = {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} # Create table + while retries < max_retries: + self.LOGGER.info(f"Create table attempt {retries+1} of {max_retries}...") + try: self.DYNAMODB_CLIENT.create_table( TableName=table_name, KeySchema=key_schema, AttributeDefinitions=attribute_definitions, ProvisionedThroughput=provisioned_throughput @@ -97,14 +103,15 @@ def create_table(self, table_name: str) -> None: except Exception as e: self.LOGGER.info("Error creating table:", e) # wait for the table to become active - while True: + while retries < max_retries: + self.LOGGER.info(f"Checking to see if {table_name} dynamodb table is active attempt {retries+1} of {max_retries}...") wait_response = self.DYNAMODB_CLIENT.describe_table(TableName=table_name) if wait_response["Table"]["TableStatus"] == "ACTIVE": self.LOGGER.info(f"{table_name} dynamodb table is active") break else: self.LOGGER.info(f"{table_name} dynamodb table is not active yet. Status is '{wait_response['Table']['TableStatus']}' Waiting...") - # TODO(liamschn): need to add a maximum retry mechanism here + retries += 1 sleep(5) def table_exists(self, table_name: str) -> bool: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 369b19a33..0b397dd10 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -59,10 +59,15 @@ class SRAIAM: "sra-lambda-basic-execution": { "Version": "2012-10-17", "Statement": [ - {"Sid": "CreateLogGroup", "Effect": "Allow", "Action": "logs:CreateLogGroup", - "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:*"}, { - "Sid": "CreateStreamPutEvents", "Effect": "Allow", + "Sid": "CreateLogGroup", + "Effect": "Allow", + "Action": "logs:CreateLogGroup", + "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:*", + }, + { + "Sid": "CreateStreamPutEvents", + "Effect": "Allow", "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "arn:" + PARTITION + ":logs:*:ACCOUNT_ID:log-group:/aws/lambda/CONFIG_RULE_NAME:*", }, @@ -99,8 +104,9 @@ def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> Dictionary output of a successful CreateRole request """ self.LOGGER.info("Creating role %s.", role_name) - return self.IAM_CLIENT.create_role(RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", - "Value": solution_name}]) + return self.IAM_CLIENT.create_role( + RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", "Value": solution_name}] + ) def create_policy(self, policy_name: str, policy_document: dict, solution_name: str) -> CreatePolicyResponseTypeDef: """Create IAM policy. @@ -114,8 +120,9 @@ def create_policy(self, policy_name: str, policy_document: dict, solution_name: Dictionary output of a successful CreatePolicy request """ self.LOGGER.info(f"Creating {policy_name} IAM policy") - return self.IAM_CLIENT.create_policy(PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", - "Value": solution_name}]) + return self.IAM_CLIENT.create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", "Value": solution_name}] + ) def attach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadataTypeDef: """Attach policy to IAM role. @@ -286,9 +293,5 @@ def get_iam_global_region(self) -> str: Returns: str: The region name for the global region """ - partition_to_region = { - 'aws': 'us-east-1', - 'aws-cn': 'cn-north-1', - 'aws-us-gov': 'us-gov-west-1' - } - return partition_to_region.get(self.PARTITION, 'us-east-1') # Default to us-east-1 if partition is unknown + partition_to_region = {"aws": "us-east-1", "aws-cn": "cn-north-1", "aws-us-gov": "us-gov-west-1"} + return partition_to_region.get(self.PARTITION, "us-east-1") # Default to us-east-1 if partition is unknown diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 405186f39..73fd0dab2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -58,8 +58,17 @@ def find_lambda_function(self, function_name: str) -> str: self.LOGGER.error(f"Error encountered searching for lambda function: {e}") return "None" - def create_lambda_function(self, code_zip_file: str, role_arn: str, function_name: str, handler: str, runtime: str, # noqa: CFQ002, CCR001 - timeout: int, memory_size: int, solution_name: str) -> str: + def create_lambda_function( # noqa: CFQ002, CCR001 + self, + code_zip_file: str, + role_arn: str, + function_name: str, + handler: str, + runtime: str, + timeout: int, + memory_size: int, + solution_name: str, + ) -> str: """Create Lambda Function. Args: @@ -181,7 +190,6 @@ def put_permissions(self, function_name: str, statement_id: str, principal: str, return response["Statement"] except ClientError as e: if e.response["Error"]["Code"] == "ResourceConflictException": - # TODO(liamschn): consider updating the permission here self.LOGGER.info(f"{function_name} permission already exists.") return "None" self.LOGGER.info(f"Error adding lambda permission: {e}") @@ -258,7 +266,7 @@ def get_lambda_execution_role(self, function_name: str) -> str: self.LOGGER.info(f"Getting execution role for Lambda function: {function_name}") try: response = self.LAMBDA_CLIENT.get_function(FunctionName=function_name) - execution_role_arn = response['Configuration']['Role'] + execution_role_arn = response["Configuration"]["Role"] self.LOGGER.info(f"Execution Role ARN: {execution_role_arn}") return execution_role_arn except ClientError as e: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 9afc73039..3ef09901e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -17,9 +17,6 @@ import urllib3 -# TODO(liamschn): need to exclude "inline_" files from the staging process - - class SRARepo: """SRA Repo Class.""" @@ -115,8 +112,9 @@ def download_code_library(self, repo_zip_url: str) -> None: self.LOGGER.info("Files extracted to /tmp") self.LOGGER.info(f"tmp directory listing: {os.listdir('/tmp')}") # noqa: S108 - def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_temp_folder: str, # noqa: CCR001, C901 - solutions_dir: str) -> None: + def prepare_config_rules_for_staging( # noqa: CCR001, C901 + self, staging_upload_folder: str, staging_temp_folder: str, solutions_dir: str + ) -> None: """Prepare config rules for staging. Args: @@ -158,9 +156,7 @@ def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_t os.mkdir(staging_temp_folder + upload_folder_name) # noqa: PL102 if not os.path.exists(staging_temp_folder + upload_folder_name + "/rules"): # noqa: PL110 os.mkdir(staging_temp_folder + upload_folder_name + "/rules") # noqa: PL102 - config_rule_staging_folder_path = ( - staging_temp_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name - ) + config_rule_staging_folder_path = staging_temp_folder + upload_folder_name + "/rules" + config_rule_upload_folder_name if not os.path.exists(config_rule_staging_folder_path): # noqa: PL110 self.LOGGER.info(f"Creating {config_rule_staging_folder_path} folder") os.mkdir(config_rule_staging_folder_path) # noqa: PL102 @@ -214,8 +210,10 @@ def prepare_config_rules_for_staging(self, staging_upload_folder: str, staging_t ) self.zip_folder(f"{config_rule_staging_folder_path}", zip_file) zip_file.close() - self.LOGGER.info(f"{lambda_target_folder}{config_rule_upload_folder_name}.zip file size is" - + f"{os.path.getsize(f'{lambda_target_folder}{config_rule_upload_folder_name}.zip')}") + self.LOGGER.info( + f"{lambda_target_folder}{config_rule_upload_folder_name}.zip file size is" + + f"{os.path.getsize(f'{lambda_target_folder}{config_rule_upload_folder_name}.zip')}" + ) # debug stuff: else: self.LOGGER.info(f"{os.path.join(service_dir, solution, 'rules')} does not exist!") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index ce703d61d..77e89b198 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -89,8 +89,7 @@ def create_s3_bucket(self, bucket: str) -> None: """ if self.REGION != "us-east-1": create_bucket = self.S3_CLIENT.create_bucket( - ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, - ObjectOwnership="BucketOwnerPreferred" + ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, ObjectOwnership="BucketOwnerPreferred" ) else: create_bucket = self.S3_CLIENT.create_bucket(ACL="private", Bucket=bucket, ObjectOwnership="BucketOwnerPreferred") @@ -127,8 +126,6 @@ def s3_resource_check(self, bucket: str) -> None: self.LOGGER.info(f"Bucket not found, creating {bucket} s3 bucket...") self.create_s3_bucket(bucket) - # TODO(liamschn): parameter formatting validation (done in App module?) - def stage_code_to_s3(self, directory_path: str, bucket_name: str) -> None: """Upload the prepared code directory to the staging S3 bucket. diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py index 6c0ef82f9..1cb2f99ac 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sns.py @@ -66,9 +66,7 @@ def find_sns_topic(self, topic_name: str, region: str = "default", account: str if account == "default": account = self.sts.MANAGEMENT_ACCOUNT try: - response = self.SNS_CLIENT.get_topic_attributes( - TopicArn=f"arn:{self.sts.PARTITION}:sns:{region}:{account}:{topic_name}" - ) + response = self.SNS_CLIENT.get_topic_attributes(TopicArn=f"arn:{self.sts.PARTITION}:sns:{region}:{account}:{topic_name}") return response["Attributes"]["TopicArn"] except ClientError as e: if e.response["Error"]["Code"] == "NotFoundException": @@ -102,7 +100,7 @@ def create_sns_topic(self, topic_name: str, solution_name: str, kms_key: str = " response = self.SNS_CLIENT.create_topic( Name=topic_name, Attributes={"DisplayName": topic_name, "KmsMasterKeyId": kms_key}, - Tags=[{"Key": "sra-solution", "Value": solution_name}] + Tags=[{"Key": "sra-solution", "Value": solution_name}], ) topic_arn = response["TopicArn"] self.LOGGER.info(f"SNS Topic '{topic_name}' created with ARN: {topic_arn}") @@ -189,19 +187,13 @@ def set_topic_access_for_alarms(self, topic_arn: str, source_account: str) -> No "Action": "sns:Publish", "Resource": topic_arn, "Condition": { - "ArnLike": { - "aws:SourceArn": f"arn:{self.sts.PARTITION}:cloudwatch:{self.sts.HOME_REGION}:{source_account}:alarm:*" - }, - "StringEquals" : {"AWS:SourceAccount": source_account} - } + "ArnLike": {"aws:SourceArn": f"arn:{self.sts.PARTITION}:cloudwatch:{self.sts.HOME_REGION}:{source_account}:alarm:*"}, + "StringEquals": {"AWS:SourceAccount": source_account}, + }, } - ] + ], } - self.SNS_CLIENT.set_topic_attributes( - TopicArn=topic_arn, - AttributeName="Policy", - AttributeValue=json.dumps(policy) - ) + self.SNS_CLIENT.set_topic_attributes(TopicArn=topic_arn, AttributeName="Policy", AttributeValue=json.dumps(policy)) self.LOGGER.info(f"SNS Topic Policy set for {topic_arn} to allow access for CloudWatch alarms in the {source_account} account") except ClientError as e: raise ValueError(f"Error setting SNS topic policy: {e}") from None diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py index 185d9235a..5e87ad8f2 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_ssm_params.py @@ -179,9 +179,8 @@ def get_enabled_regions(self) -> list: # noqa: CCR001 Enabled regions """ default_available_regions: List[str] = [ - region["RegionName"] for region in boto3.client("account").list_regions( - RegionOptStatusContains=["ENABLED", "ENABLED_BY_DEFAULT"] - )["Regions"] + region["RegionName"] + for region in boto3.client("account").list_regions(RegionOptStatusContains=["ENABLED", "ENABLED_BY_DEFAULT"])["Regions"] ] self.LOGGER.info({"Default_Available_Regions": default_available_regions}) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index c9aef48ce..f36959023 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -108,10 +108,7 @@ def assume_role(self, account: str, role_name: str, service: str, region_name: s aws_secret_access_key=sts_response["Credentials"]["SecretAccessKey"], aws_session_token=sts_response["Credentials"]["SessionToken"], ) - return self.MANAGEMENT_ACCOUNT_SESSION.client( - service, # type: ignore - region_name=region_name, - config=self.BOTO3_CONFIG) + return self.MANAGEMENT_ACCOUNT_SESSION.client(service, region_name=region_name, config=self.BOTO3_CONFIG) # type: ignore def assume_role_resource(self, account: str, role_name: str, service: str, region_name: str) -> Any: """Get boto3 resource assumed into an account for a specified service. diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 4d744eb0c..d96c51c75 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -11,8 +11,7 @@ Parameters: pDryRun: Type: String - # TODO(liamschn): change the default to 'true' after done testing - Default: 'false' + Default: 'true' AllowedValues: - 'true' - 'false' @@ -97,8 +96,7 @@ Parameters: pBedrockModelEvalBucketRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockModelEvalBucketRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"BucketName": "model-invocation-log-bucket-221082195774"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"BucketName": "model-invocation-log-bucket-444455556666-us-west-2"}}' Description: Bedrock Model Evaluation Job Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ ConstraintDescription: @@ -109,8 +107,7 @@ Parameters: pBedrockIAMUserAccessRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockIAMUserAccessRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock IAM User Access Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ ConstraintDescription: @@ -121,8 +118,7 @@ Parameters: pBedrockGuardrailsRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockGuardrailsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' Description: Bedrock Guardrails Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*"contextual_grounding"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -136,8 +132,7 @@ Parameters: pBedrockVPCEndpointsRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockVPCEndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' Description: Bedrock VPC Endpoints Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_bedrock"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_agent_runtime"\s*:\s*"(true|false)")?(\s*,\s*"check_bedrock_runtime"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -151,8 +146,7 @@ Parameters: pBedrockInvocationLogCWRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockInvocationLogCWRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' Description: Bedrock Model Invocation Logging to CloudWatch Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?\}\}$ ConstraintDescription: > @@ -166,8 +160,7 @@ Parameters: pBedrockInvocationLogS3RuleParams: Type: String - # TODO(liamschn): update default value of pBedrockInvocationLogS3RuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' Description: Bedrock Model Invocation Logging to S3 Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"check_retention"\s*:\s*"(true|false)")?(\s*,\s*"check_encryption"\s*:\s*"(true|false)")?(\s*,\s*"check_access_logging"\s*:\s*"(true|false)")?(\s*,\s*"check_object_locking"\s*:\s*"(true|false)")?(\s*,\s*"check_versioning"\s*:\s*"(true|false)")?\s*\}\}$ ConstraintDescription: > @@ -181,8 +174,7 @@ Parameters: pBedrockCWEndpointsRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockCWEndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock CloudWatch VPC Endpoint Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -193,8 +185,7 @@ Parameters: pBedrockS3EndpointsRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockS3EndpointsRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock S3 VPC Endpoint Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -205,8 +196,7 @@ Parameters: pBedrockGuardrailEncryptionRuleParams: Type: String - # TODO(liamschn): update default value of pBedrockGuardrailEncryptionRuleParams prior to production - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "input_params": {}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock Guardrail KMS Encryption Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: @@ -216,9 +206,8 @@ Parameters: {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" pBedrockServiceChangesFilterParams: - # TODO(liamschn): update default value of pBedrockServiceChangesFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["904233121418"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' + Default: '{"deploy": "true", "accounts": ["111122223333"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' Description: Bedrock Service Changes Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > @@ -226,9 +215,8 @@ Parameters: 'log_group_name' (non-empty string). Example: {"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}} pBedrockBucketChangesFilterParams: - # TODO(liamschn): update default value of pBedrockBucketChangesFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["904233121418"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["model-invocation-log-bucket-221082195774"]}}' + Default: '{"deploy": "true", "accounts": ["111122223333"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["model-invocation-log-bucket-444455556666"]}}' Description: Bedrock S3 Bucket Changes Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$ ConstraintDescription: > @@ -237,9 +225,8 @@ Parameters: Example: {"deploy": "true", "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["test-mod-eval-bucket","test-bedrock-kb-bucket"]}} pBedrockPromptInjectionFilterParams: - # TODO(liamschn): update default value of pBedrockPromptInjectionFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' Description: Bedrock Prompt Injection and Sensitive Info Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > @@ -249,9 +236,8 @@ Parameters: NOTE: input_path is based on the base model used such as clause or titan; check the invocation log InvokeModel messages for details pBedrockSensitiveInfoFilterParams: - # TODO(liamschn): update default value of pBedrockSensitiveInfoFilterParams prior to production Type: String - Default: '{"deploy": "true", "accounts": ["221082195774"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "model-invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' Description: Bedrock Prompt Injection and Sensitive Info Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"input_path"\s*:\s*"[^"\s]+"\}\}$ ConstraintDescription: > @@ -262,9 +248,8 @@ Parameters: pBedrockCentralObservabilityParams: - # TODO(liamschn): update default value of pBedrockCentralObservabilityParams prior to production Type: String - Default: '{"deploy": "true", "bedrock_accounts": ["221082195774"], "regions": ["us-west-2"]}' + Default: '{"deploy": "true", "bedrock_accounts": ["444455556666"], "regions": ["us-west-2"]}' Description: Bedrock Central Observability Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"bedrock_accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]\}$ ConstraintDescription: > @@ -273,8 +258,7 @@ Parameters: pBedrockAccounts: Type: String - # TODO(liamschn): update default value of pBedrockAccounts prior to production - Default: '["221082195774"]' + Default: '["444455556666"]' Description: Bedrock Accounts AllowedPattern: ^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$ ConstraintDescription: > @@ -282,7 +266,6 @@ Parameters: pBedrockRegions: Type: String - # TODO(liamschn): update default value of pBedrockRegions prior to production Default: '["us-west-2"]' Description: Bedrock Regions AllowedPattern: ^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$ From 2b58f85cb541063745b2f74b023f73f651a5b432 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 12 Dec 2024 16:42:30 -0700 Subject: [PATCH 330/395] fix flake8 errors --- .../ami_bakery/ami_bakery_org/lambda/src/codepipeline.py | 4 ++-- .../solutions/config/config_org/lambda/src/config.py | 2 +- .../solutions/genai/bedrock_org/lambda/src/sra_config.py | 4 ++-- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 1 - .../solutions/genai/bedrock_org/lambda/src/sra_repo.py | 1 + 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py b/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py index 01f68b50a..a84b6edcd 100644 --- a/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py +++ b/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py @@ -90,7 +90,7 @@ def create_codepipeline( "roleArn": "arn:" + aws_partition + ":iam::" + account_id + ":role/" + codepipeline_role_name, "artifactStore": {"type": "S3", "location": bucket_name}, "stages": [ - { # type: ignore + { # type: ignore "name": pipeline_name + "-CodeCommitSource", "actions": [ { @@ -104,7 +104,7 @@ def create_codepipeline( } ], }, - { # type: ignore + { # type: ignore "name": pipeline_name + "-DeployEC2ImageBuilder", "actions": [ { diff --git a/aws_sra_examples/solutions/config/config_org/lambda/src/config.py b/aws_sra_examples/solutions/config/config_org/lambda/src/config.py index a5a1a2c50..a67a75f63 100644 --- a/aws_sra_examples/solutions/config/config_org/lambda/src/config.py +++ b/aws_sra_examples/solutions/config/config_org/lambda/src/config.py @@ -92,7 +92,7 @@ def set_config_in_org( configuration_recorder: ConfigurationRecorderTypeDef = { "name": recorder_name, "roleARN": role_arn, - "recordingGroup": { # type: ignore + "recordingGroup": { # type: ignore "allSupported": all_supported, "includeGlobalResourceTypes": include_global_resource_types, "resourceTypes": resource_types, diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py index 17c2a4144..0663cdcd0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_config.py @@ -115,10 +115,10 @@ def find_config_rule(self, rule_name: str) -> tuple[bool, dict | DescribeConfigR self.LOGGER.info(f"Config rule {rule_name} exists: {response}") return True, response - def create_config_rule( + def create_config_rule( # noqa: CFQ002 self, rule_name: str, - lambda_arn: str, # noqa: CFQ002 + lambda_arn: str, max_frequency: Literal["One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours"], owner: Literal["CUSTOM_LAMBDA", "AWS"], description: str, diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index cae37c7b2..8df046cf6 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -35,7 +35,6 @@ class SRADynamoDB: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - try: MANAGEMENT_ACCOUNT_SESSION: Session = boto3.Session() except Exception as error: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py index 3ef09901e..3e308f382 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_repo.py @@ -17,6 +17,7 @@ import urllib3 + class SRARepo: """SRA Repo Class.""" From d355eb67f3f7afe568a72e976fe59e44d47bcb20 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 13 Dec 2024 10:05:05 -0700 Subject: [PATCH 331/395] resolving mypy errors --- .../solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py | 4 ++-- .../security_lake/security_lake_org/lambda/src/app.py | 2 +- .../security_lake_org/lambda/src/security_lake.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py b/aws_sra_examples/solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py index d56ae666b..393a5204a 100644 --- a/aws_sra_examples/solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py @@ -369,7 +369,7 @@ def manage_task_params( """ if task_operation is None and task_reboot_option is None: no_param_response: MaintenanceWindowTaskInvocationParametersTypeDef = { - "RunCommand": { + "RunCommand": { # type: ignore "Parameters": {}, "DocumentVersion": "$DEFAULT", "TimeoutSeconds": 3600, @@ -382,7 +382,7 @@ def manage_task_params( task_operation_final: str = "INVALID_TASK_OPERATION_PROVIDED" if task_operation is None else task_operation task_reboot_option_final: str = "INVALID_TASK_REBOOT_OPTION_PROVIDED" if task_reboot_option is None else task_reboot_option with_params_response: MaintenanceWindowTaskInvocationParametersTypeDef = { - "RunCommand": { + "RunCommand": { # type: ignore "Parameters": { "Operation": [task_operation_final], "RebootOption": [task_reboot_option_final], diff --git a/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/app.py b/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/app.py index c01f557ae..8fdfe58f0 100644 --- a/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/app.py @@ -44,7 +44,7 @@ try: MANAGEMENT_ACCOUNT_SESSION = boto3.Session() - PARTITION: str = MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(HOME_REGION) # type: ignore + PARTITION: str = MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(HOME_REGION) CFN_CLIENT = MANAGEMENT_ACCOUNT_SESSION.client("cloudformation") except Exception: LOGGER.exception(UNEXPECTED) diff --git a/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/security_lake.py b/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/security_lake.py index 74ff92e79..15b0028a3 100644 --- a/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/security_lake.py +++ b/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/security_lake.py @@ -104,7 +104,7 @@ def register_delegated_admin(admin_account_id: str, region: str, service_princip region: AWS Region service_principal: AWS Service Principal """ - sl_client: SecurityLakeClient = MANAGEMENT_ACCOUNT_SESSION.client("securitylake", region, config=BOTO3_CONFIG) # type: ignore + sl_client: SecurityLakeClient = MANAGEMENT_ACCOUNT_SESSION.client("securitylake", region, config=BOTO3_CONFIG) if not check_organization_admin_enabled(admin_account_id, service_principal): LOGGER.info(f"Registering delegated administrator ({admin_account_id})...") sl_client.register_data_lake_delegated_administrator(accountId=admin_account_id) @@ -913,7 +913,7 @@ def set_lake_formation_permissions(lf_client: LakeFormationClient, account: str, try: resource: Union[ResourceTypeDef] = { "Database": {"CatalogId": account, "Name": db_name + "_subscriber"}, - "Table": {"CatalogId": account, "DatabaseName": db_name + "_subscriber", "Name": "rl_*"}, + "Table": {"CatalogId": account, "DatabaseName": db_name + "_subscriber", "Name": "rl_*"}, # type: ignore } lf_client.grant_permissions( CatalogId=account, From c3aef8cb84c3f45eb2635675fbde9ecec2fa8edc Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 13 Dec 2024 10:11:09 -0700 Subject: [PATCH 332/395] black lint reformat --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index c7ea68ad7..5ea70fa4f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -924,11 +924,7 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info(f"kms_key_policy: {kms_key_policy}") kms_key_policy["Statement"][0]["Principal"]["AWS"] = KMS_KEY_POLICIES[ALARM_SNS_KEY_ALIAS]["Statement"][0][ # noqa ECE001 "Principal" - ][ - "AWS" - ].replace( - "ACCOUNT_ID", acct - ) + ]["AWS"].replace("ACCOUNT_ID", acct) kms_key_policy["Statement"][2]["Principal"]["AWS"] = execution_role_arn LOGGER.info(f"Customizing key policy...done: {kms_key_policy}") From 12d4e5cf7e06afcab943ffc0641283ce45726082 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 13 Dec 2024 10:26:44 -0700 Subject: [PATCH 333/395] resolving checkov errors --- .../terraform/solutions/inspector/configuration/main.tf | 1 + aws_sra_examples/terraform/solutions/macie/configuration/main.tf | 1 + .../terraform/solutions/security_hub/configuration/main.tf | 1 + 3 files changed, 3 insertions(+) diff --git a/aws_sra_examples/terraform/solutions/inspector/configuration/main.tf b/aws_sra_examples/terraform/solutions/inspector/configuration/main.tf index 131a08a47..2fcbecbf9 100644 --- a/aws_sra_examples/terraform/solutions/inspector/configuration/main.tf +++ b/aws_sra_examples/terraform/solutions/inspector/configuration/main.tf @@ -437,6 +437,7 @@ resource "aws_sns_topic_subscription" "inspector_org_topic_subscription" { ######################################################################## # AWS SQS Queue resource "aws_sqs_queue" "inspector_org_dlq" { + # checkov:skip=CKV2_AWS_73: Using default KMS key name = "${var.sra_solution_name}-dlq" kms_master_key_id = "alias/aws/sqs" diff --git a/aws_sra_examples/terraform/solutions/macie/configuration/main.tf b/aws_sra_examples/terraform/solutions/macie/configuration/main.tf index 249da2586..0c35bb7c8 100644 --- a/aws_sra_examples/terraform/solutions/macie/configuration/main.tf +++ b/aws_sra_examples/terraform/solutions/macie/configuration/main.tf @@ -377,6 +377,7 @@ resource "aws_sns_topic_subscription" "r_macie_org_topic_subscription" { } resource "aws_sqs_queue" "macie_org_dlq" { + # checkov:skip=CKV2_AWS_73: Using default KMS key name = "${var.p_sra_solution_name}-dlq" kms_master_key_id = "alias/aws/sqs" tags = { diff --git a/aws_sra_examples/terraform/solutions/security_hub/configuration/main.tf b/aws_sra_examples/terraform/solutions/security_hub/configuration/main.tf index 9204c5897..3a36e6e09 100644 --- a/aws_sra_examples/terraform/solutions/security_hub/configuration/main.tf +++ b/aws_sra_examples/terraform/solutions/security_hub/configuration/main.tf @@ -435,6 +435,7 @@ resource "aws_sns_topic_subscription" "securityhub_org_topic_subscription" { # AWS SQS Queue resource "aws_sqs_queue" "securityhub_org_dlq" { + # checkov:skip=CKV2_AWS_73: Using default KMS key name = "${var.sra_solution_name}-dlq" kms_master_key_id = "alias/aws/sqs" From 0156b950422136d60d15585b942ac848a050f233 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 13 Dec 2024 13:14:45 -0700 Subject: [PATCH 334/395] adding documentation --- .../solutions/genai/bedrock_org/README.md | 118 ++++++++++++++++++ .../bedrock_org/documentation/bedrock-org.png | Bin 0 -> 79547 bytes .../documentation/bedrock-org.pptx | Bin 0 -> 256206 bytes .../templates/sra-bedrock-org-main.yaml | 4 +- 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/README.md create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.png create mode 100644 aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.pptx diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md new file mode 100644 index 000000000..6f9e5bda6 --- /dev/null +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -0,0 +1,118 @@ +# SRA Bedrock Organizations Solution + +## Table of Contents +- [Introduction](#introduction) +- [Deployed Resource Details](#deployed-resource-details) +- [Implementation Instructions](#implementation-instructions) +- [References](#references) + +--- + +## Introduction + +This solution provides an automated framework for deploying Bedrock organizational controls using AWS CloudFormation. It leverages a Lambda function to configure and deploy AWS Config rules, CloudWatch metrics, and other resources necessary to monitor and enforce governance policies across multiple AWS accounts and regions in an organization. + +The architecture follows best practices for security and scalability and is designed for easy extensibility. + +--- + +## Deployed Resource Details + +![Architecture Diagram](./documentation/bedrock-org.png) + +This section provides a detailed explanation of the resources shown in the architecture diagram: + +1. **CloudFormation**: Used to define and deploy all the resources in the solution. +2. **CloudWatch Log Group**: Logs for Lambda functions to monitor execution details. +3. **SNS Topic (Alarms)**: For publishing CloudWatch alarm notifications. +4. **SNS Topic (DLQ)**: Dead-letter queue to handle failed Lambda invocations. +5. **KMS Key**: Used to encrypt resources such as SNS topics and SQS queues. +6. **CloudWatch Filters**: Monitors specific log events based on configured patterns. +7. **CloudWatch Alarms**: Triggers notifications based on predefined thresholds. +8. **CloudWatch Link**: Links metrics across accounts and regions. +9. **Bedrock Lambda Function**: Core function responsible for deploying resources. +10. **Audit (Security Tooling) Account**: + - **CloudWatch Dashboard**: Provides an overview of the security state. + - **CloudWatch Sink**: Receives metrics and logs from other accounts. + - **Resource Table**: Maintains metadata for tracking deployed resources. +11. **Bedrock Regions**: + - **CloudWatch Filters**: Region-specific event monitoring. + - **CloudWatch Alarms**: Region-specific alarm configurations. + - **SNS Topic**: Publishes notifications within a region. + - **Config Rules**: Enforces compliance policies. + - **Config Lambdas**: Functions to evaluate and remediate non-compliance. + - **KMS Key**: Encrypts resources in the region. + +--- + +## Implementation Instructions + +You can deploy this solution using the AWS Management Console or AWS CLI. + +### Deploying via AWS Management Console +1. Open the [CloudFormation Console](https://console.aws.amazon.com/cloudformation). +2. Create a new stack by uploading the `sra-bedrock-org-main.yaml` template located in the `./templates` directory. +3. Provide the required parameters such as the email for SNS notifications and other configuration details. +4. Review and confirm the stack creation. + +### Deploying via AWS CLI +1. Run the following command to deploy the stack: + +```bash +aws cloudformation create-stack \ + --stack-name BedrockOrg \ + --template-body file://templates/sra-bedrock-org-main.yaml \ + --parameters \ + ParameterKey=pSRARepoZipUrl,ParameterValue=https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip \ + ParameterKey=pDryRun,ParameterValue=false \ + ParameterKey=pSRAExecutionRoleName,ParameterValue=sra-execution-role \ + ParameterKey=pDeployLambdaLogGroup,ParameterValue=true \ + ParameterKey=pLogGroupRetention,ParameterValue=30 \ + ParameterKey=pLambdaLogLevel,ParameterValue=INFO \ + ParameterKey=pSRASolutionName,ParameterValue=sra-bedrock-org \ + ParameterKey=pSRASolutionVersion,ParameterValue=1.0.0 \ + ParameterKey=pSRAAlarmEmail,ParameterValue=alerts@examplecorp.com \ + ParameterKey=pSRAStagingS3BucketName,ParameterValue=staging-artifacts-bucket \ + ParameterKey=pBedrockOrgLambdaRoleName,ParameterValue=sra-bedrock-org-lambda-role \ + ParameterKey=pBedrockAccounts,ParameterValue='["123456789012","234567890123"]' \ + ParameterKey=pBedrockRegions,ParameterValue='["us-east-1","us-west-2"]' \ + ParameterKey=pBedrockModelEvalBucketRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"BucketName": "evaluation-bucket"}}' \ + ParameterKey=pBedrockIAMUserAccessRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ + ParameterKey=pBedrockGuardrailsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' \ + ParameterKey=pBedrockVPCEndpointsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' \ + ParameterKey=pBedrockInvocationLogCWRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' \ + ParameterKey=pBedrockInvocationLogS3RuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' \ + ParameterKey=pBedrockCWEndpointsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ + ParameterKey=pBedrockS3EndpointsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ + ParameterKey=pBedrockGuardrailEncryptionRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ + ParameterKey=pBedrockServiceChangesFilterParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' \ + ParameterKey=pBedrockBucketChangesFilterParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["my-bucket-name"]}}' \ + ParameterKey=pBedrockPromptInjectionFilterParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "filter_params": {"log_group_name": "invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' \ + ParameterKey=pBedrockSensitiveInfoFilterParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "filter_params": {"log_group_name": "invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' \ + ParameterKey=pBedrockCentralObservabilityParams,ParameterValue='{"deploy": "true", "bedrock_accounts": ["123456789012"], "regions": ["us-east-1"]}' \ + --capabilities CAPABILITY_NAMED_IAM +``` + +#### Notes: +- Replace alerts@examplecorp.com, my-staging-bucket, and other parameter values with your specific settings. +- Ensure the JSON strings (e.g., pBedrockAccounts, pBedrockModelEvalBucketRuleParams) are formatted correctly and match your deployment requirements. +- This example assumes the CloudFormation template file is saved in the templates directory. Adjust the --template-body path if necessary. +- Always validate the JSON parameters for correctness to avoid deployment errors. +- Ensure the --capabilities CAPABILITY_NAMED_IAM flag is included to allow CloudFormation to create the necessary IAM resources. +- An example test fork URL for `pSRARepoZipUrl` is - `https://github.com/liamschn/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip` + + +2. Monitor the stack creation progress in the AWS CloudFormation Console or via CLI commands. + +### Post-Deployment +Once the stack is deployed, the Bedrock Lambda function (`sra-bedrock-org`) will automatically deploy all the resources and configurations across the accounts and regions specified in the parameters. + +--- + +## References +- [AWS SRA Generative AI Deep-Dive](https://docs.aws.amazon.com/prescriptive-guidance/latest/security-reference-architecture/gen-ai-sra.html) +- [AWS CloudFormation Documentation](https://docs.aws.amazon.com/cloudformation/index.html) +- [AWS Config Rules](https://docs.aws.amazon.com/config/latest/developerguide/evaluate-config.html) +- [CloudWatch Metrics and Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/WhatIsCloudWatch.html) +- [AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) +- [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.png b/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.png new file mode 100644 index 0000000000000000000000000000000000000000..f8e497111a1c37037257ff848144af79d38dc165 GIT binary patch literal 79547 zcmY&=1ymee(l#!^J-BO-;5xVj2<~pd-DPkH5Zv8@y9al7cXxMp{>i?(|L(_}Io(X( zzPE05+0;`tzvN{lkUrsk0s{j>lKdvB2nGf*2?hq900#m3ME#q>FVF$pQBgt|tYVzt z2=q^&vAU#*j0_kZ=o}6V8XO%A^6wDP3k)0&4EkT^U|>?<`2YW05uE1VV?c8Tn}b39 zdyFRN`1g+k^akDi?;~V3_(Jgexkbt>BbR#_ATRGt+QWLQJv}Y0{Ul=q zBpxgqvKhf7SGK+2j-ZT#pjuptrMu<7Kk)4b|2qN`+00ku1or<7jc^o%Bw!ymEC>5P zb3hVk68|&Vzwfn3hs4_~vd6ZB{?97^T-cm^{`ZJ~)|cNQm;{rEs8#wPOpzdvms+J@kE59qDrytu z|2yZb95NRV&%8k&TY?lJi{2N_|6bCj0nsq<^Sa=Vi^c!vfFIAUFAUdl^pob)e;KeT zyd_8o7Fn?Md#%~&_V%kb4d487F^Gl#rh(B7dA4NAOg{UA{6UW2e>v;;iq(Boc z|GB@pt9H{|x3;liR0pT<%S9Ft=zvDRMuog$o#11&UFU;vcXtOvwza*GW@@W59!VsZ zi1a+lPvU=nG)mdZ1OUauKcf*4@OeI7ay#z(5^Zm9Co*VJ!CS$S=(c+{S>v4*|0A3K zNDm3OytHqwSnVLymO+g&?%>c6fQYc?tYi+KD8pOM>|3 z=_4md7{tXIV+6(E&d$z|5V$-%d`=qzHZyq_7Z-bb`-}B9i72ACA?hqP+jZ^zr)A4Z z!29El>xLI+)lL{wyPbx~5D- z67oLZ9+BI)^B8nPp{+OAZb%S&1=bX(RqDb?*VNP?6Y&j_Llo)1)3nW(Y2@}ZvdA&~ z)&Bo4knsb4%U}dMl|=8a-3`|_2*ag;Ap@szeDkY=bPpQk=HugIW>#3u@c|bJ`6>^&^sBP+ z;%-6jV}z;Wn@8=^cQ+8@M1l~nuCMVpZPGpSp#OQK|B&SOiCZ4u7xJfA=9IdD3W97eX3@4fntbYr|Q$OMg+3yfq>*2{IY>;u>FS~c4^T`l9mN5=^*G=al46-fk;N~Qn@ z2Y-9M6L|-ZKU!^K{R+(te@~XzhYclF$M89f1Ujb4{9m^CKg{XmfF-az7gcrV1AH}{ zH+tBzh4j(mu;^h-2)@XAedZ;mqM$;kA#WKs@#EYaC1}V z6CT<=KWE%Th+2fmE!ZmV%5IYUvzg*j`X5oa%YYoUWam6mTYSW$*MdN%1K)3a+duJa zZ5yQwWfo1r;Z<#ayf5s{a}15L04Fd`%oqC~tBDW+i37D+v?@IZCb9QekU#xj>oreM zq}8c_FP=MZJ#YA*{rr_bKZH<_vuMoLZJ1a8uUC=h{OcLkE_J@B{8vV`<^P&$=)2}W z{r|IQ^VVNe9dw~t{6D7Je_IgACahe`auMN}!6IVrzl2~pWZN7V%XBMjAxy*lA3{nT zK;BJdFFW!7SiGWW$UpXpq0W@v4#d`k8coR|m(jzNvx?^bau#67w_Nq=W5E`<8FluO zV~PygF9cPGbAR;wSftDFI~7b+=x{UymNc8{&5HjnR%DEPYUZvc6JHb|&EBQLAjius zFVeO=K5ZRsPKSj*Iz{x)y#LnBxgI|lrmZ3(4zs3zJZWbr?qQcZh7j%>VG1bQ?u(#)o z-ffz}h!=ZBwqUttZ(C`qaR@1pNSjIqxM(*+C6lGk%TJlt$i4lePusnaVM^`VVTN<# z%JV-Yi9a+@lPn0Cj0X8^CnRmY8vYuJv}u-_eNEF|p(_D?Xm$b2h&2ISAwDsf?PI{(7W$tqLPGy1)3Ow|^EE!#syx zMI={3sl^Z{-s$&@%!O*3SN4QcY|@JqaAx3oevTnwtFya%b}sNtR&a9|%uhp0TTT6& zJ;E`&!5UaSD&SJ!`6n?DOL2HKB3Q1_vzltBfsl;KdrHn+8q3{kP4}vjA;9*SXR_jU zPirRWEjO~vFxlX6mz-FY8H1sz-%oSjSFI()=HmOwfeu8+`Gh)UqWQAn)3prlQ*hc^qBZ`&Qqx$Hq3UqlO<}1Za51mz;jXk-2V*c`PBsmzk znB7E;JTB)+9&(%+U05ti;x?-Y8@WI44yVhOx_ONbJkK#V6RI6rfCEJ)2p9TyoAW%c zkF%~)pF*WSg%M^b1SO!WGF@-MX1ZJoNcJSAdug+SpQE8&q zyiBcWf{%w`+OKWCB!L-TfPmn;szQc&J=K(AZF0!BicCwZ$*Nw@3B%_R-9H2T`aDx=yE15M z!~MjyF;q2pU^AxMv5~G8eKjy%P)l9^;VwIVpZg>shm-!D;{dB(i{p#yg1!DHO_K=`Gr(d zZYB&S_{+7txBy#YS#xvur)#NF*o6mqAFX4y%EUy>HG-@Ar4m~00t{Dc3B5%#ntWoi zs4uf><%z2YfJTJ79~M#W*Gip2yRZ0iV=s!5Ej#Lqb*0_s_z9QyO!3H^EZ!R_?c2oS z6)pn&U7cNa^6}{j4KJc6a>P#7=+RFHHXC1nU>#7)B*CNXKpm~1C!IOi1gZ0JBNHQ` z+kgvBEhp0%4GblpMNyJ#(CZSRfy1_o=d)`xDJ0?{9|;Y z?B7UT#AgjcjwJgt0u39_*X_8(*9~{i2Vb<|e)Vg5YV6sQSRWMt-iY5C4xC7SYNb(O z6WhL?e9=yYs(-tZl%NK14|XwZoh9%mx$fl3J~>2OnZ1G(_c`Kxe(OI9Wc`UM4{6H^5k!-56gw^n3IZ@=1ZCqrEkr z86yp*SoF}4gn*MRK9OgOVa)hJ-A5=lG8KkiOf&KzUAoAt@2e=!lgH^DWG5AnD+;kT%KC#;!2-NF$6?$6e6 zCo!WTPdYve#7C_hMxXIs9rZuHykn{kvS0P2***UVrBMVy%*C`JG&zrsE*&G#WGod&h{d|QAvRSHk6Z8&YZ zdy+Z--HG$)MiCf^os)yZ>=9oxJIH-QG3QQtkwt{3yNa~rv)&Qe3{qgS{0>v8BQUL) zVPY`0M`b>MIiG3Y7z|w7O5zr3KvXz%jy&{$B210%eq>Y1^w+tLqHZMlZ9kpGtjDC1 znm#7?v|;<>BG6Tp=LyL30(7wCLrr^L6W^uTjAGrWq$}@Jngx{6`SO2bp;;?;4~gqg zJDDJ&XnM$&4J6FCBM|&vU#CV}b}MYUD^Q@R>)O7*)b{hLlXkkl?Y51!urp_FlY=HgLN*B- zSb+J1R7#_&G=6A1=DyTE+s`hWVsjf=h6)kWCumT=t?SQ9n$nFdm5G&rziJ<<@q)eV zyj~(3QNwP)dJHA3)Tdd3A}7x4q(SLXJr2&EE>;d9Hur1Nf&DDJUxyybuv|oufIP)ns zrL4OF%jw4W=dYCD3U_3w1ARTHa9?<1O@@q{R6ZV}pT?5a8V6eij`sH*B`Cw+^uehJ zM+@@V@||)%$8z)N6K$cRx#{onZKrm4s7)985H}g4owyZvE|`!WvGQ+?DDPWKz$U_l zDw9wP+^)ySdS|kbPTs{pu zH?XMzUf}L_A?RLaZG}TdtG>_F&Qz3~Zsm1 z>a=|dyZj!1>ZCKIDI{_dK#L`xkn?bteeK&?WNOQ;Rde6b@Ao|JcgqkQ>Nsw_u>8;~ zfySxh@>;%!%Nx%?0VLL3wKDe~}uTr_wabwIdf*jIX1 zb>3nDUH+k5P9}ZIY)|cBI|maxgbWrkmvyxwArQ4fDE@Zav$Y#lR0>Ws^g`)QMY7XL z*4%HyH3d<6ka+rpKg~qxrN6%DXJ9)VV6ngp8OmktKl1`%$_Z^}(|DIi@gi2!r8Tc*i$4{+9}H z=JtL2YMUUp3^FX}EKb+tuF7)wgv#i*>wyWwyX#y9P&Zg`QhK;FV&sQ93=Pg&@cM{O ze+by8c;6EkUzlV}-q$NkL&;0O@y#4;7bNWQZUyjt1||mU6ziLsh2IvR@B8$jXfa>L%&v! zV`ym9;U)S5hv~(Nsb8xn)SObd&0_FiOOhZ79F6TKQ$3uzMV%%BE;Qm7Uo&Hh@gGdz zfc|c+_D*ik^8gfv`fPef?V7=ZRvVDoVRo6xl{r?$jq>~sS2!qpA0?GH9VgF7t0j2; z;eZys13yBoN`)==XC-yt*SRv0aydDPQf3SqlsB z7IF{EU=f7E-%-KjIm(zDlfQqkXLdqu#kx$%d4|g~@4lB30JO0nTurBq;tUj;%e9=x zpB3dP<4y>sYSoi0zckcNjL#mNmiK)eWF8ahtgtT$Y`JHPJf@$F$M?|Up2p^g9>FG&KOso_6F4v()hg^6gg+9~7*f>F2wS%}Z;$r59 z=g~cOwt9fI*xP=!YD>^Cc!unOq6Zq7%d99XD(89o#aYM+lUS?$;Stl#&WuG4rgLUl zac_R)Y&Ymz&ba>p-qFyXiOy#PuI+Y>m9vZ&hsbosZ{JYbbEw1M|BUCyD^XIxI+~c6 zh>MFG{3e6jO50811ZA(N%m7;uub&R@D=I45+uQm1`JtpFe$l~B zbh)mKryrhL>-!9^4+zwmec8-e{2fT?Ei5eT;!;0bZsy1kKAoJ+yafS8fHrqgheYhy)6X}xM@m&SZ<{AO%b)?=MqUPMcys{+AVJy!te<_4Ax002t!1y+{! zEF#O^A&{5TeR$3`Ue@cU-1x0aac3U#x!}Lsv&b~;j8RiuwZCx`@#d<< zX>Fyr?=2k1w?Ih|&;1Koyd8A9u3!wfAk>-q*4H1XMad@En6sZN3T632adL2Q@bGv{ z*VjDwEa}Nv8_#XdqccFJuLR>&oDo{c`CtROn$<>#42^UX+1PQ4fSn3Q_2$00(`7U0 zs!Xt|mAU}O{qKS6FAo>g2grmx+Mw+5;QhgPhE^N2dDGI6UmE}7$5meaJ>ulNh$8@s zAcRvklh1v-_ZP1#Z3RTBgE{Dt+ErFeFK=XtA(aFFl)0}#M0%7f#_-jk`Fa`iw;kjR zI#W;IQ|`w<)!9qTzrRZSVl^4n?+4gzg0)(2w7*pu^vDBr;aF`z>F6k8fy_h~n^K4> z5fYTLuyyx?jQ+JHiy~*0(;jOPx=N!6IF#zkQ3Vm}*_t^neQMZ}>8vyQ9G5{nzD?9b zd+gC`r>P0t<>xjAW=C%U!>gjHjrr@>f!?oP&rb8Y$4Jec$bNjuzid0+?*As`t7`~b zM_8WIrDH$pZ{8j@d>+#d;Cfs40`uJhN^jNjZ2dW8pJ` zTVE!w>iA9yHNWp{3d-Cb@N9h#fF&??U?`ltJ>O5hn>z9$_Zp!X9A)B3j@R1Hg&`b@ zR!+Mi7P;K)3b3|2otZwl==iXSPu}Urw+-Cd`;D~NO%B)WbaZ{uG5jmT*;z$FQ+{Nw zB9Z@fr$X6^tE6=Q4qOFNYecjHh+b1aVBBY%;n=RLQ$BxPEI?4S{;nN-RszbuWlmnV zf>hN}b&i>t*|?^1Q5*em>-0!7NaI4~EZ~7^m*6{(f=PD}zQ)Te_i`|FKd zBkVRVuM2&Lp3mDKeX1YEkqJmibF-e~>D&SzuZ};@{5J<<$bH`K7L1d0RH@I(o9nVU zN_BL+8u7#??+8>1cd@as(=IMHMxWWCKWjG!0d=0(m3GXI6X=DX%~}Q@L~<6Pn|vD1b5s)pu0xTHV&8 zg0x9XR1)!VpZbwM2S=bheh@DnO>R!ke7Tk@2xSOUZtVr33wLKL4H~s3WI=1Ys9|=w zPhw$#W1(iqt$k*gvwhZK*bM7#I}x_+PX#Kas@t^VcEp~ST|DMoSSwAAh0E=tI!J8% zIci1ZCtvAb>_Bh54N70?K`)Li2ht49BRzT?dZx!EdJ#jHXOd>!?Vz%vhkRA!df`xZ;@!V=CUHlAhcW0p?gJCwpkZeqacy)oFeus7+X^H8pCsKV4*&j84;neQr69#U50h zbrQVa0%3JT(R86L$0eoep{P{ow5cJOpKTT@boNw=n?)xfRKmP%m<2UfKPl@tW)LLz zsptY3wgUhlO?3ZJ+x{hjGymf8rP15sDyVt6@MR*@aR_lB>1)c8>cn%877xXL>N{0v4 ziH@0SuZNm`3S~YL|A1nm7ep;d5@N|FCQoG02?1cM&Mj(2`g=eq1(~w zfrL%yqU;xWoC&y%wJ>H6CzwQI)CkT81=07x6QU#b0KfU690D1RYg{?})!7LV zZhMeWkDfQIx!_}DnvM2H;NyPsc|Ix>3&Z~P+1s(EhSilYsex>B5NzkPcgSV+cvU-| zRo{Gsy?XU#2y>8j7iTL27Fw~f(KjWHSe_Nb2>*K~3% z#t`nY!@J`>OQV=_8ghHOa0zAv*v_HCp07j2SQ=-pE}DTiwM^>mewuAbA*>{=xdjhN zJ~SrOJLFZ!0~R_ui~Y7Jg(9yDjinx)Mgtx(JMj-<@BI|hOwVh{tHTxB4y^84H9fC{ zxBl35j-XOTv^vqbwH8-c2@dmVC_DI>BHf8hz*U6(hS$R{y16gP8SzS=Gi+M9hHdYz z*oK%_{1~Wf+E+52WG3{P)ZRh?#qcT64Y~yN&+4J7CKcWuJh>#yrI#g);G3C9 z6094Kyf*!pG?M0ocJtgGEem0^L(MkM_)0n;ozoIJa{h3cBPYjde%eplYn-XKUYW|3 zVBOxlDrk*S0Fj#(FC;yya|tc56X_l#He{*90ZGwYdnSW8!w&5XDa9d$N*XI z+<1r4$vT0pCk0``7azxiJw0UK_hSW63Sd2WZg_#pwbszOqyGuKZN)^c|zFDLPY9Yk0IX zdhIt#nXGl%oYAS__dXdqHONPR;QnB~EI!i=k^BA~3S3`}R4tI%{1-SR&k)@&LHJKZ zPiY zT$81v3X@`tVWLaqF-+wiuxAG1CFSMF?77P-;KvS9`P{Ezi9LN2ityHj#4A-X7KGKM z5PZEhd_DkJ7)%Jzl$RlDGr1Bd3K5_3q5sHhDFmMOtag95(Wy`TI?%#NCFAoEApPb|FNJ)Op#mr$9XfwVziuABN<$Ti%VC69e zDtdJQj?VqI202I~FI|n(V~Xp=Oy63m=QJ+3N~HCZ3NpnCVjC8E3FSC+=h*G^EjRS; z^C|$b2J|c_))%mT-zg#5VW_Ly9UP~<>X5Ioj9A`2<#C7kd}-i?hCzhVIbzKYht5E(u#ln}Q~0&`cYb8TV3)4+)$Z(^%jg zMktmX(Rbws(xL93__rkwt{T2$Xiu{2k8TXX7jRdvCwG-)T8!g5o!{%Edzt|DCEI(1CM{q1z99hHENm%Vo$P_m% z9UXHr-3`1q7RmtSIlt-zIBXC!s01QUPSLmmR3~xyWtQd}SA0=zQbP;rq1|cmFQu_3 zXdX_r!b{WzwKI;-cjR~ zf!B~Nhd@$dv~SD%$s;KfXiVJyS^M)FoHDcVFs4HKa^lE8CI(MM;N`T|Qz}OdAtDM7 zOhKghsu+G>K2glRCSKK#C`woJex*yum!cMJkcox*2Ub0zQ!osaZ@y;nXZ0rnTm$~) z_^*y`n9{{b-eK;>hL%c_@YAcMEimKHM{bogl>zWgwc;E`jYi-mNL=^n!|wa0Fb(L5 z>I?UX1@P)s02cb#bUh8Mm)=2>EE`!j8*S;$ku;yI96ZS-v9baV;t2{l?~aX_aBQHK z)4ji?7IP69NBdWB3FkXge6MU5qB+23G&woaew@vEYm*r_ouYD~bP}V=3chAkIETD| ziIVZMn|BR>@#B}mm zjF^MzuplvF{)oV1|Kwk+RCtY`oogtJGR83eL>bpqez{#Fh1_39C8C2wP!~xpo=BOQ zI=auP=81N5&t$N}Bs3TUxvjW&UuxQh((p11SnE+ct8NsS0_ zT87T2&k{aE>CAtGa5`bg>L5X7E@bPPn$b^#a?k=74{ml z$VQf#PTzz=@?~f|WrIJt0H-JKLm8k)NPPk@j4%!4H~|QB^I~h{01cL^VF**v=N52w zFq)!(@{>urMLVx7{agAF#%6IhvixsDv>uj!rmsGqZ?ht#J~puwR9Ez9%1{g_wmV|T zS*bqhN%o<0lo|)Apm5b7yV{9H4-#Q?ay9y)TvP@eZHp+3@IDDY@|nEar5l6m3uBM< zPV_PKD>d9Ayto;?Dj8|EcAVdi5=W9UWO}=cw#fH@qN*-3xUZ8dq!m$$_F2!2j&YmR+W?>~I*ljXCu;FwcW*C)-%FFKIvN|(hA-x0?c8@xDI+JIcEH)r@<10Y#no%i z#Tb~;;a_EWSThwoN7?Q8L;5giHU@j^7+pwOIpQ8l?=A=Pnf3Jt(`SDX4MvideR^?P zu_uC&XBcDX5_A&N+1{*=I4)!P$LRa_>`!39gH`U?KnokR9zrh~{#sN!FoG_Yo|Um* z-zWyZiDnXSpnqm|U4lR1(Z46!-{Glty$tK<^K9n^pJV+}kwTsm{bHDey%$B5j~Zk5 zU6UU=WH;O|`A?U@-1BibbhYz4&bNOFfvFlu2<`^qc%gigK@~Wu?3VR5YoV6GSYK4X zacG2xv$O|6bXpY4{SM)@k$3vi4kO2g=V?_2-{|#PhC2)7{F26%3f(+GFcl7Vj|<&r zB8rv`Wz#3NyOa%CBK6+rbvjIwvT`hP6u z?ftFAeA!ZIWk(RWxloQ_ne4uL@S%44RLNIW+HmgD1kSzB2(c^~d~4~idxKMx<*5Cq>RBJ@Z7HbP zX^8mPV*+g*9fVJxeuV$2Bl%beuvu`zG82hzLb6VqA>G$|Iz7`hr++RabDxdti`WIP;sDHlhpV=uuripR5-M;aV1{r;y{ zsfeaQ)1h{JH&VJ=K;6gVc|qbfzjMDntS27HAriUFQ``kPz4o-RdNOAk)SpVwGQ)-};E_8?)q`W6Tg*C1)Nw}Pnk6T1{mg6#)d8x9 zJwZ!9o36#oDCQDDe=&`h`x2b~1=3 z!8w_2G6aRluRnQ={-Spf)N~U?DNz9=tjTCZ&ussrl2I`8e&@=OK?}t=q}9H6Lp5< z+CoXgwqn;m*I*Xr<+t6LFP{)YkENWi-+YIyoeGYboSYf%u{#znRe&0kuKQWi)|SR; z`wlApQ+}+Nn=1`A*zDdFlIcVOLzuN2~kxm^%@~g^$d}44ktrqd0)h43p{8ryES?DwGne;y_`^r z0Nr~DkFBwEeRFtTgW5L0G>uA(Rs)*$qgu4OD3dSGdW{n0PlmI|WddNQT?2T0(^2~% zN~}{=x1`gPF^*^9Ye^8@L$_Q!9TzGs#ZpiF>+RIm$cm+(Of02|Bpv2KR_vx4U&iy4 zTgimuGsFaC6M zf}o8`9kk5N{iG`g(|?u%i1Gam^^~)@c*@NVXtSWz)bjb=Pa3HB--SB^TsHDbXacmlQ}%03HaU zO1iOmXS$h(*m&cV{Ski$1tK5s<|keVYyK8h!oVqXY%r!$pl*OOx}`c@7vxp|w~;Fk zG3jGJ+WyL(q8(67&a`NEcYnb1!w6(^@$`;js*!QkvceK?h!a)?ZgPFW3EOztdmJE? z5KVoUG8E7XTOQqyv@=r9z_7=`b)VzuJ#8DIUs4dT^mHqJd(-ecG0J+l;w%VWbobtt z0j7`_vh;Fw{5=Ory+(v4IAp5lXn5^^i9GL%6rbEb-`NBJ%$L}ZC^%fluy+!5&;(jx z)oK&pQ{Qg%e!@oivl8g&IO`IH&ig`>eQD_?b9Awh6ar^vJk#`uXdMNvUPd1fh2a8m zxRooBXyaVn(V6h4AsXy;95&XX`Hv7Ta3pxep0RAA##()fUm6)@%dNSKYu^&TYj8`} z;y{h1?z{Jquv12lm~NX%;3VZOAz;lnHB(FgDP8aO2lTQImYddg+GDtV%22$VD}WD? z$I4himcmdB3eG?C7ohwgddjiY-f}$c>!-eSKm7I^QzQ7s6R!>XzE`rixRn9%SZYWW z!SDVzn`)rh21ZbqZd;5_P7XgJ`gZvGsDv*!3-5R$@|#~r>vN8If~{;KRJ-&~FcL*wMN~Hx(KN zJE!>&TR7kdU zgVW4VI0G~8c6LxLM^e3#nH)X^ES`){1(R=puKcf<80Gn*j;4;63@tPNSpF>2(^UQ<9!uB=Hp6`salw{ctc8+jL^%QnbPL{L~U}zBE!6VJI zY9|gZPtaj0MKHSyVnfnkV0Gr%1-cxR!fE`WZ3b1mm@y-CTVvIVAMBvNgi0WtG;D58 z<;2*&Kwi4_beC?Qou4VvMV)OvJ+>6(frE0d>I7}qz|_IArge-L5RCNIW+U!# zuy%bRa`X+T)b)F3(3!$`vJ6zR=RyW`-FmH92qutJ&k$<34MrpD_g-PGg-OJl^0LtX z2+V@h`6_*(#ps>T#EyqnXBCqpPdnnK*n=A@h6TeFEN5jy2FBd{^>hh-w*0iD+d^d0 z?AZ|jL2%uNKrD6N8pL8Qn}?hAcxRUq#n?HRQ>L9bYlv+sj$@RF7(ayiqzv_Kq_SWJOnd{|@}QLZ~Il&rvB4Z4;Ff zxgYDz|)j|`v zB7cFDxB*{&7OTcQ9G`Uz@xBjR)$LK7o(&I8embT}GttQ!zspeR^?CQ~E}3RDL12zJyO#<^`?7A~&d~Bdi~Q^O-g* zM*doq42P>5C!~7=X$u37dbP9ylQ>$(1LiD7hUl=}jBm5}+N<5KOVis#-b5a%fn>Ub zWKXpwj_x80O+W^&(-*q0_U-|%dx~995$L*mqBF8xo zki^Ye{FLQd3&@8WKd78>X*gOo7H9J1^0-;`h_@47-c44kmGk*|=(`%WM`T=4P5|Q2Pfv(|{`SC8t z(F0*$KZNoQynr=xf4=w{e|!eX3$fPp-W{zOK(@7Mv{Eh2S13bi;dE%xyROeGm%BBh zFhjRl`r^#{I$&ch4JuG~jnnbqYWD7>n;NmM5Z-fzMrt;Gc!BYj;&nn*D~}L4gn2wR z9U-Bg=4gW7g6qR5&W}mK*(=8S2h)er(haxL)q{n7e%m`Kg2%*{_Z~$D8r}O~XyZSt zcpd90+-E>%RU#DaWA+^f=WsFgjotw-VOKOshp@r};K_1v`BheiA8R259y*cdaFe9j zE1#_LX2q`DL+9&yfqlnMl(wRS(W?7b?@7L5D(a8#>% zNX8xgxAD<188WO}bYwDebJh!S>TpFu0%7r6RgOWflNEk$G>vb%RxIo>DYa_(B;3;L z9Pz#mvY{>c7onX4h3TnW5nePQjI&5xf8581*=0IBL5-0O@_(3Rt!>L)?ra z%h}jyPrGexnMz;A$tN5y4*1ZECEEsc(KwGtv(g4&`8`m4jwJ#SkcoQm)q5W<48L1k z6*)9Sh(j@?O%(RBmqj?NN)qZqSa0HdfO>G&YK%t;WYR>9yL)=B+5@u0?GbSQl=pZH z=Lq5JlW3I6X7G63&8ZCy4H2aAff``8?QP#cjZmoaqHLvV3&iOZX@c2Pw+ApNT=7kH zL|{J72SsXI5lIuE&~U&61Fgq|GKh|fZ z!fn|%)fF;?Ips4Dvl`ZD(kGado9Y*?OcR6s*FCJysXvv~Rolq)d!P^U)Rl*t%#$w& z7bpc@OlBtZptpV%Qug)rqS^wQQg5!EZRSpQv{fY!wz`Sb>`xB&RAaM58V@&So<{Da z@E~lATDV;6Qt$SFigB58i}@<7;a|~-l8Nu1`4)3zLje-oJrj@X99^lT7Bx4YI6+oj ztQ4g;E9$IXY@2RFN}&tXl@so0u$r+Y_6w3GCnv}5;eCvA<6EbY0;;1*)2PJ=SYTJR zwlcmgV>+|spDV>d?yz2jP9aIbxMz4SkW;m}^v%V5Sb=^Os)RU3&$gYa#tZ%BpwiyN zAt!rlh7~DkvO;a!$>$$Y35UxgHEutkb-_RV zesn+1#^G3_*2mt9l7<2-nKE2H%&8;D!z4lanYqwIg_8{XW9Mo2sK5U{xypcAr|Juy6Ged0mgE7;@vnC$wYQA?j+e)XnZ{%_9yt^Zc>mdT& z*dg>&TMl9;>81W0-M+xxd~6Pxl~`LPuEF7k7V)O3lw?mG$H_~=`a;BYi=*LO&4=8v zIJtjfx#GNvb6d@JBfLV^U%<{isJ6D&ZZtV1#T^6#+O=ntF@Ot=8U|EFyOw0lYpREG zqGMoGwq6f|aKC~6enk&i*>Mm|#r?itzhYBc5x&iTP}bsgQuv4X-%7u|rUv(lb_JhK z`tqrPr9hX|UZckXKCgTw7#~qYG@57t-;`;h-qfXsEE?6G>oInnWml;xqEtv)^D0EWsWc^vPHPmr;f*uv3mj@Uo6k5;=pjq zjlec;y-$3zx4XVQw$9%f0(D^OF1=miX=rZ1#p<<1a2helH6})2e|XpO9sNq`)u3}@ zLjfM(Hpp6+HcbBIiB+|aF=d=GP!Ks8)RqOjXnUw=Ip?uwWE;TCD=gH)guUA0htx1Z zm9urdm>zPxB)L#fJD$k&GZoSkX`rV!{sT^2KbG)1VKVx_M2nv31zlER8|Jlq%@Lg4d%fY z8bTr8*9ReNbXIQEW+MJNIRtT)#+sV!%5^Pncc1Ga>d~1;rbZAb6l49>I~3#BTp!Zm zRPA1t0D_yr@DRJrM$Fqv)=*{Jp1{$*FRyL6SBgZXZW{>W8_M!|fBHq82}Vlld$iuh z>noyjFi??#TCMNr82447t$R_)U8|wc>(P8V3?YChdEd)8!E>V+foH?+pPeehy`IJE z&YEKE-JE4uJa^v`Ad4A|ND6tTE~y``hg(+aMwOV>3P>Q`J7c9Dn&z3#<43Jb()`jqaWrSq}f-Pf`|DH*tD zVW|m@Q#mK?a?ymO-&bmDVtd&!1z{QTpmmzlFkD@uR*IVODAeG+uJZ^oQ#%(JY ze7Aayy8$(WWI#Z!wz%B4T18(pmOinbKR?{=)H*EKz@@N?9B9%#sF*6>Y>CfgebjnY z??0(3QO3i*rmXZn2zJ5E1R-)a%yn-k%F;d?%cxXGUbFX8;7}CcnC|TnMegv$Z-;AF zi7hX;eF_>J-ErfH%rwoV?*O&eU>onDMPb9#%ESuF(LwC$^QD-{AzWWye-7ucTCB2K zs_6^Esnlvf)}eLTWMPm}aw-BfX@bz`EKtuVLorq&RBH{)Gui8TGwIFF#U$on{UR|5 zmibWEI|Wjb!|mZz&UF;u^`K>CTfc*bjRlwc^$!sEza(n+c)9gkB>4Vx38Tr&qJ-gt z!1x**%_-#yiL<6VL4|>BY`ib6XefQj@zo~vkh!RX7C>(EoI4;9KBCbv#7)7q-NuR7 z2JBtsz^#^Gn8htdOaedy*fys%9^@Wc4E!gO6)MU#cgr+Kz5zEFx_-4 zq?XQjDJiHOQr*{4zfG(Jgc4r)!4h3wUki8Jf4n^~>9uRikboINdZ7^nY_B(HZHMF7 zjh5KN!{PK%=CwW|Q#Wswu58oZ=8;+dAYkOwpYoDNahQ#h>gF5~q!p{d7f2V%d8 zt_mXc-hnDvnqSUaKwYxyg>u9$*y*-o_z`f+Ia_<>W%Pp<=kv01SkX_~fa#aekvFPv zl#CbA(z%Z34fdm35mxjpJzo`x$=l+f%r!XymfHQ};_;<%o{rXg+esJ?jI2^|s8wgJ z-j+%QYf-`hiHM13Yb;el8C`cYcuE<0Q(fM_LW$3}S%vhvH%ob1WPDK7m~a)rw7Js2 z@ZPs{+eK^R{k9yiHW~Dv-vX%Bf!5O{&1G^xxFU}|tI<6p*ManUydz#5VjoO$%WRf_ z5`qU5ld#so-JLnEn|KkdXV~tkbc0$xfj|U)^4Gy(x}LotGE1J5KUD^8G1*Nh$*i?; zS>9t1j$FaPm>FC@D66gMYDcW+WD5LN~f{w<%jGR=8PwF74LZ#D3 z-{N&~y2)xEU#p_MDn5|Je|_wMmWVX|cye`rQnuyO8F$GbtztyWmpd#QNNa8X>fN7S zmgOThhyK1jqajfK^?cn(Mmv`|qCkMtt&>#O(Y;ge9*Dqe5vCO*37N5LQJ1U6+r_r{_5iiYH=5gFwJz|4?y7;Ok{+geP;TFxi-i{b#|V)0J9m1`{irDNcIW1T7y2kTC|e?CV~V-s|h@qS9qQej?E{ zU6}~TERG~z3)#;#?JynpWDge(zm8GDF7gxU20Y(EhS?2$({M+#Lq__geJy)m(!r|s z|Co9Ut~j?QTDPHbcXxLW9$XuDcL?qh+}+*X-5r8E1b0brC&2^3PUqX_o^k&{kAByx zRW)nYQ?-Z7KX)U3$$8yv2POZ&#_e{P0)!sq5x_oEuy+o)-)W{ME(z z{Lo=Pzcy0x?`-uhRK5)uZK{~*1s>pfF}{qynY4uJiEI9?AQ?|=GR zTe+k>*JfzxNqvxf_SW~k89hQbhV`GM&H&J7N{+?i z17pSGf@SgVPcLt}#dkcZuV#QxxiUgPJerV4Z!JqGbY$P_^>i^Q{meh<2 z@Bs!iqx|Z)Pv2vJY25L-8O3Fv?c56|Y^0~*G5z~^6;mHa@&YCvV1{&I6S7t)=1pBn zQU%>Hw1OogW?h3HQ&YjUI1bP1lJs5zU&psyv<8Sf3hAmE^=U5WpIb2Ub5B0GMXnu% zU>*y`swf5ut8tse^_xaKvzp;2l!+RJ# z%R!~b(mI^%|9APbx=XjkHdR8nPoZu#J>ZhkLN*3#&3<=M4w2FrUqcB@x|b<(6seDk zK*cpLydo*UvwyIMr0cAK03(62G|4s~Gbu};jJ!M&8r1Z^NprxbNZe}Y0a(3F zPvu^WR_#&QI~4}j2e z959lKn!3uYFd-p9Yup!X{jAj0*VnIP)0Td#bPg3oC1;(PFleC$3xfEh{${6ZA%2ay zDaD>FbJOEJxbNKS5Yzdog?3zP9?wyb z1Vsq#$2O?ONT0YPl7;6Re!ZycFs%$qgXw?_kRgi+p|O)|x*Qui022d*8(zsB{?*Ob ziz^B{1!WW$l)$bFp8dJ+mY4XbT%mXEzoDeC^5S{Q%5pfc2yNUj>#w%FQ99SlbW-^^ zW7_mzFue{YNnt?#E|o$DuOoSYsX z6On|!ptY~F4PCswT50Xa#5CRwgmZm;qxoq&Hi2Zz|4;7%m*)El8L+Q9B+jxkCMA0E z$iD=bI}-*l%QD4_(lUW`DlVEYP;u`}2=wkY2XthzRg~X3u!6V|>V8Pef7I!9z{tmv z3X`yhBpL#`9Khb&Hruu6W&lOy7zA~RRGcZHw->g^SFkzM86Yho0hRw$2o*{8EJ7FD z3CS#g(`m;Y6!;lhMtW+xqSv#@5yv_@*LbSLuJeWr{v82@feHtg zu8Y$Rof2HkvGF&jKO0Wfx2vj9&;0y2)87vTO!Tw-uWjm8o+D5)u&~JX5+zIh?yXH` z=ymt7uq4{tVZ@`@Nnev57c47CbB-37FtGMFKK8fW30-*XG<{)xKT7607bd=0pnM{* z;(F^E1jZu-2{Z6F6*upYzvC`%*|CWFsL~t*_Q1u0rB8+|^>ZMJ0YWh<`AMSzNI}@5 z5k~JiCJd^AP?^Z{;&h<|jD^6GEFrKIjq)H`d1CI^R?ROZ*c%69J@yEdfdkG9lDI+K zy6txGn`qBYe*r`AfQYt;!E6V!;VDve72APg!C5GKuf|^K2f;oUs}QMwQt2ongQJCo zsHSblK+MoOZ(c+MlgjV21(&k@hV-kF(J0yST03)i`Sta(=EoU6jlTjQum8_#bf0q2=m2fpFco?`QH=GL;2avL*=!#`y=LuT!Pq8b}?}U zZ(JxDJN)i+^bAb=dAshTM^(RjjXd{DZKsbpA_$PdshY0`LY<1kiuv&)AizrdG(#)q zNBH%(t0osZcE%Hby5}Hr(1!pfFmv}69X16&w`e58FgP5Jzg||h;#AX9)sVgoZB9o* zrbrZzZh~*k7)&1c%FO3@LAWbL`(p zO%Fe$yv*tvME%*|`qgrzu8bcStK9FA`p2jBFZ-vzNO&FwFZ*I?cT=U^>QiUFA~(!%6VnB*9e(Ih@ZI6jl7{I1ruhpz9#2qM26}6Nl}E&A%fm z$2&sUfc7NkN-n4XkBo@PY3<0rzp_P{%ZDdX35k#!NkF;ReXlACFIG4cI?IhOFlh^MZwLcA z1gM`L78X_{DFz3X5;lm?&Ptb=2po*a6Zm?Iunq(5Hn1}tg-Hvh%7pt@H#VYiL+;E= z4R(MX6i%w##pTa;Czh6$uJKr15Ef_?eqh|qu2`wG_GF@lvN9U{WI-*Mv|=@A!-lrV zpSlpsY;e3BW5A#euQPhHu*UoR67gC1r>pVDPzrmMoDkoqXCZyF zE(s~mxK0YUU-E11br0|FNX5&&*PdMEB=nBoB6iB=GHa0hy!YmdcX!D>CbL~#-C5f9 zdY1H+olIxk;dFyZGNW6N)jSmcS{hM*e!)ol>gIk!@@YF@Q2{Tz5FVpX;(Kl9BdiEK9^|mBIk_<|mwxi(1150t zd;@M?g!k_Gq~PF1E;D{#YZ>P4`G0+Zm~gHCNhU>G7-~)UT5sDCJZ069*t~o+phrTywFgA!cO0R zVQjR9uiNJRU!6!D{V7LoJ-K+8KXp=rJ#U;Sgt$pjks2lb*Ee0>h-cdG@7t-FXPHK+ z9eW~seR#u(EV=6rir?ACsnV#7XY{Rt_-bE3%;Rik&KoStBrCj(b}Y9LJ%xoPZ}v$Q zo;K(kk&Y^ClQcOX)E%5Szn7~H2qI#BH)Ec}QGX(N!^E&{w`3x~GXW>46oPix zgK+FF^kMcr@bCvaEalk11Pr7E!31|Ai8BeZp^l%Q9|WBYYp7_Mxy$Dfk!FW>Alo%~ z;G{6_f2bkLAa1Kqgc)edLC)-SQuYyQzK|21b|+un{-v=NOP5AElT@E@MaUO#|5)$^ z)E!-;>qGE{MLhrXB;JQWtTZuzAd0=UBsM~1=SLNvP7^L(r58kx6WOAt!jrCa$P<8M zvdfs|d-y?FzdOgR6DMTQi-sYH7tPn9%u}B1%wLBt@wfkqs->mCX-YPMaTo=5FyD;9+ERNWstN*@m3IS zFkEQVbuX#Ja>IuZGlx)2UXu#f27C~jG578Wk#<_G)%HTL9SV0u=%pOYXDK0Xt0VAa zR_lfURtMEJ_2*y%<*9+)`dB@5EfGHcm zB4Etj?fF!l$1Q~GGP6Ua8zL*|rE7ip?>Ox%t--Ot`~K@`X>f|{ z*jv+>0qrenzRNv3Z)Xn!O-Y!fWK)&oIHtTT{_A;3@O_;aaW67;^vxK)&m!n6J+sN@ z6M%yZ?+={9fiMvubeVXm5C>Y6 z%*`Nggy~|7aQkJ{U^g(&5sA^D_W6l-BU(&%WL6$YW^!NiVtK9=oJ zwxVWc1dfyK5+Dl~K|MrFJ4}}$!{Sd36O3eyAkGIv4GAoYhhVH^NBag%1_@(IP&mTD zJ|98q4+kUWNy&x=h1N%9s{2Wm|u>bn4ecpf={A*TE<_5TK3Xby(6C&5i$w?x<`hdMMM{klLTJ_ z4V6w6N@xX?q&Z)`nOfe^VY8TU$qUO_0+sWp=K$>2Y@asEc_VZI&YxPt5KNB(Q@wi3t9i%h^ZD z5`8e-_x8AzrDaoWb!+Zonl;Te#;xmi{z)A)Jhi%+8}=8mm7Jg$1(R|iJnjpq6(Wvn z5Z!86ih*Q>2g$sreO0L`sO)QyEd=Rhc8~~>;hYpnFSa07+T5}&V%_6N$DA_dCOb+Bf{6H%Q}Y19KOmf&0XHDHHSs4IO(xCQUTrQ zTN>=Um6#hptRm)D^;*7l%!|a^(d@*YhIy|}0`GRW-*^yx^RS+L&|U&O@bg{#IjYTA z6I0IzSzhIxT+lIxxWzVuy3q=ZA1jSo#YQ?bMH!R>9kCiaE&0d$J=NqhgWehBfpt0H z&dsnK|C6{zCr&LZQKc5%1W9=#7nUhuWLCWdhPuj(>@G7-DiaR=5xVDkO3o(*u%xBO zRd>tC7!!Nh$vh_(*#^6HJP5l^tMhaVQPPNtLK_q*LI<&e7l?O7sJG=r1?MZVHxX%X zPaSuS7&{-?h4#1O0*#xFh2{#Lvv!D3W*I@cipgT5w}6u71^0ZYw>-))iU&w{6-`!SN7K6 zTv$y6Xhl3j(Il>nD3t|K-`f9^Egl)_Yt4loVW(P>ztFSRmfpNnT$(RkELgxDSUCZc zutbsiPniH{bib7?$)0!7gzkri#3qJ7+2moqGDB#v+EuM#XsWQ$B5U+^)f1R+G#dz~ zB7um^7m#pp1xO1YL9!0cO<8W;}X?#|FWmvVLDF9f3Kzn{#UGni13wBj;B$y2L z6_Q;kvVu&q00NNZ$UNLBRR45=nO3gx9QbXY33xEv-$N}_SWo>@GQ%HlKA z*-`FkVZUp2YcxU_BQbpw2eisI|GmUn%Ku*C@plm~95J^qtr+rSNCLh?ku38?yAk08 zrYnQNP-S>aUF+wu8WC(c#wsIVU7uJ<7Z&|8Hji5b_|++f+e~=q zNhGm7P0rhopZzG0x&1tIUG!8{-5AoC8O8#XVDxzaKvh@W_O*do0CHh?GQev*sx)|K z!7h7+kuTL7JamP$JX*lc?4k3P_sPlv`j0^6jLlQz0?9W(9 zluypnRQo_ftiJlBKPD|-?x2RJO zSC^Inm$Uco)EE_Kh)C#hzPDf4ea6ulU!N^1FNh98c>%z52SjfQ@)TtY$c8;+XfE1- z+ALc6k>a>KG?(;3S2P#lU>cLZKxyP?>CJ2X#rfqd+e4RxO3mQ`kvTP;PSfI22s%PX zQ=;L}i#BdfIVkFO=XPoQNP2tIIkYZ8U8=l2ws^Tvvz~I{Gdv@* z8C&|t7E7cx43Y}TCKL<}*Ny1iWKv0q(?kfZtC<_yVKC~W5e3zkpnc@*(-wW;( z%hauZM77{(HSCWXd>NDes1F zECL{6g0M%QL?pmedUT@Knw{T~U>ut-4z!S_1_1W2TJ~EPJ}*D7lm1T`H9v;0-`p;v zh!HYEB=?EUTd~n8x6hVxe$3RG*4@AdxPi3EQqatR_99X#l4<4`7lHhGpd>3G$o--g zQd(G|zHa(AH-kPOEt2R6Jt8~ki;n@Yb#t*5{ zzvASA=woGBK$F}@%?6-Smbbdq7Iie;iF$e`^k`YAgo&#rbyJXWdNLGqNH&rGFWZ~1 zryC9XeFho0DO3fTj)7MUGvZ8n8{FXq3R_t~8YqY!Ms|vn5hRv#g@j~z46YHtwpg&rs3XhX)R=7LIFai+L*>p%7G9F%45RPRX%Re4eAGB@o3MzXy3Fe zTeIr2R?Kz0rFQ}w+r4O0?~J?fGIPIuo`H_mQTuKnuPCqE&hs7gf+Q%i3m5s_Og^X> zG~>$jhutmlQ>gah^h7weNJvbx>~fZ@fq|DHg3qT~f)0`Y)F=y*KDNoXgg7b+ zG_pv3Y!jNxDQgO4{~){Tte`&p1!xl131nGd1JW7f8!V8go0UaKR4~=nBwEQHeUS{Z zsu)K~Otx*0^E<3Vu_NSmqisN9=R6{Kj$D#uv);}Yy!!-s941GNa z7_b;jg%zVhg;fYInOsOD)u2E18@~&h(uAJc4J1NNLyWSJW?bc2nyTuh?}rQNRTkUK z@@aFot7_zRP`Ka2>&@_H?IG^tQ-R=6iB}~(Pd^#?XIB0NybRS`Gxhk9x>Y&5Y{~s0 zuCaTmAM)fjpAlg*qafKg_qEY-(#L_!ZTv`O zFx>su_5*-HGxEr( zl0{u?lPCESK!!1*b!qSedkzxV&SHoUybsded94opr$usz>b$9&SWw?Jaf&#O45TQ% zfGNPh%;J?JPm?WcmGHig_m_6-5jBsbLM)m9J4N&^9V(L=W{(n_q!k~;{f?^?tl`&l z=BDVbPyagV)I|G|ngmZk^a1^uVfnEs*sF8>;6A*~8__po^ z!7*TjvlHt_hxZqtA^W3D4%eUA*Lg8-y&NJRCa-WhgqMk_7F_jctL;xjNZ(8S&8H*g z3OU2r=OH1#9-hWg7;cZ+X~`sF*Nn`U&5xx;LibqR@aM1-WA;sM{v9rv{;oT ztg6Ia2gqAlC49uy#h&^v8%Jv_Tx?f&yXWK;t&n@S_sLoNHE2rdbq0e)1DY)ZI{!#o zdS;(Qu&Clu2}Q|OY*5Q}Alor!WLn5AD;_}K1B2p-rzx282-yZz(;R>7qjy{)I^wL@ zEd+-F|32{G;#}PGbDuJpe{tO`4_R>h$o08z{n~+ZdCNS>%-eho`-kl#9W@rdw>2iE zW01e2dL&YzB{P#PJ`vR{Vx33sjr!PFDkwtGVuvaBgl$l!Rn+$*KM%4#U&WMxHE_QFyR;F=N`keLrm4Q^OcBJmqdo8(%H?a8Sc$QW(Fn9g;H5eDJ9XdOO|_q zeLO@%1OvkRVKv2CY_1{X3_)_SZ4Od41T6yl#1<}ei#`=Xp%Q&UVShm@jZS+s_r#I? zc7}d%`er{E3J#=9w^i3T8^B7e%t#(?K`Zhu3F_#95SEc=p-WSZmwgG%AY!hZ+8M6voVmuWvGgDo}t$`-K&jY*al zRr1;f5a%V2veBPE4GEqJX`~iE$|4|%MIt_xUu@D(RMl4}EB%-HvPC#+=#~vJX?vHt z!1wjWfB#5M>!%n{-VJ}MC`1wCw?Iq_X6nSkDh|XN?F)(Ey&)rb4lkiN1~-2-zs<3K zb_#ksyOBpVRgUL?>cr;XR^2X=lXv>j=Gfroxi$K;<@qqFj)dfQ<=UwKw8^WE(v+FLN1%k^F2G99U~u&H|xcu2+15dKb)$5I32qsRIe!?2L2)B8#X~3NR$v9 zP6rc(*t;TA%@Cv*&KF@HE)EDbK_~ctL{1CpE=dpiNP)xvUp%H0{!e}5j06_K&K?V_YUZx?3aC>5H@8&5mtp!#Tc+VF(C&xxs zl<;|_U5XH24+XpQ%SKr~Bfa$w*ZD+MN-6Z;zRsO>_>ct8iHilN49qbDBrRy&Y3g?*wHP`-B3J#V~} z_m;N&+>J3Wb=*&z{IQMA(ah7v&-O=cHvP_Si|Xq1?7)%9QOLr4y;F_@?ApgT#pXUX zeF3#+AfQvHN7;2r7*35EhqmV0aEfkbjot_C>E!ggJH7=x&H{z1i!X-HzWtF3oEP<$ zhLB{UD>T+(3S57&lpGDd(r9|=ql4tO3&a+npdd5w{!NwmBSoQkDYof5G&j4n&HrbzwENeLlvs< z-lA`rrM>S3evkOPQHe4lEQET_*jaOebB3tA*?U0;(@Q~c5M*#B1lESOi@8{25+aB@ zqhAV;^H?NGKL&~TeL$|hDtYH6I$EYjn3vTjti=k=O##(U@3?QR-*CxRhH0ZAizD|z?&e&9hCL&%Xm{^ zfIket`y^=XLr%kCkVvDOF?8~49K?MFhUccvAhmd~N zvzQFjgM5#@9=!+lsIDtKIh6;V)he0?m_q`_GJ(>lXTj{pRzD;pjZu2?DQF|T$Y+dm zN;!QvUq*gH^)DojWKJmJ7iV3!PfGSXTGQ2i8D(-PeM;%Zamu_v;-q4O4aat^jzYQl=3b8VL;ut1m32%)cuLN>3wV0Z! z0h_z%)MX8WWrO-}*61z^iQ(DTDb)Aqt{=a6?{PeV9^((cGhGyg^PCsLhowg7XQa5n zrrfBx%EGcA=RZ7oventbi%XX~EFve^*NLIz0v+jbDz2YQ&gaP4nKXM#LWi?}sA|3( zrE*Umkz9dNp14JJfWX_u1?eaxVN|`~bkKb}tq31cu;Dp+wivy6_2J^kMZzL+<(@*^OhT1!AyHAx*1I@FO7 zla+=?HGUiZiw^}<3>Ok&hZlwO0@t+uGhFlvDOD)=bLi@M-qF(kHZ<^^u9*C-FqE(Q zc)K`II#F%mQ%p-fqxRGJBx|h>Va=*>bwd@KC)-fl$ye&qZU7>Sxotz{fRn+hs^2$; zcl(Pf`jzf=E*rkT`o1Qpu-kJl47ahX5lerHD@&xrIM;D;cr$ZGgU^(b>TUDBj!&CY z56XTZ;s3@N=l%nlw41i>Q*^_x%SRbix1gufoPB+s)@%EH+`Z7%s!uEK!L?1zG`uwW zwbSr$e4lc2sz)BY5`=H(qds=7caKoV<9mh3Oa6>q+=vYaRDkKDp<@Q%ds?Ga z?W~KcvIS!H3DG*pnCUkXImi_v^P;E2FP zY#!bt_ww{dxf8U^(m)29elD`9Vct38GF%kH-VdaIK|kBivW~E^_|W z{N8y6_jtcDFQZ*P#UgBqbpJ1~IB(H2>Vr4lT(uwjwvqpL8o2_Nwvk`;UYmZso~D&s z*1o?}TuV9Gm39}08<*^DOo}d`hZ@tOSU>a~IQ#Ke#$&@uhJ}@9J^q!-A5pN3Y;%S& z>!DNc(S(v12{Q-t2ttB`7Gx*21gAly3I-2R8$4WxSFxwObX+ZZR$tblH43N{=11ca zQ9vr~zt%m@qb~jOQyo8vCa@{NB@VLcB2zR2Ip8=THs=clwdIe5~{>50T-NPABwY02f3Dkelk365G0r;*cbRUQPX#{bBr}EkY6$#O# z$@c6#&%)MY{PAjn=i9Nd|KCw@3YW!|vK47zeS!^^Ph^4;?zMEq_R$V$5V~lefk?-) z9>iu>0(bsI(|L#Uot-v6jY97bqLtRbh<7XAK0O0BUapp&awa8rV?XU4Arn5}w7)-M zn6yZzB02?eD)RSJ)t=w1HGx1Mwwhb03FAHLY!kGxNOVf=wQ-k#DlsL+b&Dt~Uj1mG z^it8{{!72Sw^#A0*4dKq*iY`}%kLIvq>3IUN;-Z9KCL2Z(p-Eyi%uqM!$ImysnB7k zskYw}G+lLZ`;JR}tnZRp>UX)Z7g(sR4=34_Sq>Gdr!3XyRTFeiW4cb5-= zCGLLul!I?%&F;97%DpycQim<|3=f*NhAjuz{AmAe1*Cu;RTcm3x-Q+N(~u ziFT1UR4b+Zvg7N5o-tjOKx6&;#LvAvn26HD&@XPIjTm27&5tKkNu6=2K@sNzF!a*G zK_P_eVu488!p>z-=8gODVYR>9`sXbtC%H01hRL81--hnZjHBQtWve}pPf{h-VtyG` z)>WfMqA+JQQQ29&bPGR@8h(!a7L&%vx&H>d z0#-4%c|lrsKi!5IAHt4yz8KM4(%)hr~pzltXs zVExHBE7zHZ592dD78O7=q#>kRP?$_`Rrr1xfhRb+c&l=!Wr~$bUr*}{7i&>F-Ke04 zlb2qgcs#32_#3{@-!%P2NiAC;Izl}3Eo~LD6(q4_JL=9J%xbtq(QDLTtjVTSMVqxe z_z*OvF@=`nF8{M1_l}MX-ImTN)hd3qt+)LFQVImxFE2ml>#dW_3~FU6w_WI*B@%Ws zBLfr3kf`x4UqjB@;^J!iP>@#YIN;!r_iPMg*aq)|4~konZR*3RM&Z2u-L+g}AV{+^ z@^oX&w^=P+e5Ih7;fvg7YfFE1+=l)a(nmtpHv@%ldXU6F|9!dX!lg;R=AJYt$W(t^ ztKS!HKMv?63oRDJGl6J}IX&H3=k~0WwKmnd7I3P#QSyeaGZFC9l0tew?gbyLLq8#&fHqyd1f(ZvXNM?^v{XZkd9KkLlrs2cXa>~Wdj zQd9Mhp4X6NzGVX}5ew|Q9gavVe$#30Cj+o?a-<>gTTG<0+`9B5*B;iIZ8{AGZUO*a zA{L!6pyw`afOc8T6=G1-L`PQkvYO%=>a z0$j{D?)rBrbwAejGGV2{Jd!Pd1PW=b8--~`?+i(iQB_TSweeb z%uiQCd}_llu6{ZVohYMRLjz4loyo?&mPL(t`;-(5W5ElLydTm`Es%2X=Bb0EVdFvi zvXQYER#I+n z6@$j;PxmiR_{47x_~eck{{H7v*);ByNgvjc5ekAqszc@%iyBFVOe}K%+>{ZP$$NZk zu0@d{3B5tqXe*rYXe8z|7W53&s0g`%{|?B$s5ADpWe`kX;y(;$Bk%>X2MxV0$bNF& zF*c7Z(n}

=j0jzWc+(0vaUlOwEEull=t=4o<|vUJ)@^$!O~NjogZ^Ci-k)RRa0e zyr~j<1-gl6-jGeTDkfpo&Im`?CR?#dMQ=en9XFqot$K}bMK7Rg=RtaAH>#+LZ_hxf zVY8Krje^B{KD4pjuT^6-`_}hW_Q+&@$rj8wZ*=)pU4CtB-^Ea?rMc5?h9Bb~=sa!- zapcbaHFmiO`Rr+Ym%^fxZSZc`M$b6n+%rnEY)m}@6Q1yX_cH++Rwylf)3KPE=?0~; zfg$D!y?D)n`l}yVyB3Of(xx~x#NOZ@ew0F-t}9}T!B9205=epx1a>e8h6UN0QR;UQ z2gym)sqe$UUkfT-`hbc~!*MKZhAtpr9VsJ*x0VPa(>x=gdMWLIFZaFoi*lF75_Zzt zsLr_SU9KnKZH&<+07;~FRr46i5br~wQO!l|Dh^!3AaaNfWkf-=TcWLKXh=R5NjHMv zaVaxvl1NcKtraIzC9}ze%%z6%x@_X<#uj63c-h|=y1uo)2EsS<0wf;3v3b3UAKBuh zGZ&DirXueuXB>aRA~FApJ@^AW)g_3e?6y@*4vJB zOJ6?bk8-8#=;{NKelj)5IIroFYlOM@om@BSs3)oNHT}o3GaaPPrbN z)?|dbv91f%B5Rt!WZEC>mO{CqlIKfOx}WS;W($_XSAU4SP@ zFCHmtTEy0xPn;o9c^i`zl~HkNv;+-D?R0*Hf(omgR)nf>FJ|g1gFpFc?b4x*gA)X~ z)ovd{UJKsjDMUF*)7F0ds8muKf%P4``8R@4=fPf<9K`5)NVt`~<$4%E|Z>(Otd{MZbIw52> zwD~B-IfpUTJ)C3n=Up;2J~Ie6M}iPbf_QZCi07y8-L~tTMS|S`tbmamwzp&P>X-w-z*$in47zE^_s(wO;{`;bgDomEE)r(n-aZ8x+NZ_ZM|Y8R3Jt2BjuXY|Jc*x=-P(D68l2 zra)X{@aZVv{hr^zal#DII+bWn@J`;{y^TF9;|Q&jGgjL&u3~-lFLA$>$2nmJuJDW6 zcHQ?F$=s*S81A61;!4%vNmb`oUsk{3*KM*65VzLw6V=&U z?4P9Kmfz_9yz-KO=@9?3xy`2&B&4O`HCkF!fn_1vVzsKuIcsp&q`@!?FHT=4GoVJb z8$yHq_+VT<{WocOc^UlD5i}^RrZpC?R8MTkn;(qDb%r$`2CW;-bE?oJR;~ooz{8z6 z?~lN1qGELa+Y-PfyTs4wo=Isb;$)hWeI-}J?T_o}rl;&8K*Sr_A$sXL6o8&CuBIa~ zp`u{#<@Vv6R1vqTqk5S&esDKU4C}ECUZvpa-ut*|o2lEYvU%A;z}}of(~mF=8eE1R z+_;(it)C|RaL_tO^CF$M2u9i3R+mVk+b0KOme@WXLlDe+aILPUy?2E$M*_uy8M#TR zIA96O(HLMrkDkL>CC}9?`qD@3!=CPFp;)oS{HkQ6ThX$TLK_U97%`l*A^+W;&uctY zXr;Eh13^%i-j_||5}~nR$g}v^`RYGjVC9C6ijInkD2*9Lzi}#<{)gTC`s({gLfKlm zjv-%&OqaoGG+V4gm&N0_*&4MEAee`jqYA`g)T}MU(xRU|*MXD4gv#T}DMBT!-zu6a z#AxSv0gt7wOXkUq!SY6_f>#XxNDzLXSjlnh+rYU1D4;dR4jf8YR$x*70dpY;4$Ue4l@8w!p( zzDz4InUNRNG+F8c%M7f?xHYAvPq{;|u!gj~=z?pbvQm1zZvSrq6CJ|;-jFj66Q=X5sIbOdC z-`xsV(8*Q&YSe1A(DS>GkvG-f2)tGVZ1uBYVP&ll(>yPxME8KG z9=ahx1FAx>BpS!4ijmAp6jlro(t!e$zr-*xR;L zAT4LcVh%(-uUIRrxtkLvEkXS}Y7XQAyC{&o!K2(SE9Bi^$kG`Pd6^0syK##%!8}xE zaJo!)X+fsISC(^3S1szP;9y7*LJ%>11eCGE`EnJ2{!Jdyx)r286gOYN{v{CZ6pR-U zq=39q;q$1OOrc#ud{cE$90t&B!X<$rlFzH`%vgBJAp#$8PJs=nC=TT z5;3}0of^zc2l0!z1nG}TSK8mNgW#I$Pnvi@+Egx|D_+9J*T8`jPehk~@`2pBD^jq1 zK@(_)j5O!F|7;jSn0aCeJ#93QN`ZiB zrGlVAVxJRwVe`F`C3_c3S)*Y*0(eXuRz6Y zOajG&B9(4Ec7Q#!4=oHO6H)#>QCO%e*7wxCFzJ%lx3-8J zb{JxHlJPp`^=04-ypTkFgY&0iQ+zs*Beh2ATJ10LyVYRtHO-)V7^tP#q049i4B5vR&{1^i0 z#H7;g(C-g~zlt+ez8JHR~w@cNmQjXET@)s7~@lLRwiXpJ$J=)$ME%yaIrz zzLOr{5H4^2JV{%y3km(vt0|~5VBt4!1|C(|=|%?W@Ve*7hVekRiav=tph1f2@9&`h z#wFU!55&+Rx9pQCv>cfjw)1iB*;I|}Qfo#K?M~ZPa3-}@-P=q4F%~3Vne#l9FP*5ovVIk-46(j3N+Whzt|c$8f)8 zUKOUsXtPe9-S`vLO57WM$XGN1s1E{scp z8pj_W1z%3NBU0pd>_+TQUpfqa>hJ62!e4sJ(N!9EXUNKsB5Do-iNWK@7yAG6%!7ASP<;zZ>G)*^my7{y=E z{hHyeTtUJ}kYi0oYA3Tyi!Wl2M4ED0Tj!+qr(mEX&9p=vB0^D_kuj5P%?@P>eux9JH+|KogO%lUDi_3I<}B~Y&)LSFlM|v2?bC&G=yD@BZNvG zHAr8G@%=tA4GNs_%+y=nsNFu)KX}J2R?R1DH*Qg6QmT=)&WaAxCzR1!fxl6%KmOmOg z7#t0tt-`WFa6`c$JMv(;7!WZHR+-r_k@?b)v|!l$U{BuFP}#Qk3fQ(c$1jX|1AsY*hfP?LGuJ+|-qDGOjH z&@4>b{y4)s=Jkj3aJf@5-N0g*k6Vs}pMa#pne!yOhM(b zX6T~?U#}*XhiX{dX7Tj96cDQ9x@He9%}+v;$k`mQDO z+4P=YuD$%vOC>Y|x==rfPQ`p@7}#T~Sp&c8DwpGIweC(ngMR*KM5+Q1$Z#xQwg!4u zF-RAd^Y1fi3BOs2F@%*|p^b)u_nr1e3VBAk!VW4cK3Kb8!uZAT!if$qSFK7e*-4Z! zBld9zt*ljKNu!NKi$tMBqmS7^!a=N!_=9n!&&XTID7O=}zaX&C6bX3(dKp8JOsHeu z`!s^5K@K3+s7%*!je>8qnsSX-3gC|;hMe((P@xg=AX-tNVt&jBW)IIGC|hv@LipB3 zeC&xGt~ND~F_?$j2x>ORK_D$-5o84%i#lIZHIWI`0oOTY{eo%`Hj`f*+U20fp^|@) z5a>-h68pXb&R0`EIklrMGVR$bVbUmMqb~^PQfkG)?ENtW^r{Scbs4HWgB5F3J^Vj! z12jH-fM&{f0l{_z{v;@gEmFx-CJk_ylEP_kuo+W zV^U-*uomJ0JTBPbT6`o(vgX|zaAw11(t~jyQiT>G!qDW#*i#Q-k5I!$K_Nz(^4i}v z2z%-lNF1;E=3*a|nMvq5e+wDMuqCk=1POlNrbxC`D4OX! zB@A_Wp#5AFYD(SwTb@~6tkePcGPm!1N$_oWWi5qWS?%++DLIObRHk{8LJ$ug$qx4BL;gJdH1y>uidY}fBn^R3xPIB zHv}k0Vupr01aPn5ij-JT zOxU2+8_2{?@$CI-WFhQWHil4>D8c0xZgPxpVbZUojR?yk?IzS^3Wp>KgXakEgznh& zK!u4^>|}x~LB98EU5?@DxPH>`YZX^V4pXA_F_G&j>ZxQqQ5rO-PvBml6Y*AuIANy{ zY$XO_3q;$aw6B08NkSrR>6V%}UX{+C;|xbl1eOxOcLvQtz<07->}8BY#`GV_Jq6W?qKJ(Q~`SKm?_Nokfd?yfK#z3#22Ui(=X7`PiRo7%s_? zVB&T+(lGkTIt^!)IyF0y5$A`P83{G3vm;rDU(o7y_om@q zd-sv1WD0&$nd^0Vp51AeLZX*-qBQ-WmgU5GVabs-qoIrJ?9Y-8jKq)?TQ$7i8w>=a zbaC~CW#vy!!}7za!A_)el?J<#okgn)BWPeOa_C2ZBFCUBAPyFRB^jt4!iqPJbVqz# z8z!Ov^L2`Pz>96CanKvci%V@71Dl1aHX=!D+&+S7<4kjd#obNPz$+diJC6>!cg#Ip z-bbdc+G!+BG-3f4P`uVy?nUqA zFvp=@Jjlc@%>IpqC^6_jJs5Af6=ShGz! zpbFP}6evvA}kFD(`DASFAudy|2LBwr7tibKtPR}f zNr9rs7#Lng)wTdtEhP|dB((M0s&rdM(6iX9+@VrTpUK`Wjd@a@ngSKSkA`M_YHO&r zgco<&n^SO1yInErdwC6;te!26KNa72H8g|(tGmmS@49yYEc4=2atXI1w@6SEN&cWi!^20Rl;$Ul zzXKj1X&RbELW%P=*d!_~H{PHhy9#Aa9>dFn^-cn0YY@t+%58nHZ+Rfx!m{GQ&_uA_ z#>iyhv_n?nBu4ELgQQ`ZTy+}D1PtO8yf8}rcmNh`7)~{&hB2#J&(M1oWEcjJL!(>L z6Um?P_(8N@B|TiyH)j|5g+W7}4r41#_v8F20u=EJV%Fqqc_4d31HWUP{Z`0I8j;Wa zfID&Rc!0l2M2JLHTfj`MsttVgB96&2L5=78fcrbsrkblkLMUQHwT1q&ldSz0C~R0q z47EzJ5b(SX?xP)MoQ(IKXyVcpQ|zwT6EshNT5Fx zq#O~94^N05%1VqZF*=#N^j9r*q6|;Iup)UdpK@x@7ic&5%s$jha;NG*k!`eDC__hQ z@dc0RConMYAdxn$>^1^elBj!*VJr zW@3@$qv0e`2jU%qlWRMc1lWto#K0r=hx|KxQ2ORrDftE2xXGDZKX&N+?BCkrkYM4y z#v^^3ZC_HVmop#UfdxCJ!ve|M3VEmIVy%<-dDs+!a~R5 z{s?Xr0sYLcp|{Eoq%18gGjM5cj{n#bSJz)4>)F&pz!6;n#Uf?ro^4*p8xx9BWw z>O?33NMg!X7P|ei=^g2AqT;q1qjh;|WqG;K{Xq~r?{M5z?)#!g#J5(5?27BeK6>af z!Jqq%GOi8wifi=QzKm+&^-0mH!l6+*Go&O%HyeRXVj$H9lKR?QQx8uriCrtfPn1Ar zy93eFBpuccn9hF51;&8N3^>H4E5=6d+yMgwxag-sZ>RAj2}sg~mTvKLR@n{Vi(DL+ zW7WPUwvUA->Q3tV3M$8b#q8OUqb`zop9F;>)Hky~EiH>gN=P&*i7TO}e+5%2l!nF; z(3!PSbdoJDDIOy1&>z*sQTBGnwRU&#^_jpnU#{bu(rS-1SEt9ejfXR6kgOQ~U-xtLt| z7f|Oo+55ST|22$^LC(3tdt~6EJ-}d|OFN*2A7Wy3&b_fK$9XQRxOgoB$9JgR>5r7f zBtUAMWU?F-qq~Rf3tD>boA?6^MX&c)RSP=X6^oLt5lMaDCTza&VDB%7`Dl7PiX)u= z4S*MO4-jx2s)*ezz6|j67BP*zHS4^n&6(wbeS&1g={n+*)wRf8cwMTlYQYR!n;;`R z?CUL(uHJ*IA^B7Ei({j zcikVWR8euFEM%5zlP2$|+&@+(+@5vF<&!M|0YgU_2*kI3+zFE1;#H`HboA;i;p%U< zfY+VGLDwDI!78fbsBCCblIfs`fN0;?bu>QkG-=Yn8im1LKAEn{uqrAc#lycb<`Msj z>A^M&x1K~Kiq`77^=}YtT1YfSpy3{~RN=tOcv@Q@u!2EsQ&qkETf>&mo;b}8gqvox zJ@g2XLG~*-E{9CK6s+;XCmUE14hWKhNZj)$dTt~vdwHt>MyrapXL#x8 z;Bu#)%+W9&(tcm|2o*U)$^J^Ii89>}F);pq-{;vI=Sb!P!Z609rt{>C5~cur#@3UG znCIlCFrv>A1|w39QJ`?~*>GF&a`4cj#oMZi(d-kA;PA~|bdz`cf@-PQB5dLKt*5`k z70!fq{zLvOZCQZ*H2r54=bZd=4GruJMlf$ttCaIV{oCoMTXqsz&uaCl(&0*N$!${* z>io}#mr!gQZejc(XR0yGxt^%4K3ZrlIZ;QM0fBiV2uy1OmaYu9j?)&>rqkwc3z3!w zRdBiuM53)fV#A&-Ao47sd$9J!zv&o)jkt*H5%_tBaNr^3Fv8?b7FF>STH@l0U6d}w7bL+U{5_`I8$!T@5QojX=;y0ie>q;vsQZsmO z_4w)2(3e(WeFtIA0{PRat|qyRwh?Xz4cCGx5!dk&2YGgJeFa6J zrLyXY(Cjn_RX%Xj!`oS0l;|o+`nz?r*V1!wDz&4)4cng@guqt$Z~#uXy9ySas-Trr ziw{)_5cIs;PgZRGwoo?Qp@IVpvp|rVy^kL~HQdsWB9JghQ^0e%)pTh0htamH&xZf3 z&vasEt0{d0`LZgJ?8oNtr)GV5Y^QjiR$};~;aUMRr(=Kip7l4A8nWQu_q#tP{)#O- z^t1!>d-JT8(>Z`7Snez#1MhWeo|m>zZ0gJi4_4<;PDE|MGeo2GRj!X56~_U80w2) zdr3AX4=*!sv0rf5&n|5B(~Kc)>eG#+vF2g zMMcCfQ}$g^Nw=tU&PpzAFJtw=G}EOcS49@DC6XWQRmKnfGHi-hDA}vsXEmTf&Zi9! zdV~J}n?>rIt0Hi4P2G1AYm%LktHY|J;oE#|V=h4u0 z-gRH|6IQ@X>^}eDOgY3mMHQH50jr(-KBoWY3^YO?%=__un%H(~U0usglQwKs61JV7 zws?o6Dm$+B4FPvYPRZeXwXwLNA+g~SsOULwEPTs61b?F*$N&O1;e|NBsO zcBcF0sR~k_#&ZorjpCT@_p|Bw(H_C)$E%JP%!djsL4)MKeMKL4nz(7ram8L37G2*d&&{AsC)VD?~}yLzgQ~^W&g_C`109J6lLiaC1kMKS1HmpQOSy#K#ugVnVqaVmcb(VLn`JYqp#6p!-r}7gz*qmO8ZV7)-lisNR)jn zCDd1Jp5?B6oQPHFpZ^-qF?}2fh3RYu@DLP);!Dv(5bS<7`T{gzW*ia^z?KbG$aB{C51OBRK?2c_ge>Er;-=B z#Z3%%1$r%-U`+WXzb6B;Pk>ZCBRRb{|8M&xEg`!6kdrB<>j064g|{>`;75m6 zu^j+woR}-I(HUCFj>-H{xcb%l5$eE3vKY#+x-IQMG8E#EzXg6umwRIgif(EkK{7{m zTq{Ws(7`9@e<-Ohr0eWd|4Q6J|Ofz zIVx_;PYp}j+hMh9BdY$~u7Af5FAIZ!d_PJ;>b+!mv(x8El{!QuAgNR+W~ghSxN;VjjY_@HngM@$A1bbGk~XXPFgktsF z^^Ac%tTSDY1cvRrb@O7;oOn2d9v{WUVDGJAPmQUh-Z8l$;Xb(AOJIuq$@leJ>+7A! z1Oy~0^R-yI&Ju|s2?$UnYrJMHVAV?p& zxL8<^tELGX&XbJq0L0Jj_5S1^SU8XAl|!bpl)|W28nUL)ZoWeOOR7BGMyF~Q(&HD3 zu?!k#Wlc``MM&)<Ejal0|GPkG zUdx{Tx+gmv5x>{$x-kW}$ktkJA1ofc^%zwMg-;RXJ6CsmRszvk60Vb0o}qkqW+q0Y z^6!eTao*>Aa=`y-q~Bjl&VJp;t)c?Gjl$Tf2*+Hhd{<{eCZJrXE3Txr)OY4rp%qr^ zI^5$tKLzE#uHN}Y^9a%2vHk1c6Ky0t=K?oKM;v~h921Ky0B$i|VmojPAeK<*&zmNh z?lKjVoB{-dq+;RRp+7~t3YEE*G}3ZsIkAXsG@Or{%Q=bG+eT#q#aQ~{TW)S{&M8LA zelav+J-`ISt@f354>Umw3dDCjX7yLvDwk|UH^Iml;$6xy>$te8y`h{4;&J@48@9>o z8n1U1L$vBXTLeDLb73F-b#a0Hu#Vq<=I9N~jqydEC~cFVd=;b4xxA()1PiPofUAnLLP9O-Q{1XhP*Q|qZ{|95v(2>r0H--2oWa*#D3R^nCz zH`c#0pkp$bcBq(i$ycx*k_<}A%gakiNy)>LnYz?jza*3{Zek|6FouvqnAH@_|5%Dy zieMEQC)u&j*+{{aN+3`LPC!LDBM=uG8ygcdXhXD0dP&>ub+z*lDAs*?yAfC*{5M<5 zqG$v}YkQgz&--buj)Lhp)6+fy0>_9Due{!16Lu5{BV)G_M6BR%AEO>de>ff5y9rb3 ze0%UMHJjpzknAZ42eba#O&25Yhh_O`Oc!h#KgT~*183;>hyN4Aqe|7 zedb>r8wF+EeP#KJoK46g34i%`GZfl%ei4}Oh_+w!ERh@&6MCX8#c6n^XhqNcoOKd6 zf!&n;tq&MV8(PT8s9EW_9tUU=vr3z4Qr&^U+8txt8u&ZQxCIqgxTcb!ttGNCQiI?I z2fESpo-^crE{R~(Hh@X=rcl?1q>q;j1XeMFQra;DNhee3`@bB7p5X}ka>th@2)@#= z>zlGsN>RbGc)Db;S2{)&4(wa8ve1{=NuJ((@aa#k?j|YxZUnU=2>4xXjlNMr!LI>x z8o5R^4%>TK&Hl?+WeD3xop9p4GgZxib9N|Nc14iWzT$%7Gqsq~fE~0hj(%&=Sf>}< zCHC%OqJITSc7Lu|3Yn0Xb zP{meN=asN!T(1OBK~!NH7LrTFUt?CuoVsjWKSB+ONBx?dTn8$>$BXr?S){iW^;#&y zxsd^}EOh9BaQ(-6+38%2X`F+#m02G5l=*2HqxnC%?U!r*ij4e&SR>|^9nFRBmAD;< zLwR3K=1T?!)E&d0T^JX{#kWDMVVXq_DEf#A;I{ipc6Gj~X76%Xr+-C9LvtjTpdJdc zGw%s7Lg7#on|zS zY#?_2V2O0t`}X#BMg~qpfm44nk41AsZZa9R-o{ZFJVrUek0cvP?EULe2h=&QpU6^o z*_T38e0O`58|JA6ac~It#1KVksptu{dhuMnKI1@+wFOxJ->ScjgML!e5b0>mdwM`G{q z13E|n;lom*WGc)dutOt1^3l9tZ$EbaEhYt?b~6R<7xf1&BA6Tw8WX=N5 zYF6kY9o6k0&C-&zEg!h7Wa`bXEfH0F6^G5{b))Ord^-Pq2Vgon0HHALtu2|(t8jZb zfokdGXsZo2(6K>+WIgZO^q-Jroc<Pk~;Jk)5wo?~L< zl@!E5r=9bvlr`zNAAH_=RV!SN64A?gq@of2c1CsozLBPBmnr#|?j}Oe(6AuCxI~{G zl;{0Mf3h&H1!$Z*zGT~~I3|r0b$90=!c*WC5&8lfYWvOU`P!J)NNVz8*`c14#FJV8I08?Mrsn!j7y|hMKyc2ME9L_* zo29VSw%e+jQ|(`bGHaA*iDF*oXd~yf;;fac^sKGAWS158(JYqnOetS2 zEDrTOHYCIo-OiSyI*EF2Ps?6kUq7HjRFVeX?vbt*=s#~|XB|_f->zrdWf$@Tcwf)i zAxjfJCV+(w96XqTmDO~#`vi(5PuG8p3TjB-_U_bW)k1ZFYICp~fks(9#rMvu-*;}+ znmwEL$<{%&r;3xY!GiTQJvC+DA$*wM8{2YbgVXP2EkaTuZQxfuvgFHOnhz+#P}^9@ zIro#@AZ;zTxcFXo1*OlB)DP2^u9#ICm(Fu7%^U4Xj57R+p_=0?kZGN&-X};wGgC&L z8>~AN0HjCSmf2)(+s7%oB7YElMZwmxZ{oeUr3boUO z+uTvC%u8=qk!0Ld*WKT1`GnqGm1OguysBkw!9ORicdo6+J*n=vljHHZ;{CRPwvh|K zyWhn{e7=^`5Xhmg_SUF|cyv#sow+A|;`h~v7zYnIs-J1m@f}}IQh%erel#M6YSkF4 zuaKf(qOG>=#y5KGJ5`+<5Hi2rCPB@P^q5? z9y(erA@&=e!$H)8RMqr_zjNvpP>;y%~+0Q(omLOtNN+=fHIT{^jCmdrL> zGC*pKl*~bP2OKx8Su#`=-aoMi?HFeWc}W08{Zc!9VV-oxBmMN z9j3L#Q}|e-aJ7FiIq&F$9s0xnUl_uy={#C~jY2X`u$iJcrt4wEew%!?<5&68smt#} zi(rc%$5bLF<5^!r(`xuN0lxp6*l9|VV+e$q3Q<>(O9&M(!;AdP@JUAWKAWPYvx47( zthDfJ7O@}!4XEp-a^IDpE)#NkZj!bW$|PQ^6dMV!$jkUNo1WQ%l6+Z|0nOZmgLM3= zm@Ws4G6G;ik#9YpcOq`6hb6oL4dn@5v-MIt^%?8T|32zaqjGhcD23HH$PR7blAHbx z7@oPxM2$(qjgAF}lh=0}|LTucpxxQBWm>k%W-0i(BL!sM0XOs~f}Dky?k+BU@a<91 zeNtcE+HEm&aM0(8A(MSePu(Dk8Q2mKq$hr4PjWxN3CdPCM3c7K5-^a1eri4guf5)N z$v}9g^tMbD)*y{cppX~;pQN_<%S+}>A+A$lg0I{4!&ZEYJc23nSoddhw;P}^*bRpkscXo(g_9!65b0H;keC2TC&gfRZ4J~|Dl=iB#BtxzH%s+F# z)#GYYJPXvl;bT39x#t-eE+ONv^ih;1eIo${g2n~HIboUG?rRN_J@FuMx!E$8wX)7O zv3E%clJ2-{y8FAmENc(mo)0zb>j`$V>KCg4G1sk%m{*DzIa*SM7eturd;#gGUZ(NE z2UsBX>ns;_;{MSQ2sxgpbkBOoSNaJEw6xUt+%L}8p1P>>3_g$YfzhU_{z785W}AoM z;CEBcglLO*v0znVpNi*PueI0LYtWDP;9%<&2iH(=MMm-k-JtLgnAyCwZ(I9kfd2!~ ztcK^?Fm#=hSkd<&>?(Y<5Yk_yM*)u@2_fsrtbu(g~ux+}*nQen*wn%4si8dI0VUIw=U9tGHy8_kr?V4y%b)o-L=zWWvu#>v?E&c=Ts zhR;Re@$cX0T+7!9IWH>KB3&#hcS`U4^rqPI^Lb+yI_WkxU)c!oc=2&t$;IbmdL!Kl zx3T7Wk3*#7CKvGeJkPkBq4hP^SW4(k>EY8WpJnXz|ddydAIsy=DmDB*Xy)tah6EZXn9CZpr8z_?#TztaC8q;p?qB0%Np!B8 zqSZ+%NrdB3I?NhIIOe71s{hOvcJ3Hql#m9HLkB2|HJ^-ZoRBPDK zDG8uY@B;ytt;AgWHgUQ3Us?b|V&dGkTC>{FlgWzjOwUUpdEHiCQ8tIcbb|s*V>?C% zn$KDZP_9^jwRC-J3?#_qptN`ov)lBeXJJZPu1;fQ_PYw1AohpltMxhELzc)B~ zDtK6P!-siyd)}QK?DT-2o)~HrQ`MW-Qh_6bm5XF2dE8We*@QDz2PQhy{P>nY-U1jJ zufD~_UH$7E0#|jS<93hVjtUgM&j*Fi(Kwe48alWy9~3EPtxEF6^AVU$4<2Cr7O{YFVcD)_kD*sA1B8|Ui~ zw##)IlmMZ62Oz`W({$~3+H6+pjN)}2ex{->f@`N{Qv#P>GK6oJsHo`dIwdvw?yg#- zVn>Ji=@T#PSUJD@4;tX9%kWJ;z%o&CMhkFm+jV=GeEOGanG!=m$+iY`0r|Dlx#wOS zmzJ2%NojiPG;S-9Yk`8Q43vN!Mn*@Mr?DqScXl zwFataN_)}6!vonWnN?uqVO(6Cnye#OoTOM5M7j9jP{iNu{*YOtnau`cF=OMMoxunI z8abxEjGhOucu~;NKY&D~zPg&v`<}-CQ%&}OfxCpn?7a!|t-kjY4zzsJZS23Lq|XZl zEMsc)yB>jJnu43w8{lC-c|)#Gvws6lY$^y5P#I3UlrCk)*69k&5(oI%=oyq1x*8MW=1oR5TVM$RFg(Kt+O%nv% z0aG29iFpwa=+)isyJ>vDC=*~XPULCrZ@I1L(+E975c9dmI?k>3YI}(wi~3Y#cXxL$FP?ArxC4tiy1MXsB-B(? z;c{sE*D=8*RaJ1su%2MQpu_F_$1Oogy^)%+S53GN#LzKI+5aSMw%A~wTA@KCQ!Qeq zfAv`*-TIe30e=dj&FBnx6*))aOg^lP^ko*e} zG$kNFP`F`0MtyeiSRYM!`;pa9jNd?53)=d4;QdCXbNOyyhi?Z~ATDlz{!I9hl)wKc z&ruGz01-Zzx>1;M6*b$OwJ%^kJoO0S*x!gz3yZj81}BVqh%*sfB-e0oqdh@~nb0f2 z*hgaA-}8Pu+57o=_V@GZucHo`>nKp#>jWadWH(3SOregk+9dUM$-rMG2l`H!K0Oq6 zPy&UGsKMc;SwtzU%r<&dXE3Hs5nAI2gr;w*j06N28*4pEsBj6-$6>aEFq-k-P+UI% zKP(~QieoYiS1t_qM{o=^J#rOB3l{$xYI3W8uo zG?w3FzI0TnY2F%$>n#WJoD9V|ljNW;a&!e8wz@RYkx3NDS2DHsNg$unKreR-n1ws0 zjGRYiB@R+Fd7z8c*^JPcut<@Nu)`bk+wOl)6{)Glo;E9ju`ooMH{zQb;#?*t#uejU z-JLBIOHD0rjRFmEo~4<(GYXVCFzp)w)Z;75^L91!^Hgb}sW!&Tf&KYF>Lk5*z(XP> zcX4RKr>Ni{l@?xZ6IFKp>iifauBS)vNibCPyVXt?FbHG)(v&ys^Jb38szv2EUK2(2 z`T6GH*IdXv16=ROU>^ie<+!!VXn+l~1hL)m4-r>!m=Y2Fze#)x!HV#v-fjd*6>jag# z=}dQ~TiEX8IcaLdmw&O=@^=1Xrswsv8Zbk9I$0Pss_eLUXyz{cZ(EqS*==@12Gf;1 zE3;Fe(n(O@ujJ9%Ns|+ZY73S{hnFf~_b z68K*3ag(Eg;gs&C*$HZ6)^^JufZ-|l8?f2Aff^11@e}-x4hsthI$Z*BVxk~PRS*=F zr4}s>6)BR_bRD{C7S$w{rnL91posh z%FX@gG5U6PJhqGK!;<@_g!?JERmjjdu9H(PcUlJLlCPng>K$Mv3su|8y1O7Iv zv1;>V!79Vjm|=z|(he(}>nODNtG@uWqjn|`paRfJ>1Z4@5+Gz(F95apaYhH%Z@o6V zDe7ntQVziNday_%w!jxxyVJQJ5*wu#9`gWjq7^y?6=-k`tL=;`y6J}|MtO_zh&nFn z-emX-&e4#&6!7=Wuu?*43|Z*CujaCC%imN(3PmMtakozqoqsm?d`Z)g@*SI*v{yxB zMse)*U$SsV3}VS`_KQas;ve^SN<`Seo!PmJlPkR?8ixV>x^|np?T7tEHaIjBstI`6 z;-Xr(bq(E&z*A1esL`TXYlwS)5LFKIfK-wL;luW8;AH$hxPlpIH*Y+#5Yv}_aU>H3 zdZOpD)02F$(Z@(He_N%5`=)Banh&#LaVk}?b9!seZFJaT|K%fewkRzsixO*dbt2i? zc2Z!XF?gGOUIOs~vnwChBqIJqwm$;+C<3%!LxS_G_C2JjnMFksWhzUn^;O0&zcmf1>tONQCx7W`(?QdckUZ_a#kL82fTRHR;re^1aWGjE ze1d-q3RjJwTAP*ZKmwQ4CO5@>jlx_)Y<#wl`qDk+nu@-e#0%26n^?YLo?MFL6f!_$ zDcNfNf6mFCInS9_m}I zcH9NV&HoqF5jLV*PyA#X(rf>_6ws^t6*5_>IY=o#rWOSRdwk!xw(4j+VJ1eD0x6Rq zH}GQq;5J{}mmL^cQAtNmPaoOxwiEWO&`{sXNH=UVw|t3+3HiakTdq&Yc0nltn&gdA$P zs5;^51r`Yv%W{a$J_|5fxAl65cl6(udK{7`a|3L(o*_1-G2;oF=n@D`#~W3E+(x|Z z*Uxbzm@sv-Rc@NG>_9}wihi*XY>_G%UqWuoOn_gl6zl^-3U^3ShuV8gt>nz2>;s4d zWVn}9dfIRB27qZ?;0EQ-ZlJGO5==+sDVa-&fV%f&+<|3AMZ4 z&psyYHa9nCCAmpG(ibL-+(l6>i@3I!3jgmx9M?uKE;fqdLHI%b&NG{VHi;9~%h3rR z2WGZn;8QEu5c%_!thTCd_s3Oqo@c4IWBxw%6(S5W`(?o$k1s{02cQ?wz@*d5**qXl zn)Fz`i*^dJ0Wcg;P%x_$rPqY$YAQ9j`jf9nLOB8lu>^OFt&H3^6gwe*rXkx&%d4vY zWDgVrt{d}1eP4C{Gw1h#iVkb8!#`G+Nf$3#5OavzCgF^GSx@e(2D3~udHlA~X))KW zUm+aB!o_%pO~qt%zix+FJ7PYme^?{dc^{q(AilU7Qf)?p&P@MgQ?FAy8%OIaQUsy@ zLwd9A&XYf~MFx(mPJu4EnCY*N*MBmLbWkYO?FwDp6LpA_6)=J+Y&29R3rAN{h z4_2$8fJGb7HEy2_KtKh1v0GGqZYp=VyIf?+zbDmgximCZlvMJZW(6o2-I{tp4oPAQ zyi^ec4+{i05@g4ZPuy(utKxu-kz6Aq$!T%_7Gf*$oTY!kWtX9z9*iJAJcyA?bF(Yk zM$b2QS%L%WFPF{=Y(5UW$FWnEzy5aeStz2l^^qlqww86mPKC+*xRVG1EfWb&?}BZ| zUQh5P_s;BIlB z{}UkV!EK-fB?!tmZ&w}JPX}t7y%4avN}kmxgi+3F`r1C*z}atX=pnH8ya)|mME)Tb z`~e33&aqYo)zVs3R@sx6lUPSs#7uz6?j(15hfJ95sQQc8=0RT|_RN(bLL`fvni?8A z88B&;V9@WENbw>k1!*rav`cQKeH^~=uUDAGT7j?_vVyC2TMOP6_QT7Z|7ClcQR(OK ztEyuqla?`{^{*wUTc?$YXyE=C3>_S?j;I(xvkDz|8v)Slz~xSl@zFiRchYh8}6_avS?js;$mVg{ifktV((M#kSFBZ+3tEmnL} z;j*r-!cV}iSazuVv{P4&qkHS}68Sc6euusM-pY2im;44;Cyd#ZA9yfXnAzi zZ0&9r2x!&+=B9T)8tlE=O=bQMXcmh@3+h4ZzvzZa9dkMb(AGmi=iK{=F)HQd(j-8G zMjO?k#d%cVV!0VcXlRz=kZ5jJduA993yEyE8HSo;?Va$pt>G=p94yZo$x@C^WfYWP zXsO+F<9lnbFT-*+hQOP}L&79}%;dzOUm=j+ zMAJ(pDUXD(DES@>W%8l9RND~MzhPTedMHI#v?LHTVB0AbveZhlb>5C%BaJ^wmug`> z6zcRFnO=i`6iwsP&h;e-I@Z5Bc@5@)!+PjZ!_degF7D~{6~4Y-Me||H@+U@iKgp4K zpB&+ofU}zpGGEcN(b_vM36wis>ErUV=vvGE8s+(@o~Sp9?Q*>fB#?Lc7Yv!XbQ^aTBtx z*m|kgFD;QpqUMlTBDM5TSi26`Wi~lAvNQgX*EfP^NBGCCW?F;o?@(mlJ=H-p)oR`AB_^lKG+u)ApgUZ27wjiLKGb+EdLgv5 z;*n|TwIkVsglH$VRs)0ses+GSuPG1EnKZ~5#e5FB+h32vOvo*iOSLIKY&euU^yKq@ zAbt3LXYzCLw@S~cxckJ^%fZ9z{aDIa9nT3IODs4~#*_Jb+)&0#2&Lj6VDNO|{*g6E zbq{xWQ_pltX;9I{WN&jPFivW2n=Y}Xf?W?sOZH4?X6=XBA|a#4RQTV;jgg+cC*`qI1?95 z>iu=?k`M!RVuy$z&yvvR_xo8ps25OF<27FGA}1V5diQxL5NxnUQYvRjO%b3Za&*gC5AkP=WjlRw%X}0ijwd{5 zG=I9BK4eek41JKK;HdLxgUUbVxhk^qGF;aeRyO?=cei_tB^S(bpIiSmEVPybRwus+ zXV~XsgsAjupE#-3(|BKA)Iiq1LFc}obA91V67zRVEA(w{aRW7O8sg?MlFJE4n2GYT zulcI^=Pgw_D{aD-u5yW>o_opL2!yDnTp>>7^h?C=RjfeNw|UQT(Ba659)#f%ll*Rk z3jHRkcg$ z;m4NDu{oTJB&xiKxNCxkVNO9jQKTVjGMNfgz%I_4;2}{v1gdmn$_TSoHVPFqy7j(vp07(X@%uiR zR7x-SA0P>|T&&Vl(eY&Q!V4&KERc>f`p?r;GzXq$jnLQR>@`k(ay=E+&sb>KLo?Pt zZJ#5G?1OTrWe@ffC!PskPYX!`gL!#a0-PGp3bJ&1$!1`~tu7SasaTucoffp6x|ZWY zV*IH(Rl8dfh66X^=5;0rW8@m{Dy3}S$$#a&C?}D(-*{Sj zM`<`n2eUP*eR8dQwp_2r*nr-#)u#cVfR^fw0yMK>G3z9r z|H)|-mtYRr)VR5!^-5Uvol$x21B@#lhU8O`XUE7bF z8=O$f+l}V@#Lnn&s^>P*WM$MpYrJI`Dw34SLWX}fQ!g%!E0HD$58lg!)JUsQ$g#t zosbkhK@wKf-WMvYe)%_NxA3gCQI{$k@hmmrVk{EAa|JCAVnL4Ha>o}1qnw=xzb|h) z;zm<>{*f+lL{*M5RtFp`m2IM9E66MLU~nUT+K*1+llL@*mlx1#=h)(`xoVtPum7qL zevSLT?~1((^SMF^@l4@j(0kD~!%_jxO!i>!TPJ(#@v{+ZqaGzPWGG2V7jMb&8(!(xYSX*0E<3Y?lI9UxK(kS_}Shfu8At}2Q&G3zeIPjk{Kjm zX~rJa*^2PG#0)owzC1xg1;Cktvde?&MTQH#uohW)FAG!sIp1D7su-z;@Qad@5?= zAJk8?PiegBG#Yh_BMJZeR<&7)-8_CV?YAr5?3NCYuA;fW=AVF>qli_JLej~-uvX<| ztm}a*m=5Cm z(Vq~cwa9bUWBv=u7`j&kQ^%1S==QRcvh~PkMRr=dtbW~hsvkBbQbe@05583G5^2ND=ZB+$cI?` z?wYqBU2kO1OOg35)H~@_=5LgVrnP&u%$ehkZ0(O;RVMaVLXor+{;&L%dgaY8DNVaL zUs1Wac&RiMkACboc8GPzj6ffNkm|A<6%S;^5{V=S~WG z&-=vRD{+R6SRb1mKze1pri6G5|2#H6hUxqe$^bOixR7=MI^OY8ZK2W|Fj*;uVAcbU z>Uzgg2m;u*W9M_wfa}~7&B<^-FNGj2*&p($_K?QxGM{=%fl!~Z1C&3K3Y9+#My6JN z1ZuogrtR0pL6j+lvaYTPmrVW%06r`saoVh*j;*v@d|D}NHum?{dfFoC;_eZWtcOUq zNBA3rJ2Ie!`}V%Xg~(nQRv$^WWD$Gnsi$^4oiF+=pIqL;r%3laOzY=ArRqlW&Oheo zT6FrK(S*>zj;8bOwHxj`hP>YCM}8Q`#aXbXb99o7CSQ-IB?DWRu1A4Wp2_P4U?g(5 z-5df47al;zUanqmr{<6t@i*D0<*jGWa}CW$NmPG}RQXsRnP!Km|5i5f@@%duS>E)D zJKxz>aRUBUc)hLD+518wHC&`fVp)6a@vzY^dBoBHD>&R+N1?^ge(1%K#M`QBc2=ou z&5}yNK8xU>hocwmwZ?8i>-@aqd?z*#+Y&MbZT)^$ad$-7$~mc|B2RxpAam($4n3YT zp18&gn*W?cU%G}t{0t5bEvGhW(GGYDFSi3KX6=KU8Tec z14+Q~2(lLbRUa<`0*y*d0R3_)udb}TPmP}#!LQYY`xGY=)D1ZKXf2?*aEP%K%(iQ{Z zYMX<~>GweJSiHC_QdF5nzd>5|i=%sOZ`j|iI+DJz<#B;&#VbHN)pH1uT+f@r$2`rt1UFN} zRKIfJKAlT)At7`y@*MQ&lsd65E{cgQ&hq{DhUEw4irI*5851JmzQc{qchkajR{h=(U?}^w>BMlgFvtm( z!$>^e){wj(0&+|Aa1CrmZjX+V36gw9_-^X^O*tKV^H@HStOS@Mw|Yc069;% z)9EQag3RZ>Hb49_{2yVyen%47L@O1Zj<3ZEvRX)L&9{7W^PiXSM=72h6MmDS(EOUa z^Pd{1FCU|b5{+PR9FpWX&qJt`oEJVGGg5laOmEn_5xL`G8q?G^swi52^gb`cf85M~ z<}GhjW!1T$9(u%U(0su09Xt40YkRU|g7(E*o1aa`p5T4coXL50l}{7q+gJnZ7o@1( znWxL(db#bS4v5J~3-+Cubmuk+1|;^IeVK;B-dXfN3A9V{)wbv3T-UT!~mG0gMogj8`AW>f1vQiC6q7Fuwt2qxiPuelLy-YErmU z9>CC<%Lb+n%V92$1Z8e;I$r|zCoqw!`U4E*8vyx4QxoRbB_8)XP5UvHDhy1hm)w?? zlU$q!v0h^Wcncb36V!3vEib8O3bXaf$Z2maVb*5l)!;KCHsTen5@$qkdDl91p z6N&gF`|-CO9UTJ}Ds>P!{O!OXBTykOLcE7a5`Dadgdhvcri1%KElhofAe15galYQn zsND;eNTsscqW_LjEc(Hz9*C7=xLu5nfe|K|_Bu>~4jOc!>2Mi~I<0(@t>#MYygG$m zSbOf^Qag}(W!g*oD;uynQNCuTWo2vWMd7==y4~_3-{LJ`Y_@QYkCtI`d?ntrynQSe zn)^(ewtR7<{L;=;V2vSm_+|j@xWssr3}48j+2Pb!t;PB3ti`X^c#cn>sEGF3#`t`y zR`vVrCr{3l8Rh&**=n0kJklDh?^Oa9<2euAZO&Hj%ow(MzTqk(Yl-{67wd03x@6}g zkJX`?t~#AD-X2sEE8oz$vaWp-3s=&=h)K9Ajjd}+xU$6O-}ZcKNa7~FkJ||M zQd8=%b8kGy%QBm5wf`M)x>4<+)HBd!`nK>jjw~(7<17?%gQh`oE}nK-^#)j?Rk)T& zpW}R)HrHC{k;42IP@!;6_4aDrn!2o~nyT^3h|Alpkbwzu-8eYIQp*GRPe9MNqG9ee zejg5)C(NfdKtm1!*!I7|2~b)L;f}#?lyD4MnzT=(5;2gcHaX(oBq><;N#U|CNq>2Z z4HWdEKD<4jFY!Y$#iPp9ofRQet2WteUG0thKy@g+DF8(BdU_Xtx~0i_qhonl-3uB4 zhu!1B=0P0rnSZRq{i_JDStAUTTQsIZm)C#6J{P%UiOA^4%lpv=kFV@(Q|zIExjccD4f;Nmw;)|s%Z;~{A)8s zGCSNL1$uCm3sVhgC9(Hk&b>;TM~uW~ro&3jPS8f2LhVM1Qj4zVZN_S@7`1ZlR>{~i zs`OEQbhPpQ_F=wvP>J)NAYMjQ(Q^9ARS=UK-Mh>R86C}=qvN=UeSlu?pRXBII@RU+ zYiGkW&?v%p!a`GbLnHc6P8Rf{ydFd~vy3W?Wx<2u%>u*CWEgKvX&yQ5^)qAx6qn*z zhU-sF*#p%m?53+(v99K)(&!AxKA7LtiBoDAspnXV%(kUZ`r(jw#WeTkzkuJ?J3?;I z(LPvBji+t|(4dcNN^coJJMRdaCZ@$Y9fg$8JjY3&2W#hDi!^ezAH|gPA)Xh9Gh%TW z&#KHVq7N+;#@ak!=_Pk^f*TA(8L8_{=ZO`K9pxOp|Nbe7Ig9Bp0mf)s@LokTQKi>Y zAe~OBfB1~9^;P{#_5tW7;!40K=H$eJcxQXN_fyJW&O-W70OFf%xzfOnm2Dqbz{h^* z_VZ!WA5gI%3Hf2JD(KGpP1VK*Apc~=hx-Q`*E~Gy4;(0Nn++>g{*+T<;S@9zVIq%~ zh#GE!4LCkN=WH%>0Q58B37Qam!wooD3s$t{5l3ryB>au|c%w}Q;0)ECwUlh?3E!VH-wRyM zmY>v_;;1EOLM)mc1>SxmTG_hzA@Kwk3yi$JXM`aLH=ALH;LbMNy;|{-)xiXfqO~e$ zh;?)3u4CoazxCb-WLD#@i7nMY8TSi@W=r{Ja@}C`m;G5Y=3afgZe4kM{z96h|6~L! zCAl?0m8`K5P>E0yyDedbNfO3)Mnh3y;UGpvW3x}VP+E+MNV+AE z`T=g4qnI*)ZtY;SWYW?e3me;<8H<61V3dvrzkvd<tgsUVVH<3D`T{ip54;x z@k3t#(P5=!Ik#wh_%L^Sib0_mh+OW=b*5t!8CunH23?C;xVNy{Rk{X$}&B_<9i`azJC`5QuB_LrCwkXq#m{-uNoa1}nT zrPO1}w`&@x>7z!{^Rz(2$Q@44U8$!xB6jY$UY%omThLTP-6-{}eAjGz3d&8P5}Ib^ z)6ux-k>7_Q)R*in)TH-#cC2)Kdx)}BY1iywqrVYuN7f*$rH|OgA~ZTD4}q zO^mB(kLdwsNZrYZXBoTa#W;x9!)uOSVZiT22wm2aI8bUCX7u41q5aO_Ts$ynlClhUsvt88mJ!<@_s)2c&0`m2HFqZ=5 zZnU&tHM<*(%jd$yy0Fw=bZBI4Nkrs&75WT5;{t>B!|`FF-9=ie3Ho{65MLO-RCnQ^ zyGrsMMoW>JhlfQ{c9MS*R0nl-kmIr9IBeZq>e8P5v%RzkUjuY6Cwy}1vivuEO`e~b z>y+sM8Z0@7u2#cUgI@0>+$CDlMs*}v1i1fqT5UMeH6ZGIO5RLd4Zystf}d5S(P#(| z)$+LP^FcfyvPg_RHC>b}HgTP@q}uPJ$4}}RwQMX52C?5T54R+A=cmS-#;;edR$a!( zn1>Ia?oL#soz-+9A-_2kIEL5({{f8ctO6O8>ikb*4!rYCQxMgu1 zWg)*x3J=TB=*^mwMWa_uSQe)ggHa9t8!za|^VO>Q$-?uUc%{@y?<%1FQ&vbLS3N35 zAv*RQ{rSw*el0>fOKC+B_w|`QYx=8UV&%EP+hBHEY^}VDzC}!RyQu-3<#nCF#9iz{ z50Ks)DGpRF*#G&CmkLvM!oFlRbJWj3IXHRGbnf$%etDVw65FWb4tu3XMF(-XphhX5 zvV3r*hnuFQrM$9tbp3j~_P2p}zz*}PEQ1~~U)7!&f?l?cKXX+Yrc=T!hkhDnvjP2C`@SDx_11z9*if44cH8QjyYH_?TKttA@eF^*V;2 zchOs!8w)yD9%>dG?O;XYMGpwfY<)bJ;l2BhmMf;1ZtM~L#J)^1`>3)oD=L^q1HW=u z%77a1$^tZbN_#@l8ur2Dq!8z%R9P%outbZ`3>^762zK_BXPKOx6B>;PmOQeaMf9(A zZ;ts1jz;rC^zS&T4p#@^?)7b#l5ZX{OzxNs?)zUz4)SVG&^?Fqe3?V&LL`qm_>nufUV_$Ejt?nZ&( zJpo=Jz4;VX^nrid1&d#7X6Ty&HS@QxgH;`~HqTeqHEs98xwJEVV+tnD$*hqGVRS2L zPfi8$+G>&pzJPx*;1hbE6hN$AMZQKzf+h_p6Zr%w)P;cT_!^vEHKGtfWt%nR%LX$q zsA=;T@;BbR!shQVmShjEGV{LR09DYBmtg(%!6)su@GzwgTI;RqPudOie|o*KI6~x+ zyNJJnesud|WNj5k$n!5>xOI~(!a5?G+cG%!*}{~{@X+#wkj5)euDjYa+b=9u$k--R zT+tfk@wpHYjInn=KL+Wa;xIy`F;vB(PRL2#^>EO(9Oiu1YUM0T=Xyd6hQ$@`w&Q)= zMWwwxoFd}k!Id#uO;Nyc$iWH#(eL=9#z5n_`1Dbi3HXLo?faqB%zwMrc?cGjlype4 zXCnIX#fIR;q+0k}VgVuBAjEuPZ`E61J=p`fKwzjI1TCAY{s%<1w!^=VKvSO+9g#3; zB&*OB$bD1Pk|xFRSQRM2v@u%GM}9tBpVfZTq3c{9Q{KlQh9M>+W76!2AQBxW3P-kp z2|3Hu=|qwCXS;Rm7%?`9QLR_6>x@o-aQj%4a2mpNE(d9a#ZG8Hu3KN2LAxk2b~# zJcKHPOfH27LCX(Xp{O4a1mAB|U3zxA$`&)zKF2FzeolV{j9>3t=c#h6NI@Q(5WigM z{eEyTQ7HAU!e#wP;&!b;YDgu_cHO0H>~Dgw|AM2sWsr*aWg)q&^6wD3CkK|`AKfO` zO4883rZ$*ios4%s1+obnO!#4Y4Zj@28_Poo% z+z^KkQ}!?#UkGxX2lhxZIu5TGI4>bh;7}HkqS`kGaS#uG|x{0?S< za41ypWg}CkHn9Ua-$59eoue7)6Zg#PrX8-dd}5Qu6?OC0V(Z!$a?eAN+GU5gvK3(K7 zv%7>*YLnYyJ{-hnnDQJmFZXMmRMxUm3aYf*k;=lKfI;o<`(Ii5$5@C7=;;de6)MWR z(4Y5P$x}Pl8?^zH3Y3HbLOuz~Cyc{*d5bb_fHWI!wbk+8-ScRrx6IhxltU2tr(=BN zD&QYg#Hjy`atGq0NEk`&CWz*50Hgl6bE>dgSAScHvLd!l0+gVTEF=b{J&`Q$PD#-3 zlmTHqrvS#?UhPQlQZZu&=41Mpa`kwg(#|zLcDb9DHryxhlj`2L$W@osCfk|9RwoO5 zAHrb`C@^rD(N&JUhz7428(=&Pjow%HLYt{&pQd#x6uv9zQ(AW2n**L8Anhe5?*XSV z1y!=sNY%^@j|WjuKFC`rQ^Iv#OI|BZ_ODFmPR7W8@bX)O;>XUM?Y?lxm8>_2#V({Z zqJQ--pM%>ZeiQlFsx9=GfBlXI z2!+Jv`fpQ^kMr&7%HG#$%Ogn27k7Uog&YD$+Ya+c)hN0zh~>e@rMksC|Jc$2r2^kDm$?05n-Y zS+HYuGGJN=AWzb=b`a|~1@Hmw-6=NCxavp{x1Kv?w>e@$*{V)WXI{xA$#>LWFcEk> zSQ9^!%7SH+jpSYfXhIn$7?`{L=~pKz+J8X4%ugY?T^;|zjLAkcH>MGAkOltTpHGzX9;o-3g&)b4_ zHAaJ?JLA=AqWnd7i~o&R-Qgm5t~p-`o`Y+Irm${*0Y0kSJe?%w62;fx3)oM#N!sr~ z*`E=`^g6(ByJ={IONfL-WD-nbYHYX(3ylCKgo_EG%hAW{o8<7ZZm3Lk3*`y1@10h^ zo2g=iGcG~myO59ApNgYuRJR6MeN89~OS z7I`4&D#z9;wS7d>S$Qch`q_>H<5ft&!2}5iKJLV(B%jthG9Tt%IV3oHi2ewV!|Apd za+grq^Llm)Vs0jj(->V28BevQt~&NV(kJqh27YEG3uWp)g@t^cWMj(1}cb4D(rJjOrB#qa1GZ-wzZm-hXC!|jDR^3iJ zY6`tpAx#My#a?H8yOr&xeSpRH3DJ?~^p--j$G=O|C8SMhP#Gkg(0)27yL)x2CA+If z?{+#Rbg&nrwgtd`6JtlSFxtcv{J+;3hf>HFAv2{Gs=D=W9b zYxqy-y`04{XS`GX+xDaYFX;D-NG?*_uQ-C$D>R4;;m!_8N_-JnxPS7JV}M(r_~{($ zctVN$3P?Uy$ChJLQ)BVMr(PQ+>0@8&o1Y)Fs$0?e{3+zws>Uly0KZ&KWGyiTK!X_liQM=Y-u+l@hEh$1~n*-Mp0kISKdFBUFCLM<%*%Akj znT9k;d>{pO40`99y72jMWlq5`E)kL6o+)KuJXk0nAJT>W=hIk*qOTX|fC>N72324Y zs&?9ZzO32^yTOzpRNxcCiYR_yam}}Mk>{e{luMKdIc|3+p@6)nev8j=$!wzb~zI`LM@-qnfu&tmtEpYaUOGKs_d$(89h zUtpo(w>wVGM!txv^cFXLgY8p_ z$$m+I!SM%v6ev$}%7CeD6F!UC?8R2k_4T#drAX&ui=%njhPLOeQr}68B^Z*|-4Y15 z1M{|y0=*u2Y|&nP3f|DzSpT6DP{}pyqTT1v1J0&YCJ;hlm{pg-!4$&CdAVpGKN^#) zOYrc>2r9?U*VdYvSadj``McKP*&0H<-f1wQkV{jq)FK*3%e$7eMAI^pRbS*5hrBHX!}Z0PQ6G} z-;Avpc|P%KGW4WtsVf)CH@K~LGPro<03ywA_Zpj%EyIqrQ>QcFIb zby>FcjBhl6mtn`fxy{-DOo;8G`zzg~B`{1Fk^SPq`pexZ^~QS$M6t>QKth8~dnbmpXHngsSJ0H1$RVO0v|RH;3)gZY zL(13$7Q+60LHtAgTw!zeZ2Q{Zf5R)4ISG;Z72)3@C^Ef=(S?MO8&<@?1mm1my`}Il zACjxtuo^@Z+2>CMMfH2AM;%fn+0c|2oc}*r7$7vr|D?)lFD{f67>g)y0WcxJ1>kY8 zY=?R4OxSH!x%Ijs`1#B3IosRiAR>Kw#U}QWj7(G~CmRILe|LTM)Vy&p7qoQs~TY2?!3kf0T zp${1i0s$TQwmS>SAPN}t>t)ERsusmx=Ts9B?aUBv%R5$699mK5yVgHGm)u*It+wPn zb^U1q1EntC&k4J%uRsYJ6wZ!^gH8On(ErhvfxZ*k?;3qu_vnKOh4oI4miKaVFa^unL7#cf69^=3G|%mYZ;X`42|73qrD0NEaY*<}_lFh+L( zobI9>g#=tisxlz8xl|edP>`md{Dk=Nph(zS|-BF?zS3}O426mW4bB_xS4V-6b7Ga_5{zZ`SzE>TUeD$wB!V1E%3srLzEoqJ(d;|0Z*gy9&RN<^nOn&Ln~ghq9ksx;-88@;)9+7|bN!bLS$E zATxnk*Q+F9H7S^LRhJ|@JHQsS%srRBt_s_deLYpB0hqCZE*d7ll0| z9Wx;pg$0kNXfiGA6%fC!`AFy1vmfa=s;B3#UoHBCw*E{ik3~O@4AWYYVj+|pn)H{T zrs-c&5tG{*efO-pm4)TloPt;zjYE7Z1zn<)F^`gPv^3r9GQH6iJk$4NbV^{nR?>P~ z8@l~DZP#~?Wc+qQr@=sEHR+01mT+6!Ki;sr4j)Xmg@nn0E#vZyKRNZ|hFkpiOVIrC zDNALUY7hcCC>fB#)Pg23jPsI0rup?thDch;lypF>=M)hg4!UCn77}8PYuT~KB&~H8 zeX^Rx;S9pjcODFsN*y<|wwV*@rp_YLs5$z}#QENyYdiZTSdz?T6~lGZr&S2Am*0J| zi&(N|?DE(3L#-139Iuwt+3t6Q{N-1COwl>*x&JU@LD!6mkV=toL5y>}1M7@e z6(0j53i0ckuMhJ_!Nmkt*{DLxYRa`baBcI?{8)Vbjp;6p*OBWh^-?|q=(cFhMKlvj zp_PV>kYbs+EVskR!+zZtM7l9o;%F@7d3y5GYoo>bo0_9(BD!U$i^s5lz%wGHddFOR zGS(VvxxS3mobSu-UoD#YeSx1eGWat;yA8?J>$ULlaaTxcNei1uE1Nj_F2t9^%EKee zTaM!_RteNhV=MN%3R2S23s`x^0-vcG>DcPe4TiB~*`FhKXRJ^Qa);N%%m)uOn;om6 z1A2g%oS%r26Wm?<6&i)o26lqk`| z8&jVe%jqgBG-E+0@#Fo{1z@K)<=CtE?k|!O^Jgv3+vYoNb?&-zPLd4rLH9MYvuDp& z(G5g0b6e(1h9M!W!UerTy9p+@$U*TC67_R8KNu-k35P@Y1mpiEVd;1lWf{I&MUQNj z2e1S;aN2AsSyQ~6rk8kPqe97)tv1xT-Io-NlVMJCVor0#Q)i^GZ&RYNh=bX@qa!CO zcNhes?09tn*LDAOUU3L`bl{MnozMb(;=PY*ee&1;%9&3tuT$Ev0@CI4@=6*{b6>RD zLj9CpI+mELv~EHwT%M+#C15vtC?zoRnMW zf8VVa)CrTAm$bTi=28f$j9)BIGLW6Eba=4IZ^m!VP%Ej*yVg;uW{Vbs+VclDlBVgU zP_Y-PzA6buh}jz!5LU~G$OSF3|Dz>dv@szf8qEhvxXEz0^!0gr07fJJ$J5+mt!FXY zdTibCX?OJLIP41En>e~TG&I4@UaRjCb~7D* zMnF&cWxzA0nAk^rsr&zx%R@_h$&yQvQ_l~3ZM899!gBuTZ}tY6qaino)5TP|ZgpYF zCPvuvbHw_%rDJsxO!%AupK}#Yw>*l82!3ay*d#UEDM5&8=xC^DY{!*X;$U>C{wt8e}~B_)54!qLEq9%@idG#{7;fQZKGI z1nZD}tn1sHL)sz`{-kbyxxt0<zQZQ3|VF8P%YulGVIqbpLZ-46Xtd##vEieXkBjldq*L z|M?{xWci7X3VQjXay8jOS6yK1`fIbt^WSO5R<_k={>OdKJuZYZx1E8s%=ePT^q%wa zg+i4RPQ?yaIu$+Qy%huk8Omvlip3Nr_xxB;7WfIfH7#dvg~dGh?83tSDTU6n#&mI< z9`^olY>S`g(_-EtFG#SUp?Hxw)AKlzrY5Hqo!1Agv$nhQ=}QmFFR8oR++RMi zycFQwjVfYJqGdF6Vl#AL**_~@t_BBTS9?x~V|!a)##VlPdmvz}Pp{U>Z1H=o=iu6b zPXEXM19(%fvjVJ0Ia*8x#vpzW?^|#q|5ExblmJ98ZodB=fIggV?d>Hav@H14yrZl+ zdf2OkXR*F1FzFo{E%W+Qn4Y}Ln#}vYxOCnsO&-_FT&u_OoSk1p{08=H$BBeOUIV;HLKwny&vY zF0k^}@sEOX8^#*CEN0Sxki1An}_#z z4dT9g4o$_%Kjp>AdM(H+*3{#y;wqzciit{AT4tiA3N1JPT5<%ar5PGdagnXHLUUzI zOf6M948ZJJ4Nv(10R5{&1TJcUBC(4!kX?~LOCF=R(d|&-8(-a*O zAODeieEm%oY$;UHCkOmDL0%&^$V4wm|O~{#A(z^EaJC;jD^CJXBJZ#nJQid z{3I*#$}g1g9@dA;d1|=UI2`&r*ccnz?gG7AoBsxdq%0PKN*^F(N?b`%im9rgwNNl&8Rek=22F?I;WcaR?YE7jdL z8}FLd%wFhrzk$p!#K0rs3e^N;yd9r`GB~(vU zkw|DxIA!GBI${0E>4j%+b=msHvc#p|g2-kBKYQ%JP8BX{ii@0@vkI>ya^eHK@8_9R zn6=WCm#aTQ=qX?N;EOuQk8CbaK!DXsnlLG~^?wN-TlQtx;>SvPqv81N?QM8$*55x? z;4-|Pj%;mhX<4~0hTD6XQDIG!cvY>Hss7vQ?h-jgf9uW-c5FaX#%yUIceyDH!LYq# zgQM{Q`PF>Ec z0g3mG3%IOxDf_1@f7!2Os+XJ=NnH#kVPt^%8hw1yJ)5)yV>uyVDn0gG(zfgbM6Bzqer5iTLYk9vPdqY1#u z5d9{(6zDOU#1I-9D(}6200m^dHWXcPw{q@~2b&XiMT)4_ldwH7tqX&oLYe^t#ynEp z?_pLws&bKPK@IAOvz@2~Stq>F2Z*VNSOgQ~LDKh*i^MmXL zY!oS7H3wQNu(0qt-@pNB)hlqd38jpiaA2U{#>U3e=NT^Bp@dru;1m`Hz)b1CP47w1 zr=vm`0^z*oqMY4owW66ozT1}1O6G`)mfawI7VP*``;jnSZTUovphl<_oL@XA|9KH) zznPf1*Sp`J0b+dX!QVds!x;%v!}I<5`$Fg2-Z;_6y*9NO2mCWBDOkIJXmzz%p%7&= z@Nwh)4V2i^dfs9F&3;CTEnB)aoI6OSzDz*xNPM|hgvt83W=){8(c1#Kv)uZb@wH7P zq_IN&5^Cj>9&qk^+)MVv*}$}=MUr_07Yrub7Cul8T5EOB;eOIr%ND!f*d7KH8>pkN0DunY;wS2OagY}Lb@(cJ4!8*}f*={Uq#*+_ z*E37BbrhS8@x{zoK?bcL3i8UzqVf?srh`PK$|arKz2Ou~j@twlYJLZ@mEXTF|1(Pf zIzCGGE2Y5NvJLwQp0D3%1w)01KDd66KcG`^RgJZ@aJK+hcD68yq|Z!STN_|h3EYSI zo{|OhTdBy%KnoQ>kLs001)zAZ*MejM4#rXkbVH*}CNg+{W+&lNEC)_tK*(ikX68Gv zF@>9 zQo(`&6<}?mdQ-cyLB)gXXYi>WF7sUY2Ti*|OnzF_eg#SZF=V||v$y$oz8IPr2geVd z4`CdD;f11-<(&G2XFWax96A>n*&46QzHe=m%p&*z#n=bzvv4F<;HO8)4DQDs#Gise zHqm%IgOo%>xpj5%0Uz|*4K(IGV9Uq};OWT`3deIlVK{sE&~^=;0emNy)B1&zb+DqJ z#gk1A$FoNOO>winw+n{SfFAQhL!~;d21*yw%M$F(sx@rS7~JyvO3NqK<2lp}PzwQk zeu38baB&vz2cHLJC$kHn8zYQ`MMXi)F^#he>=h7*&(D zTsxu0;Dxbi=fDKPl3Y%P>y){}9t!nW(xu>zqPw{BKqk@(`?^|0{`B^VJmvtGf1OMw zFYq=Zz+*%f$a{9FbR&R^M&XrnY(b1I*Qbe{i&oYJU3@io1ni{Uxv&V948XfMYZ@E9`6Bh&xd$BnS=f0^3_om!5X$@EM z9e}M|{DETMe0Xg3RnBApgLm{Ltyj4*RL!M=p;CPQa6jxsanZJ()O2_MauX5q>GFg8 zDh=E$05>mdqAe1I6WV~DzX_ny|K>CU5~Hh!ooJC0K=%Hzq9BXzH?qln^4^1aJ-xy71a(y5F`R);u`e2oiMuy{D zf$|3o1jFthAA_371D|#(h;CU?>%r_I(EMyQmMxINbvBvioMC)zTp0V;jXx<+o)fAp zq7e}E5{R2QQxY~};7&%Jpyje`PI6u@U1GJ}%U|42&_Qyd_aWAT2cb{L3+UfaDV#A5sKxBsutE za*7hdBK6RHL4ko-Blhq^o&uC?6Lgm;ur zXb}l>L#IK#Lf>oyhR3}z;{9m}y4Rz^Oyx3F+lQ79L@!|aYu!&VAN)c~NQnJDt(#-6 zXAI4SUcqRQi!Mau=VrWAV+3$X{LOTOz`4Hj0F4MeubniW@N8m9U0(rl@!=As(ob=m zjL9Ru^UzlikdHvt!7vjf@i#!%QSrB({3ak;+yp#>(BCJl%JYDu?F-spOj&%Hhn8M? z{rKQ2bvNFSW=4pu4pptCb)Ipwjp;Poi&xSz#>!PM>hTOtf5oJ>rC(uEw5RFBt+y(P>*8x^b(SYP%IkyM!jdPhx>6GaF?60Tc z!=38L&U3m>r-bV+9tDomy%tk~CR^VbOc`Q$mZ=vSkb|}!@cr_pBeX`F% zx{D zr~le;6bqWjfX{b1E-utXM4O{Ul}?s--Ahoph#6!*1z5Mg@qnE=zw{@w2z+9rhX~FH zl`B}r|F$wTP?B^rA01lze8Z7#(R7?slI075L`^3Tt|~z$j%Li_^s~%L&+i(e{?9<0 zx?J22P!WSi1t$_bq;ok7yABpwjj&>YoR>ElobQ@iEfT0w{k;n$F|mBmt=_5qIww{G z;z>W1kddY)8%(xc_>oeG`NNxPXA~HSKxw9!&R~MnBsz`4=YoiM);3s7?_O08>s+sm z3nWY|O`QVY;2;q%w-uW|(-6dzIY(y9HtxRm^<`3vwOLfrQly~AQmYrgTAF)$xq1f7 zTTBn=05qTW{67iRTXw)?X}@o0QRBFY3`)@v4C_l2gI3OJ0REpUfQdVcu8JN)tgq}B zRSux=D2G!JXQ)O~L+A@twYrEU3!tEL9kvL6tTn4vcT>dYl$Awb+M0e4b;BBA!Xez$ z2=$n@V^u$qbPv-pv5Z=;|G&DfGAydD?b01XhcwcNARr(h-AH$L2uP=NNT+loN{GnN zAl)gUbc0Ga2nYz@9@OW0|9t!#W}maqo*n0owbl&^c)5LYYlq-)j5D0zy9k~#GqY|M z7mzC=ydg5lMKFv`GC&8lAeA_&*k#9xRD`^UOfCHYoX|n#K82x)x_LfoDnF)cr*8T_cM5m2K$4hR==11GJpY}Q_fgTq=gG|PsK**>jv1$ zowN{Vb*Wj3i|ltjO(;O3AJ3XxBuC4-wBGmdT82SkDBE(4-6)S@MESFBxuH1gSdnV= zdb?y-IJKSt>g&%rPsSIX_r^Q+lNr{yJH99^s!|Ja5b5Hz3UgrW$swYVT?5kGv#_d7 zoikgbHUxVUw&^}XK4(8?UoTC6p4Utv%JW)I-(IdWX;-b7uA=D)zKX>uO(cta5>n^0 z^)a(t01sWY*Wcko43dLP1hxN*{BmEh6fR&N9Te4RW6VSSv@p78zC0c-?R;$iW3Llq zo+cMp-hjxyOR4H;3%uB4@ZSMRXB~UA*J^X3-B8M)eaH%Hh<^!+O+IF&`8{z`t_e~d zX^B!=4p4cKr$FcBn02*cnk`LtZye>&5blpPqgGJpF5IV}IfK`Z*p(rD?2$WihJEd&XLFq}^PBGFf(qSvrlNn(&+OfCGfi}CD8sdCmUDZ2lrmiJe{0oxB0%pzLF`Q0J zbNsI*)y;}Ze|n}32ReNBSBFejn95n35A<^J&O(2GA||0+WGksuZ0Ic!9faheZ~X|k zDK*WY)-XS_e8smLk-*6?isIoDCyS9QX8F_Q^99#ao=pi*H4Fj z`8yO&ifk;so3onULM0X&tgSlPt}yl?0SRHE!dS$V&Y>8JhwgWZX$Ywgc@iN&6QjV4 zk{t2gS)KUj;Y&bxpCyKaw=#;5yoG;O+45a%2ILWN~)W{k*n<40k2`x^$QG6T8RxSt+UTP4NP3Xj+* zmVCVN6z^mSL9x}V{leMVuu1oMttS&6k!K8(QJtf8=J`ijoS5~wnsEwUK8sih9h(bGW0}7)%zUpVw7hV#b9Yj6JItRal7SQ?1^IB>y~X~2dgmL17S+fbqMAl>Vzp848I zt7k2}V`ZA(7YnzeSht7Xzt`R?Pp|EW$vSC1zRY~`dw1^#p3he{0^Lb7&t(;XpZ^>k z9pf5pQmd*Yb8t@9tc=^gYh_ zuHHlPRD?I%?J8Yo{5$NQhcoK%Od-Sz3Z=4s?m3MO2v=i+Q4V2+%b{A5%R)TPy+7?w z%KL3%6NwF8Y`R~jqR%~+dqtk4p~*30)Z#;p)4@}T@|a6G!&yS|Ie~8%a|WvNfB7KK zJ)$lRQioqsHEZ2yY{-M0j>K;?bvy!3l{{gSI{s}x@;Hal$ z*BiAdG-mQJ<~ntJFuO^X^+YI&TdmG)&$)a*pqTl#K=}6_-{)`fd{FGSl_+5joMoN6 zk^H3Xl+Gymsrn_xU!T)qC0?c;C6?n4gF-k0KNuW2#-`HEneqr{TRS$xl|%HXZnPas zOM(h*-ar2-H}0Ns^ph(QlGr~nQ?(V)_iVc&8gq4e_#e!+{tN>ZU$LulCAfp{h42wk z&<55LDxeyrxF7~bd2J5V~lzJZpD&6l6*w(ZMpr-@##Elj>3F+ zLr(pnL2x&D%!gq=T}RfSeqUu)`0Im3wZgvhDB-IMpXztdf9)p~2RtA>t@8GouOS(! z{elyc8<`YAS~vw!J-S+dPqs6R=_$4NnB7uMq-@;ALh{GL18+X%axWL9?FD_hdCJA} zVy^ZMif?~IQE=)0b`>Tf!|EnRj6!ywGq_P@G`*ymo~GxOE)Sv*rhj0q@A5|9rWVRJ ztBP_#Gskfg*KznJ{s~~2y%4*8ze=FRFH_SxT-%9}U5!oPlxLh;hpwS`I?Rlk5e*F* zF$8!bz>m)~szZwgK@u2PqC7e4Yp0y>FcQ=rUc8$MGZQ7mx zEF__ZRZOm5e^dDVfu2rF3UwJOrsA6$N1dM|&D%Xq!-5!VjCq8-4H6T$)0!Ofy2lf{ z*J2N)6gO414G}jipLsl!PGS_E;&1Tl-rT?Zrk8(FqTAADb>k6T^puW!+-vE`MqZqx z|6i8MM|^xu1f}kfpZ}F`9Dc>WEjTd4J|>=uSB08j2oC2h}BW#kFHu)@pKPBxSDc zo_}r+ru=!UQ@`IYr_^pa0i~!%kI1+CduNBz^jlt1mW6W&Pw}*-Swk-%sAAW7Z`jjbD?}t~t8JwW(<2x0~o;)EDnoVTur7nz4`$Asmj5 zgQ^wWqe28jq?YsK3vE?C#4TvOjb>b&8X;xR%rU0WMCOGyxM&GB!&J^c7FdX7>nG7F_P-X_gp zlDB33EyZV4wzJl!B0qj&zL9YoXX@K-s^_UyE}PeWYllbTwu zL70hey^nol7e`OGz3>03gK?nM^Mi=Y?~p4 zDQiZ(!S_Q}A190ZWa{jooSOOAjsL)Mq7il4W^4r3_933H6g0{~)u;w^oO11m#c$og zlm>-W2b4pvjuc+?yfS=Hk`vJgG~-qXZqEa5t?M1Ix2a1|j9e2!b5_`i^5`HW#7MK2 z@BFvH1p1g}y7K-B-mK@ZJJ0!5SjaGLBsM?SjxB9*uQc1LIxer@brI>BTX=FF7XLs_ zjz3Q5;4+Fl*(SA$Tqvs7(A{je+aG!K+H7~8(ZkGmSeA=?vR8Urzk8aJeOzOD>x)_A z+sFx9976rUvIm+npJU>_lla_T{l&)x*R`m5AKEVuexyszeqU++!2i=`*6hcy`}R*e zKUVJ!ce20&yHY7%-om#G`u%p2vsWx!Kr?2%I3$Ye&nN6Qrx+B2tH$y_iXMC@k7JOC z4?PJUWORLt%;1c!Kw_y*YPKK*h%2tu8dMRmT)imB)qBCc)=sMV#Ik5GwL(ExXB<-+ z!u3e5BKmoiBZJ=?Ok7VR7KcqsI`qDAYb%H48dYrL`o|;b#}i)8s%a*jO^qB}b2DtO z(|r_L>ZS~|RZLkvpqciZk4N!^{;{DrkeNN9wc4?wzh$~XuzgD~va`tL;&AoXuU|IE z&>2zeK*RevJaJpy3)nRwVGU89CjsB_F_>%^3G0hle-PPApv`<@sGUFVMXVFc1?O?8 z#?&{3KhO@UkjddtOE$I{6sdu?t#f9!4$Qg}-#^b|mD7D4!JhmXCI7Pp0?kr85|3Mb z9p=7ST95x#xXW14&(~nFuKA)&d15n82Z!=w$-A041D%hhwaLDf3u6q6?IS;BG`b6L zB>y5aMV;!jw+{l>w5XMf6v^_5bgC@SMG@N6#1A<$qwP*f#oN}T#zOA zC!gd*K=Uvvg`Gu#JSWgBL`Z=+CAFgBL%q#*)V5kyClzbGhBvh11Bo48v#uO=U!rv( zcO~tIy5kM(qmA?FP5SqI6s)(S3o|EI+iQh!5jwXO^Xh?~stzE9qM=%C7H_F<)Y2oa zr=J1A{%`qIx2Of2<~(sXt3-Jev)gvDhN;#2sH>AjU#8L`#FjAIm$~SkxSFtpw| zVKwBx3@faTHSmYD&w6!cG*hp=mhOpe(O^#@uJq0-ZK>?(c?KOfKY1+2Pa(nr*80jD z$DHKjv5b@h_NDRdn|>Sm`Oo?>c8n7V&Xb~~pBFRLu{e`>~ejGh6di=zlDi8Nqmuh_cZE@|*gf;}o?= zgvmGZWQTp1TjyesHdolA5Xs50r*)51HA?mavC}c7AKuw!h#F!0v=gN-?Bi4l2%G=>7tG7T;}OLjYX&qnxE(k`p+#;e)D=BwxcFzu~NA z@?IulC1b>j{mQh%MZb8mt2!z)sF@O2TJC-{$>k@Chk{c>ci2xqB{DD3O~Bo1YS=p- zJ;=a7^Re8Z6ciufu=`w+NEEwSsqv^LzFI0@`$&zSNL6e{ zRG|=IWtZagzUE(Oa|KoZ<&R^(c1gsi1#1@y2A4oz&CrSgc>)hDqF1R!HrEgYK?(q( z0jqSqc>7FM>zksU!2}XwT<<{qc>38MZ)~q-A2Z>;W|raXn^&G|6{qDQJJDr4=#1Z1 za4v^KRhC~Z>Fy!^Wv~Q;-2$!EnARB9SF3&A(g@|JW9gWvupS-=seL7jM5D$YtXZUy zd>?7%6FVDQw8N-9z2yw|9r5o&!v0%~KUNT>+uImPnqf#plc`PmWWTBVKhLpa!AO(c zFz2e9?a6B{pMKGA&N(~B7#K?zH7jk@?}S}mYTOzH#vfo;=8+lkbrZAN_XjVc=r2`E zPOW}TOne3aH{?x4#G`({fPekw2IOt`3+WkPI6amBXh(1fA<&lh7p~rCexpST-VO@_ z#T^;lSt;WyoqQEyZVrevF zTs292q0OK)*+&rj!!ibjQ&qYH20wuTIfGX0D~1^?1#sKa&&JA+HgZ2gu~jV96GAcj zy2M?mZsOlnVJtsrjBl;6UYOJja13?$F8pgdw^Cd4quxQlb=O54jNXMaa5!<7W2Y>7 zWDUc!LI1;qgK1#>?U7obptWnuq|I)qd9#z3Sgzk|%%9sxbYx*l%dmXoyE!E@W}_1& zkN;5ce8nN2RM5R-=%He>%a?+A>gcDHd99XuUt-g?V)~jBUsj8tfU_;r(5RR!wD^^T z#xywFSV)(epdNd31WNp?HD8s?(}LJOHh_rVz|ByLc51?~?fL$q7i)Js@v^LEV4rHAAIM$1@Uf>H#&h(&;mIfr4 z>MJy{qKZ7KLq7Xwe;vZxSJ)S*cM>UR^~8~BHJUk`3ExgJbf=6OW?#(=4Qe9T;w};k z`>G6=GFdjCD1NCW0d+||Pa`_Ym;4yFwoml0uZ&53HOiNq4vSESrJm`WIcBwCv7sRW zQo+JvG>`&?!EYaSZm^dD`S*{8O;yn}O<2&!yFj#2tg5QYWvSVF_nBftNr}z+aK_Fw zA2Ss?^f%X$y#g}=NZwkCyftj{s64Zue6Pu)v#Z#Ugq$6YYk=&y(4ZRHb?kY4apc8n z(BMStVaw_x=}7 z+&b58HaekoAdQLOt#9wSI|ILq7VY;?j?_LpK4G=?`;TrxUZUk{Yuu9~I4ZsVnWv?H zeVU-adhNO~7E{+3txpT3dYa!ko68g}_*l-FdnS+Scd`|rkv3T|82KBmF>GwACq=}N zJ?{2#bVVu zw*1sm0+P#;jveV)txjv*k+8aiAUk{r6b{E)#z#98W{%LP+p)T5(YHXVEu;#jX6Tl& zDVxu(0I>c^snVhKvXW5kfY(N{c-kD6|6i4 zm>E!&-~wdTNV%=Ba3UC$bBDJDuQ3ahgB4dc6DZjWSU`=n-K7@6dom7S!T=`)U^2jM zuZ(3C+x&kwMBOKZXgJ50m<$LnwlXcv!A&4Om|ZEk8N;Z5f9$tl5Ja%c8_=>cu%>6eyzN0lXL2W?g@}l zgH@#m1ip8mc+pR+Rsf{fyk5cJwi@QYy*}Bx25Mz4sjrV1=%>8C!ds!jGQB`tmy;*l ziIAB1OHVF%>3{FdMK+b=-`p!HJ*MF&GDTaEW6C*gN5loBvDYjJNGq;mcogKFoF=eR zR1&uxPlT;%X(&{lxY4?m(4ZSilqKfme5RLGhLw{1dhlt719=|kqX~l#M@L7pbL-Ui zO_yn1K@q~=Aln2HBoqN6l*$SU-_rQFVZV~-Kf@$h1iF_(8JJD}(xcp19vyXlsH97RSPzSubt`AG!oR_|#|1in8>}9)KVs6H8Cc?VE_Zq7E4nH?k!cS;* zFRjDbUj=Cf&=92OhcTg?IpYH6eej4VH7^!3;~<8}3X`fqV|!c^Q(JCt1ZfrHP9tpi z(KzSU>!qTJySWSCo^=%BjCe%gPE%r(@Jo32PL=qsV1ROjPww%-ArQj1N(Cz6Nh6pk z7PfPzBSpr>hOIhBlKKm*P}oFnN_OmlI)9|XK0JxgdXSvOV<0dHRbL|TLD094w9sbt z2&Tz(f>O3hpgfoZi+U*92dpzSJJsJ1@!owoWIzUo0a}nGURt8k6d!G5*WC^_;d2N+ z{AtX0Xw9V3Vk@}RKIeP#xc99`+V|3FJYYbK?EKJvgmw2S6c1{bfhWgvu_D;G74%(+ zIRFyf46?W3W{8%&FQ0U=z5tpLy*tBDPNZVN;o)H+P#ck6!C%#!*|G^t4!}B?RnIC0 zI05CW=p53Fd{v}Q7PyS|EC}=X`SjG(8wUK^ov+2X_e1nHzJCYB5wXolOvLu!=L1L4 zwZ!z8DUT0@kdkny6Yv?0BPjTtXf#4}S#xnRaT7KoXUy1M+|SKHL!GQRND#||7wHO= zIDB04z|kI!*8DvEzc==n(JXv_X@UCH#YrF4r*B6ww@-;@pLA*ETTL(RHs#4TZ~G2B zwl_U8gvGv>HM^g5o~Z8on&WUA&pNxb?VUY7Tw+BueB??z)3mqH=&I|GRy=yJTAtmK zglkXF`rfpNcoD_)6U3!xrsR@LC%S1Sisf7iMJPj#7RB_5>M(zDnJJm5As!{AgEp_e z(LtO%AOZ&kcYzp$>cCzVwlX$qy36hZ z_ot0Es3C1iM0Fk5DZM$Ij!An%7Hma?pS1#m{Aa4MS(nlsAhwuslr2y}&HQTYne_n$ zI+L27DJ_1RuDV(kj9EpCT_f2*9%{fd74}cl1Bc3V1`0D6Sp`ZEC>UU0PIRenFfe#SCUdF|BnvezvP_ZSW zjJSi5ya&yXcCC>*t>hVmemh0fcVtAS<}DL>3j!0Qee^#vPGG!!#r1Vk=-3{@nszVA z#h+;31%iy{arZJJrY5ItH#!Ua2St1J%3SfvxcflmqrzHgQEP=1>l;3zBLxLWnsK!9 zOyD(azKJG;C#L=8q9NceLPL=PQXIoc`gQiE4meZ!BnD3KnBGv8R)Cq>!x*k&F=cp8 zLcLuKZwqsdtPwh9jlwPkAxb~aVPhNv4myl@BB+@2LoxmW2->d6tpTYCruH{l*X*mY zg!L4J>6)x==od)e(kOIDqQuDHeFIiox^#0_DdUHR0LBT%4rNCyntofAKFEs0CD8K& zWrV9oPuDl5D#_#K`d4>|&WjiOKp+I!7I6ujKP;R8Q}GkjL?s#NJ{$0?298ipC;CxMqp%|qj><&P}L41{|N-~ouQ4@+uG!tTs@F+&>u zdA9j;Q-BJ%AfS7tj5W>MEDkm|_0oPA){>o5m5kR&wg9`JwW2#H9)D;wyH3;fSqx_N zt`lksI{=?V)!O z5ibe&_Ay(n>t`5iIBY-4$oO7>dJwvMqw?+G^vl#*|b8#g{=@!n@- z#BY~F=gMAtkJMMlz9EL+MyU*tJFsDn%TTWle*?e{si4p--Uvzr8-&C2!9XnUUI#I? z<#m#8fE65SwHkrc`Fy8{^1#%~uIF>)*wI+n%ECKfCw^`-Kl~2)=QHE)5iR5SuX?%8 zh>8l?@L!&8eLb=c@kp4Cku7xE9FCUF}($wwY)4iDn=)9`M!aPmkpN}E(C1rS~dsiq2fP6Ftv4%Bj8DH zz8;Fjsc%jqt^~jaL5ri{%ZCO!?!9|l0T5e!Q!@iiw~>TAe-#8Xfd@lqt_ zF%5C|3jFKXzC)&R2+k)pgTy!6HqEu41kW)L$v!|11>nu|X$#|P+#}Efv5K7nmfk6l zbi&!`8?P6uV2%^6{JmBde)7htXZ%qj_znjh8r<-2CMF-!wmiQm6G(i`qmm6I+YoY3&I zW#NiSUI?u+Y+}x~z}vC%(8;@y0W;5L*U4f!nqG)EmBX4l(`h9bmhub6A$#ocok1|4Mn~SOA#Ia*{V3> z5|lQxTS5d=SC}%*oiXAH27AH z^{JMY`^ttA)#2g4`>93y`!D0PW7)_wyQAZ)m@%{c+jPwJ1_4y&Fco3kFcGuL`{YsWK4r|Kr<|{^e=`C{^3xB# z-M{mz6MzSZ@DQAn%LOcV4TItT%C0EDi(=lKVZ3%Q*ih9l*f!;eDwpOI+A%M{0zuSY z2s3|NN8=Ud6Bv6U7!`IIDaQ)>G)$5k!n#MaQ+9-pG;hL-QMTulI!d5@X4HQ2kg7eR zYkwe-CJ1H1ahd4h^=iRz`T>?L|xB!PMvFFZ}(A&1yH|d z)`{CW_iuGoum^0GTZMbt@u-6)%e21{UA^q$A-N>SV=+)K&_Y-Vsctt!<{_d4wfh>$~!JGj33OMW%06wO7*)OY?rB;X~m9aJ->&Y<0Q~$AfG~EaB>| z&F4`qBr|vki?6u3xt%evNbvk+DJRsWy`ut`c|3B7#}?iGtjUJxgyh#SstPuI8}FDC zG8VrRTR2}M>PQR`f6mup_=gDgmm!S#o@Qk^c4+ zA5^fs-Xppv7j;}6OPBQ?Qn&ars2o(Spt3Gxbx%$cl4#E(7aRq6j`xQ(t7x=D@3Kba zqT~SeYo8vH?YcC=EG1j7UE1Fa<~y;K-&5VIYtPILCNYJF^BvmjF3LcQ2E1h z41OQ$>>+5mD4BJ#yZfn$iIu$d zy|kTGEiP!$F&Yq{XqcnMii_~&C=E4#;6CiuD)bN`>>6=U!@>S4?+U27NxvSqv8(sn zd3+!MWEjO_OW6%pU>6#?nt(hmEhLe3aJ`xAQTg2rW~LDHdb&$~%aZevV94266_3)Mn=FkhD7KYOim0c%iQ1)Cpc z7x{M?55pBX@X%C|#4CeR_cS9~?fscORBU%M)tA9Mf7xylI0#i_!$WgN5-)=_C~0$S zX3_P)+=M z+~l#nmBLYZ>5b~Mb)qOx`PVeE5XMtEbVta@Ru(zyzmBHrJzgx=9MVweX<7V=*oRN( z8rJs6UU0l>ditrfUyGgF)yp$)N!yA8%pwvod?qE%AdSBvagnp@1b?q3<7dKzJxy+S ztb^|*9Sgo0L=+lJrd1dwqogmu(afLj!n`>Y2zEQ*`syKBydXoQ4geUQmBBEf&1?CAonH_fZps;2PaL7JGfleWz|@2N&c zBWy5S2(3$71K)zdh7MAZX%D|@fQpI2@kOHlM5P~k_}^F#=pZxxx^qrm=eC0j|NRs+ zcus*}JUF6>s{ivyUL>&bL>J$wU;O(qP+`vwM@aVbc5i_T1CACBvE%kukJ^MRI?T2r zfzuWt9G^xs5Dp$eA3nrB&6a?clQla9YVnJY6G8BopX*ftSJ&2 zA+k`4s{J#oyVekbLj?w{^oF}D^O;SY2=!lQ+%@Y)!iv)|UAyZc=uKe9|6Ib!Zx$A* zaCf&T1QeM{-VAimZ=k4R`$_~6BL}P#2r~x74ztl!4q8Jw9Qc!yQj+{AZW8=|2O{=L literal 0 HcmV?d00001 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.pptx b/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.pptx new file mode 100644 index 0000000000000000000000000000000000000000..ffeed772cffe5dc7ff922be60c1f9cabe3c44fdb GIT binary patch literal 256206 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⪙+idH>z1mxMn5$Z2II%$0dTA`y?k2dF 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

n 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 {cTUS}cJ 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 Date: Fri, 13 Dec 2024 13:46:32 -0700 Subject: [PATCH 335/395] update diagram --- .../bedrock_org/documentation/bedrock-org.png | Bin 79547 -> 286687 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.png b/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.png index f8e497111a1c37037257ff848144af79d38dc165..dc77896b33b5f627a8948839256033ca0e12c0ad 100644 GIT binary patch literal 286687 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

;-=IEEIJX zI6;K#cROG<){Q_FS}e$n^~*HJenm}Hc{|LK%qxgt=OggF6!eln^%C1CBe7_rocUm*6Hbm*CU&3;Us0 zUmE+*CK+P(O$cu{EE+7#2FD~$1Cp^6fvY!CLV%+LS>*s+fS!6e8e!Kqhn z6$Tyc91x9Rw(&olg6ajnShUNBpMSAcpAicoHEINdIJT=-I#QrW0LCifK{Neltc%{p zOQ2Q()u?HlkYss)NCVOe#A(2^J$N1F`u8lxNMe>`;<+b-qAs8|B+7t*>z+wQqTwww zInx8m^r0NCGCE@ZqxzeRW0o4vKY<|c>eut-x(-<(q7YT03Ff7V+vKVxOwO%(ZIizo zDj!l16xTvCYI$(LS!vKtM6iAPgd+ExJ+gZ3oO)GLjiJV(+vbHh31v5smvp3TlZ`q2 zG(to)D%O44_ou0s^zf5UK-(z}_2b(+rHIuaU(=#FahjfQqJykxwX9r%wyyAkVNL=> z@W3=^z}?`vh&so9ZQT%@O$jg-_CJripBokWE_tiCwWQ!m4`F5ug5Xhwb5;%ntUGZ7M zye-YLCl=vGY=vtirq41ei=uhop8R;P9a$+Iu}S{gduUJ$zHKLI2j`rBE`JDC1tHg=R(qqTK>GLY-6M2yFrFe zZYe8}g!^A$o16Hz@tqO|=uS1N^{B`Xvgz5$qrWbXVJJJ*J-?!|Is8d^6spLBX!@YY z`|PfrR*3RzGS9O5DQ6o-*;j>~F>+Enl4s&#T2cjd*BZ1{wB z)L**>&z_h@d`tLZyX<_Qrf)X|BLb|bVt!_~@9O$n8Xrts zwR?A1jL~g(c5D%1-mm^JF<$SDzPaJWb+rcjQT3Rom^tYW2M7VF-AjUIAd0NE-RN(K z9fyhR{DPRE5!Cx@;SI%;AJ}V>pspyaVeI<<-w6j)aWlfL-JkET*b0-4tVH@KJXQhb z3UUnHaZiK2FrW8L!Qh!^)ro0MxFh!_F#n#^N&e@hVXyxZa%ngeT zxKW=)yFh*xAqO-h%-(itKM+U4^>}DH5h4+jK*vozTR?r`Kr(4vykM42%liClS7Qas zGm(mGlnWOt$}5Jw=`XJ-fFAz)My0g>_sTCW}6?_{BR zlT9|A8T_Kr(AsNz6M;<5T%$^=Xx5?X=i{s)tWM9zYU(!+H~?!+0w~9Yp)o;)ifTbEkE_~8IN_JCE>o= zEcSJnX5&Rgaq-VFM4+zzB`OZZFVdL9vfTQ5bqbrznXC%!?UD^%@;(y2JmV=f&YxKawOJ%QMepqiGA>z;tf5H_c%m%>QE&rhjucUTzI zYfkj{=Y!EMS<5)PeFV3Y_wMdA$jBq2)3b?qRMl_xzL!mT$isjCC9WsZ!G7z-RXxkr zr~Lq?cm(twOvDe}=53+9wRq91X1asGVBBacXSs;RO`bm_XZ!PL}Z>>M1Y z6#aYo_0zwWH^&$vKHc7+-=Ywi%Kv?ohfh~P-jroa-+SCI`tWTJ&-fA-7=>|aYH3-l zLB`>s%&GJw6j|CISsGM=F8nm;TD+9iyoXIQ;g2f$_gBFhxE$1BAtXJl5be6#xYNY` z|MTGvNMA{umWjBVw%P5XKV8SKYcWR!>cO8D0sULl?X1+Zjoih5b@tr zBT!ibyfyEmJwi(XXkVz*-w(?oU|qV8?oCxA3p&;G^lYvNZMPO|vLH5uyAgUp=Wqwt z1zN#ZsH1qm>iqrmmt=?-Cd^BY;Na0b55?*+j4Ldit$xZIk=@Bzb&q8dcTQ1_8~ag0eDsJMN0IvP7VMaBx`U z9AC3{W))Xw++Hr z5yLdxYoEsn{+MXV2*_+7G^R7KZI(4}zhs0c5m}4QO-D_eU-=)H2lFHlTT=!=#md;c zTC0V(UB8fpRPX&T(fjv?(pX4O`*d5!G&8jmw5qg^o1MI`QpuWDUyRLZ+G~C5HFSGl z!wlbym$U)EWYjbvKVRdw*|oF*Aar36$6ya-?RpwyKH=Yfp}of&K@5o>a<1suexgQJ z8+8!UriZm{I~PNKH7H)j_9k2Ix7I*LNcQwDZF^b{6vyUM!DgKqMEUU%PBO8JNI^1Mt<(YNEkgVwN= z!)MY@=Lwz!{CgVX0AUp{7CW?m)9w(mo|Mx%LgotDKU`XXA_ue6KCD~-*TZ%8JYOaI z8to51--mAzj8i=}!%<`V0>O(Lqn95a7NzGg;*Cv2im7;Y{3xNQGFX3)VMFBK9o@st zWnF(_q2bxI5nYc^t4Y$lT=Oj{yqe(e=4ha=t*^^EI8>MV)NtT`c!TtGU}ZPPQ4s#} zKYWn-%0Jc7lq#u>^UFoe(LkP2G|(9m_EvbB@-X$zQX4<{cZK4FQ32af{fPThGw}a4 zOf?!>g6708CPGOO!B}Z2%eX_qZ9AW`{_%by@02V2@|KPys;mCUn)R_H! zX-$xS2fDiQh$k85YU~=xZl31L{=jxOijoA4DY6qjtm(~yFF*<7$emagtNh{AfHcRm%|$_> zHy_EUuW29d;0GJ{@oe|AW%8|7!m`NY)aL$EWApFqf&$#B5gbPH(>N?-_4O%G*dGOZ z!T(8`70DHE#5dcJyZQCP7dvhZ+;`+GC{n{t;R0dXYvpxeNZI`ZcWFmKW6>WG+s2V9 z2!;YnUDXF$wsGY4%Qgx6p6k3Fpj=vH+AliH;{NFiT}8DRWIC1{I@pOiBTf^8@1C+O zf>E*|clT30;DR=}3++VeU3<`n83&Gq)+j)9e%2t=#f106cj})h9z~Age!N|sm-j+I zHGz0^b$$Bp(yxU1hX*~2& zW0_}aQ81e!y?N{ho};_hcGKh-kXMYKkUIT%l9;eI(Z-p+lzg=6IL64g)?P{{`D=^$ zlN~;tBH(;j|Cy9Ve#~Y$|E|=QR=w@=`>qtx#T2dJ7e!124~Iqg2H&4XPDUpD>C>k@ z`!9KbLkB|qU^Km{&ldP5Ko5S)g^|&xdtYVF9gA|d+SikpusxoA0`AQdVB&`4j%3BJ za*yZ@!B*@HmR|8CvAX6LDhp2Q(-3Fv=t8RlaLx2R&Ol9uj`gPbG+{-(KVH{%l!)l- z!5n1J0}j6!kkH+FxtDD{&rk$v_V?mp84P9yW?8L;;1gy(AK~}B=8S(A6J5ri%bR)8 z^;+~zGEtGbGFJ6S>fgou`)9Kh!b++ZWmf{i|88p!$S@lxv%^ zlilQkd0x~m6$0#ZVzzDX6VF$+^d=Wgz68yN5*70=ZaNRLRZ28b@oO%C?4_oaMO!N- zBv0bQgKBPwAj-z&wsPnydJ2;+qbl!~<|nO=!Xv!Q$2G=Cw+MUVQqsNSpXzgq#%tHL zGF``JcWC4fXOP4uq~kP~~XBpKn!Dv!W$upXRT z02=PV*jS20B#}g}g{urwxQsuMX}HG{4+I|+&_nG{LQRftbU8SjUsc7LZ+NgG!QIUr z>|B-gI&=e1L!d_YV5I_`5)WcT@%Y5bDN9pbYst3HC7S7II>P7Z@XV@myEi-S6}zor zvbW8d(z9h;9Y_LxIE8^e?RH`nvI1D5iGnQhXahT;BV{jXD4q6lWhMwFYDN?K)!nQK z{4QBzMnGka*dX;cN2pmw>_B|%*|06)oATm6!7sC3vWuUly?Lcm!R?gm*;vZ1J%L9v z>vP}4bhQ&B*Eg`8qdrhVKB?l{!%>OYHeq_U=6h7&^tjk{b}Q}bOd$B%r;^Igbqeer z(u&t+gYt6mWnZ826jo63EdJW#<@mSPxr}h%7@{6$_DJi7ISEgso}%3fw|5boS+F$F zwUQ7Hw`|5)2seCFP-x%PYi?g)*|=#C0v;>|#(cW=@d)Rh%!2`x25BN4g5sAik->e& z%49mt)#F~x;%V)G`FmuY(k11$s>CQM)^5uJ(uKx%% z3>;n&*-;>uD_mQxg^k%kSBRK}d)SLbwUR_yc^7{}CS5ul)f%1K=-N|WVSciy#2?Jo zSEk*!6L{gK$EELS;XYEor6wead_5@CV09i>NO6Xm1nJpWVwp10bw8xvrs-dm`+|dO zeK%eoRHyy4`&9YXXXdiB+xwp_JdgrzwGcacwxTk)QwSkb@PF1{9F2qu%a$-QBI1O^ zuy-)k53h-QClS6jqOzb@uZpuylhukCEY(>G85-o=(;|yakor`xR;G6xcxL0vR-b=t z!B8$~Em2-va#hv8vF~sLZN%6SG{#*Hbey?|6lx8LLOP#4s!RIS&sPeg96pm3TSZqh zPmw46DRyOAhygC!^Yv&U>NSjYxzq|W`@Ysb6m1ka6*w}dMTaW)hWs$X*vB(}NF%9k zCSN4YY970}ynKkk<~l(8vb09H#bs~6wVcX)Rv4CdxaIVC7?+T8XTqm{4c+V#n)kJV zaERCDeuRo$S}P7E<2UWmnB2L0X%25|%6Tg4f0h>ufRLR^+s0YKL>(_$ny0PYBm092 zNEYiob=r2nt{P??4KL&pM{4FqkH~1)5t|K`IrzL3sPjo}Yk-o+E5P~-{3>5J9hHfQ z1cfT@!cFhNfPes+=-Be$Gkg}GAAjx($eK5*h= z(z^xwHKUL1>P;b=1-jDST1-W0@B??SA9Z5AT-?dk=?Q<<-x!FcUcJD~tl_Q@OiO`v z3=h`_Fu#Xe)x!N1!*nkSbmmnZVPaqDK1NDPO>wt;ZnsBdomIdOgRDy>trX*>mP?|d)UGnbF{N0Vx#w4A8)eD-jgj3wue zBXjfx)%V<8Yps>XxR7N0ED5tzl1Tf!DlX~>2;JG|&Nv?kpa-K4|ItHF9&=W|P@U`fMT z%&y69_J2?>K8Eg`tO-_%*UVu` zG8!zjV>aU?D(>LalYQ*m@^lNXE@`IqaN4C>nBgY;#g*BmsnH+Rd#qD#Qp@4UOF1L- z&i9~+jXl@JVMmoV)1x$T?t@#>zzknxmnJt)tqndavSC^v>LEOa`Uv2rfvT8%VWLZ~ zEZ0c*0jrvn`X@RDF$C+vNtWjic$i?pRY1YUOM#>shl) zj1g&F#S9hGlN_kSZ9}}+mfd&hu3egYjqUo%Bfm2E8^d@KXC)09q8Bl<$iC96VJAE9 z(~CSoXBqZ|>>a$dI%J36aB*V9V6DJqtar|M0F+$0tzvQA-C%xF&|QK1gD6r)GkUqa zR5OA_y&|}hl4XWLbDFC2$TAMUQIg&W=r;nloZGiqYxovdr-I)RnJsy_eg(SBOu z3PgBM+?xK2Wv9D8y|vTVt@urM`}v4@)4m)54PPmfW>x6O{XaA8kKFavA4zK(ulCUc z_(DUXGi+Z~3Cs{9KpiPyK6Q$^!!&R#ZE?LF$sSjHO92(rk)aBm3Y#l6(<|Mad>77Z zkRu>ph-FC-CI84wihqpNK8D-mE&3Q++)%80Sx^0|sZeRLS`IRSR(Y~t?UZ_*XG<08 zr5vU@y#qD>>ax8r?^pWPua&LX{hgn8wafcgGKAt>A4d;}GdZgk=Bz@em|>2G z4OUbbDd-2!?mEvHnxvaTocZ+Nz(=?#VLAu?@a0+F#IWsfb`5bCMhv-lbiD^ zC^DI^+u`8^{zl$edHQ|VBcK85Flb-D;4t=rCWF!Rk9sU|()Xdzt*fJQ!cSHVMXqn3 zEp~5QpPGHaN$gKf8N_6$pRS&zy)39{zDjab`@o6=RsCe_k+$}dKMgWXsj>I;P}k&M zSuyLEX)FG`;OUUGIs}EE(b8`;-6_9*G@VhdN!(c$m_vO!G4CzP0O3cG}pt;?uvg+hW7}Jq!lt>t9vm5)?8Cbij)aj&1t(y*% z4>erwDq8>8En8Gu_{19eyTe6c6j3LM%72fFrtwsb|95t$!Sg)vx7Oq|jhfuf@^uA! zVI3@w@3y*cX`9_LDmxBYbCB}_gl%mro)Qyd&NHwB5gc&AZgkUgM&Y=f`-2%ROS9zg zPxuBQpmXD~jy>8LL(d%Qv!W1ZTis$+-@uJ<@YCwOpREk81mJ7kdAdPst3tR50nPBy zZTlC;gb&D6+gyZ4pK22e#Y@^5ehJs%&sil&uP4Spxm*EeCtD4xRHG~9UDq+r2V-dg zrd3yl?a^8;7{9ok@;2g1@BK-R4$QDrlUp*R<6^nMY6y-!IbCq?j zU{vNLNyUU08EMd{5{2ThW5Dn@N#>0n_qg&?j;ZSVm9(I*tdE5jycoBWSk2qEyrUYD z59+5%vLgc)+32%EH0!`Z{Z{oy*|58ge#KKa-- zs}^8*a?_7$S`gv#I3?Ca)?QT9fZB;2(CI|3b)m-IoLuLvmUpP3Yb`#5L(XbPyZ{ng z&Fkm11{ngXlH9F7>txzaPa9XzQt+)x;`7|2)UY~S7g;a8{^;L6o+Tcm024DXvgP+O z4x6Q2;eRZjH@aKFByi5Yw3}I{u37thTcgwD_uh*46G{bD$ylq*jAL)wBOhex`Hp>| z1__O})p)j}t1pB#vb~9n8c`n)h-P;NZ`b5Du*L;vTxjqA@kjymG=?sf9~%9?QbdlE z_8P&%c40=u9+E%OXv2#7v>_oORIm`-CqJL}QRNdvANAQb8#b0Lxydt34rgBfI^U84 z;IYmmW(zI$pA6cZy+cj6tdg?tSEltoIB(1j^0wG$l(1EnbPn@&4Nu#MS+4d<%Lvdk z&WHu3QasAa<$e6FKP9IqH87q>arB>AYB)n6{2aQQ6TK-xbDU6-K17<(g*wwnjNKna za#_WFH3uuoF%>WP-iN0AGr+m;kn~=g?|l^@vIpS~tW15hz!dSWOhvh6N&%wUKe+ZIJ3kS~~X zXk~{_(Jp?a?z=7N-Qm7Zjl9;EXF0#4Wxvg+s#6R3Ih5WwO(IStE}Dp3FIc=8PLSp9 z%KP=fxbZ_fh}P$RM&am>eymHTB8)W?thUN!DP3PVNwm1ud^;F@G`ocX>+7VB_*7<8 zqok+P{O3b)#kpC@yyAV61a$B0gYiIcZk>$Q_3gpsXpR0TrYNB}IKj{?j2|r(>fGU& z<*@|edrkK>0QoPDR%d4KI3A%bR)HGQPL{I2N1aprMf#rPO^iYpspVLQ0dt3kNy^}g z;CHld9mgx%biFfW+~j}<5T^q|0si;B-DCUS8+_q0!N+ueNklRK7ipBNM&Co(~Cxos4XfdeFro53C5W0xIy3@($%IA>s(yy_3&rdbx{afdY zvX+}bY5Uyc_yYl<&yyj>hkgvm*@ioRns8>bb>u03-of;}E)B;hy&(-6Pg@MFgqn`y ze?ILE3>ka?g2G7T__@8BVeZD~FIECW7)8h;O<8rmcxd_9$3Fj2pXTtHYN7S zz$+88XXmkI*_Q%Gm3r(p!5h(U>|1p%(Zdv-x!<cGQ+FF{YhO0y>$3T(X=NxpPb}_R$eo)lDH{iY@6@z%7fOsrX9#)}dOo#WWp-Zq`jjj#dWmYa zQvnmO&9dtK)^Ya*s;6K)|6(6;YQ$2MXB6J!f#B!+`2FHYFXIM~RTc1d*%d0Z9k z1IsHGhT>N;+QQwnj|mueRwk|>+)hai34Xrr!hUpqm#aaJs=XL|cE$!w)@`}1CBLuj zLFh)>82w46Q>->be!;kYR%U-caeSc>o`e^z&l5gJIRFN->Y3v3mc74rs1xm)?5&xmxm1L^^%HlKUyk$ zK|TU?d!1byVPey1aNEQYEI|McEgQ@{RWg2iuWt#J6YSr&=H4OnXI6>)K{`gc*CuEz z8H%Njgt<5$I61zQy!+0M^Abbdp3wcAm~KGWY&29gcj#Qb0xv>;p&}%8BkAI#98T#crGAx6Pt)_qTVW3xVae!6@F@X3LjA zbh5v40S`?GBS-Rq*rX!ln?Xm7o>kK3>B;pqEW;^uxC79)!(*#d-vd>qvJI$ye27bw zk{xx19yy&Kq!bK|17(-2R0{O!kk(N>gbUiYQq1FG7uD1ZH^83Dr zDV0)6N+hNG(%k~m(p{JCPHE{B=@bwUxpa4TNp}k<-AKRB1^xWK^UnNd?l7Y=<8z;L z&OUpuz1G@ohY@S<&U7w$uoGbI%WV~y#+FXDId0+h3JYH#R~yCK8hXNZ()4TYI2IAv zuE-s6Y?vGpoi#NmHvI|3l^7bxaWQ+heKPg_s?p-4sYt@8!l9*B6v-llH44}7h^6Em z_?dB^PA2izNgeyNNSTlOnQ*2Fk&;My%f~VWyc3Dw%g5^04?vJ{5OdKJ>@AN{&0uYI zUd??hLp?KzSE|3BBrKOkqL3?S!4$f4T2#)L<~dNJh$RtT5xJr9^w&Ramq@;fgbclC z9}ZMpw539+2#`W!lp(P$e|1>gQDUqbjdH&jf#pQxru&c4%*r zKz$%+8xLg|sD==1OEt!Q=xrTzKJ#{Zyd%!!udgBTCRmWLu`n(#nq+cU^@znOgv6IB zv`A^#-mZ07Pg(VVYgHjUfRoUP+};6pmyAKOI|v2LLuZdg4$$T zmS|zqzoNQq=t)l9F#q4(--!(EMU<54XliadmmrQtJSLtbrZAS5-unpl`*lxa$kM58E5&z~^ zd7;?+jYq$iUJAL-2hSSFvZG8el9`QH9d7Xp0~(jy2HbL{Qi?}8~zwfVFE|G zb?1B)TXsc1Ph~!bihA^R<^SxB&Rot8{uJ*LTNA}1GTt@_*;^UyV8V?^%om(tDxp1K zb8D;0Y8!v1dzjACn14LRqFg?~CA0jFs$i#RUEwO7b9qopaM-LVBW~fYp2qYXTg7?l zTqdK1|B7S%Pz{)&0pJDb_Hacl@TsY(9p{REUQtK2d8H#D<*k?9B5zrIU3Q6OuH9i| zOGAj^?3B#LI0X0Mc(mEvr#$mQU9PSB8HmTeEd7oWwzIeP$5dJQ4#Rl0D^N$JiGUl& zj$}hj8nIVmQE}(r0B6;pjJ`OnJjjADzNj@zVl1)!RiqAyt>80hs$u%RDP2tr71~bL zbVUzA%0|cDr0&HNepaQQI~BowFRQ*)=a;{+248kb4;vECNIqxYp9UB80T(K-Y(3rB}T4n6UMym7kncjjmCajtP~ z+ZqUsxg^Qu&n8iFcqdN$S82s(hH$Iht3Z3jb1FqfJZJ}y2_xI4)Hvf;9F|&KLGNcL zP0_~YVb6A9L0!QG)se0^o|mt&$5!g@lY^|%2Wto2TZm`~bvZW%@0j#1nAx_{_x5g` zRWqicIAp6e+#f;>HMM-7`}!kZ6rTVYLBE?@hg6Bmv+47fTyx^xM=?UUh^K0{V#$?z zz7=%MsVZp&;In3w35jL%@K46uyn9-jeBtjpM~)NAC$oQi6=i=OYgKl>owbDQDCNa6 zX{kip#t+-cR;icMt*+dtRHydM;XCGWqf*etVcFA$-BZ8gnPV1r;W}47)p_z3WmUBn zMcXfJRXOY$Z+WOz`!sPtUE8m;;#!=dlb&*7$Q7uGm+MBum?6Jxf(zWw$rQK+|S?~){ zzcXn<0?vMDH8kPqkHms#yr&4SDwwCdv z1MT474!55=nUEiS!ah!q6cAEI5C%_Rt{wen(i~H%^WyN%(u?2Kty;!xil5yU7i6iN zD(RI%mTR)zvf0_$w<)AYT$lIx6vT8YRt+fT*tTW}R_WDWzVXcemb8(vg^t!zC(Ecn z`kL}Zf1Fo;!$2@^RDVLb#Uh(-&|W&}*th*>k9{X!PlV=EASpDB))AbvlwDX{zLm@2 z^wEl7Hn}ATa(-IOT16#f0O9{!y4g9s#tHdWKfz@vmCoB7-B@=XQ+#ofSWSBYzusi1 zl=#Y#noqqBZKh!Db39#QaOnq!FmTY0B=gIiT(O+>?Qq|B4e>iI3uRt|1+Hko8w|K| zgOv5FSb^SzMZRxt_sQOb`81SrPUDI!>3vFKIch`LQPqCs)${dOJ!iKNlK4>UZAG3| zbAPo>`B2}p4tJwm^<)3@tO>{zxk$B`KtGpLvM#`veM)~r1jDiWHHr3FDU#}r%9mxK zu^+0s)F>D6tb{W*){&+`EM9RfE|2YGC?drH^mpgf4@02P`~umEJ<~5e!v4PnC}EVl zI?kB5*Q*okt~kXU`mR?Se~#A26tvz@y_&c%ihtH7)AHmumzQVWEo?@^G#Vk#PwcV} zd9PMN4WgRdC6i-gv*$axql%1o*Qkp%KDX@TRDAvkd5>T2;Fud%u!SK{&;PwJ-AOU^ z`m_CZU3;WE{1d~>cYf8j{ntC0)BFg%Jr`S_GfP^%y)REb;|U55{UP{_=6K@|MF*B8w|bLR3GaX^D4d^wkejNXe%F$ZP%HFd6qa0p46 zR)6$>&iw+J;N=Y$F&$M0ybVg`dtu`mjZQ4bA#*g#q6-ecjv`G?)cL`os(B~67jnh*yG*L57jHIE%_zZ0ZSDdsxYt8+iy_@ zK|9?|Z=yOrk!WIXM#p5f)XH++?_YH|-2Jq|Mz2j22(~jF!=mfo$V2fG?AXEnoY_>X z;&XxjQQi0F~n(TN1R178;-i!{|N6Wa4>}&+(aiG(dV3;*CG2Xg<)UsnL|92`NT< zrb!=@=KDb|RI|iOkyOI+)kbo>acuSioUVg~k1zxhtxR9KOmh(=sotXJ;c@RG8%N_B z+2Us0fRpm{Tq+*TRdpc1c6V$8UFd=qdj$uWHz(WPe%=`BWn8H_DAMa`JOBRjc0PlL zT4{a;$8=x<>Fh4zeG`Rzs6j3Pt@AjL$`Fj*Q%Sh8cE3q%($7G1}v@)f|pyt&nmC`69omRCF1Gc4Pu;luIAj%UD3sG zw~8Ewe=q3)>N+knLG^MCn`B<8vVH|?j{eNV{m)evsFLgP*L?+|^9`e@Ux^b){lq90 z6ck8KwnpTQ5Yl*&$Wi|zw489bBfKOhtztTP3 zkL&jlcMj9e{qEU(v0Ic|(;hqyqR;UOMe7Kfpx`D}TV^KKnDUN0hlYyE#%+V4w|M=) zv$RnhjvLvwWxP(-fnJ4XM-_iAlj7f~chgZYfn_d|BAEs_Z>JK5&|r^C|6rSA@j*7+ z7TDn&DUB^hHw`G1Cnwm=k5CeHXVNg&Vs+Aa)i*O!)}3Zkga7COm6*)F4>&(c>xr<* zQMs4Yp5VLNv7<)V070+76Y)L`@LYq8m?*l1&DA`QPETjE$Q}FEi}<@HKB>E3X{h-5 zhFR8&KH9Vs^SU*HW$!%-xXhBmJuc;_LTu5~^qZ>HkZd}xszc2)Ftcv&2`YjIUQzrc z<-ijwrCJ7>vO}-3-?kCNHFSCEF6J?`>gqTvl=_7_d*@SI7LCZN(K*KhHD7hO1k_Fy zqD;1~XdDq>QXzgbA&=_AiB5!8jR)?j=0XuXUT>LDLpSOfs; zwLw$(F?lt0gfK3+bB#&jbAVQVvDGgX@}alq0}QuN)TVl26nPldW#~ld^>1% zx!!W#Pm4{Di{$?jv$Mi-gfM&klh=K+EDzc~UNDVc?{bneY4E+`M3SsjRnlY%-%fK) znEm-Oz>Qd@d5``(KKf!{D_%vrI&7ZZ%ZTZ6kNxuoT7Rt7Vul67Qu1Znq(PfGeC7N* zb+=?T;ZT*9dXH?rvT|({Ur$;~ihPL_8AVku+{`gO@6#I-ok|Ihok#`K(m_$FvP$@7 zS04j^m(a`OZTPuB6VV(~VUR+dP3P@x%BvdosN^f%HQA|Z8A>t!&#?>`MZ3=CYoM+md)IpRy49Pguk6WW&6UFkkp{(&yj_&WY*X}h1e12V-`J+<3}ot5 z`Lc?-^)>Q15DlzH`~$XG(&Aqb@%{4h#g`G>H2A857eJkmoLmqHYV3-BzLB=y#6NPi z@^XFktomSY1&|5|%G`W7Jn_p!`TXYuV>S~F3<^qoJ9zM6XT+@ZqNmc2|88oi{)-h`k>Zb^g8ax4 zT`HC5X6FgJ5PwfxmC`5pVL@S16afBt|7#57v2Y#ry=E_t?&IifMO@|~hf2>J7HnF;_o8KGjP7$R5eeA2^k+=+~Hn7ssZ{#FBj9~L# zkk&hyzi4g)1QPd-KIK@v)1vjp=kWA@GB>$}Iz@n>U@e?K*X`vZbsG;eJ=*B#@zup` zu|Rh7lMo+P@6b^E+M1!C)>|MA1nO8I6F=JLHU${N==TV_Xsh*t;1F#KCA1~ERu#j~ z9?bc=>SEfK?^UTP4(<>|7X9tr16__5x`zkv=zBZ}r%N@6 zWm29cxS1jOp=x<~HF?{a&(&0$^LOJx_EJFxwwNW?-U+?Y?J<|Ltr>#EHJ&LoFc@TT{x)?M-sWG7XCNq3=4W&_cxdpF!{JG)<6wWd`saBSW-}j zSs2>7)T(~#C9K()J?JT5|K;ALF_8XQDP~7E0M-K85)|doghTI@@lSnAZkbWn{fGbw z=>spPz|Lj_1(G`~9av$#yrfms7RNP}jlhfumVIuQrC!p>vSB#7BZaXcBYWW27ZTTL`j{Wlj5Hh}F z)Hm9+eOsDSImPK?dFcA&(yjn;#|Y85x8rFRs*exk%zuw}Y5P5%<55(~+nC>DO;5i6 z{^W@nLhonae?0UxSlXp;LCL}Bc!j8ctWd5Cg_joSow9#bQqg9?avh4>%7Etl&<}|N zLInC#1IV{;l4zYj-`XheCPV+;FwxhYNZ&^e-IJANEn&{mlDSIF_S+8UWc*o@2W>um zUkW+vy%vbIYPtiY7${O&#r^KnNf25GmLj&w9(kXFH5_TOBs=?AnHk5{zdj z%~>XfFTP9yZf->h*8u+EZ}{W zcm=+eOY&~Zw^@`s&nK#4kwPd65D_B(9 zcWth03y7_m)c$)as;d3K^tdy39i4Oz+eXXlkQkD`a)4NX#DM(HGt260_OCGoj-G16 zI3dRYBzMma1NU3O;IA?oVUtrelEfR^4vh}7NEdO5X&gc<<9z$%hE8FxUE;7>55Vj7 zoj14P$D|TgZcfQKpwrzX1j8WTGLjr29M+l$cnZI7r08L$2w7%t7KG}rtXlb2p1WxO zLK`71Z39}A&Q-6u?1cLJ`%i<`0p0J;76y}vkHCzL=1zJH0TUk6R@64bnRAt*lCw+{ zkpOit-MREV7JIqRy|+d~j*P`5*AjuVG3Rwy%eJxW$VE5)`bF%YhL$u8=$;pYhc!d9 zD?_XaAHA#T2ILF|zGs-30umimabOZZ1hSYkr`W;Nls9kHWaGLx(+b0rh6^)}Ur_W(1Ak|Ds?9qR?ybO_^JLP8tdT41PmQT8_-) z2Uy30Rr(iybS*eS4ADEr#>c&?_2>5+iE_f6hXIF}#}Eq5cK|MA_~{ysmkyPL~J%#Iu3FG8Eqs3*V^8-@xA3k$1zOLSwxM**UNFV{fIFY;_3x?D!k2AO z>9W4Rq5~uut8%~Kam;@`5>X$Y4l@~0lhmq+We0kZ`CxLnVoGYmF)%O?Y6fjkF?|Nm zf&(0q8}GO6MD2Kxu83T`k03-tevebc&&*Fr2X#QZryy=cF4x$l)6Prczs-;sUrJy0 zE}*adDTo*@0<`%|hPvXB5Sz6dYkf%y=U$Njzs^ZRF23= zysN;;B=%uENRLRSei1Mh8O3Y9nW0^AFvBF;GSif)<3R}XxHZ+4w6Cf4@Qz$tXd*7( zV71=>tWVP2C|PdwkV44WpHoB7pWdcvS zgt+3Gfl^7TcK@~+;Zl~I=v)>o8T9;8kf26w>6@UI>xIm!xz0WgXaZ>bRB3gu z_e4vpsKmeKb5rOa7&voTX@%wf@Tc@OuZxWCzQazsT5X|Q4n6H*ryR|!HmOt23^!{6 z@S+|+hfw{=8dY2KOjX7R5qJ5#u!~Nbl#DaBHDCt3iPzm3%nv#Pt?5I16AvG465lh7 z2ez65iK8E91N5l)0MQ_3aA^>4i$@;GND_UZK7}O0fECBwB--Vu5k9phvzUWO$Q=;r zyl&WfULS@GgCG9i$*IKr!rUD#qK)MXV+_|IF)jw8e3}xS$J<^8{K}s7SRanIthF*hHT#n0-$@@Hh! zPO6l#X};Y2(oj)tX+7^@m1+AU-C5yi!T$D1YHA;%qs@6=^M6r81bT?gxSmdgd4ceT zyKIHv4M|%NN<(UvnOXt^8if+Wg#E$n|6;J;M-muP)YP-LRAFEw`yK9gn39M<%n+(= zaph^N4AbbYA1Kf!ai6M&^i0+FqPem#Vs|V?H+hADUC8+UhEl&75+Dk1T)*t=Dj86U z4BLm;;y>g0@{Gt$$fj-sCz;3jJ4CN#$(9{~zIio>;3qInHx?LbWahI?rwWMy%=MI} zigk8sGU{Lw!~Q4M0`w8AQe-2y+WtRa!jm->;2i&=fY~XE`dA92e~Rkrcwc;gk#g zL87lSuAi8i3Tny0+iP35;t_vFOxN+k=qMbGR>=&>iM<-HnR!JRMGW+{va<5UGbdma zSuZew`Rfxl1l-~N{@4-k^9n^xP1-jqgSA46_V)H9UFv``kwgsPu)*c%ECXhd)PiL{ z)o)(yMi&g##Q8~L`3jj3QLks&s7-r(A`-lQh)$9~QYBYNZNeXQI+NXW4~Qrb-UHiZJBwrN6=h&0M^ka&~UJrGkMXw>=%%=!k%J`U#^9l}Y`tut4~a z5N^lKUcd~OoQg^U5PI^aZLPEgG1&5v^xQZ)nQ%tgX}HD z7*6NX>(#)?&l%_?9qo(JP7;wkQ%=crZqk7Pz+mLaunqb~f@sg4t%prj685?2o1l(1 zM{~SKZG?hF4QP{3KFZ-A_9dPK^#P<<#BU$Ip%50@#&QZAb$Ss@3bJko0+)H0B`sT! zrOAsdW}Iy_g!zZ}MGYm@bP*Wp?an>OK+7?JkvE(OTCQUc#>9X@ZZ)~*Y9KD0MgLW4 zor$>?YUZ{vo%8KF@ds)R)1gE2!fA;1ZxHVYRWucJQP3ITo;-Q7YLT)B1^I<~5rt4Y zp8BC$TGck@3E{9bS^=#d5;;I%Kgbx{)pshw^ZZR%{G;fYXslXvR6Q2634pr>@(ZxG zA>yI$OR0Hvm$TO#!fr%E`JJB+Ih=6^c=aE5Cbmi!qItXpEz|KmavDR_0BZf(*|39d zY-%c<>MD)4B)aV`FdnezB@T`hkQsis9MDTRK0fwsn6F;3(5dz~d)32pesddrS5mX^ zWv8V$`q}dDP}j|-)(1}a<5`dt0vc49hk1_u`*h_^Q|?`ZA#*N`0d$> zd_G`c>s7cIP^f9U%o_6ytA80!pUSBb`codR=Qjmhnbt!yG>YD?pO)Z?1{|QP@%TY6 zK_H1quHvNcl|e0cOJ~}83#^kTwCj1&KznZT<1z&7Wg}hVu>j*DF#e17)AzwXD4A{e z)v_AeZYidZi<-->HwN5$(F)|JIQrjn7aYvbvml<1n!H{AE|#3QIKfO2zbrVF2WfeW9g)obk;&>v!gB zO8BmKD_DTBU3&sYvC@p6fN2XY((By(9Vb)DCBMfj2gGXMi5+jzV(jA-+29oUr<9R* z5G5E@lI@HY=^Fs2{sm$yTup7Q`Y+Woc*#ZpM?sHAfb74F6<_=kUU;HJV*X7_ zQ~VhV+5EJ{&gaF+;|b4=@| zd#=U;mrf;>_(}bS5_vvq(;b+}(|2=u!b>}rYdJeR8=IU=^X$dzn7lm7I$%n4C3Use zwM#OmZHCoieU{sqbIeJdK= zL$Xcux5aY|NtMxE8(oP2LLIa;06}jvIU`0n$C@y({IZ?RU?><+?Db=QpX04so&CK8 ze$CfhuC43Vq8+Zf=|9$Fbg;_fE`p>=OgHT>cz46qCu!}-cGg0xt8tWqtfYN_MiXA7 zCLr)@Xh71EPH%aJ9^mhsRRPkvB^E8TiT>yWtMf3$&32HA$x8$2i|Lg@i92II`EFD1C zHU%IE+oEt;6yfW}zY2SFJiqD|b-#F18q5%UxU6a`H(i_GKZmWc~-z{E?gr+shFLh}1qx+EZ@Ta%gI5K}Q zVyW5i(k{Du0J__4j9l@bM!i3pt=nhno7l_0s=VA8f1_ekXBNkIJXvroZT#Cg%)v9H z(m*eg@4C(VV21P+{(CF+7SzilAPHeFwBD!TK0aZRiQyIG6wjk|jg*X}y{Ev| zV)$_yc@jL(BqbR@9W+5%bxHJWj%WZHBZVq4T2t2MB=}W-jB-?);h_Nmr544H-dhxy zXCL^xQyJIpzRF>vpD}VaDT#xP?axGE$pt;_Cqa)>vrJ2+Jel8z3tNUeeSqfd3}fL< ziqQi3tHiM08m*Azayid8vBIdVI7Txa9#%hUZ1)wlhEDt=5y7 zm`FuIK>?&Uw9YQ#57!=gZ>caIP*Fkp%!F|m=d2O|ZWA$VL&!^c|JY z`s9m47L6+Y-n=GyH`64!+I&@2{)=TQq=eggDLw5zD*z7FAk zJ`Z$Tyti&B-}Z1ngT356y^AfHbKB;RmYkBV<88bMLy8506@5J8Z(gCN=kF35^yczL zc&`uAp9fcM;P&e{Y5LT?4^^U!O!`C`prX~{ZXJx%dRJ|gx!~T1=Q!KIqyVn>cF}55 z3Ob8Ty(^RZ9>v+0SN_VjZukXyc;sK2q5VUmSL!d zk}K||gr2+X6yJSV)w_c-j4GKR_NN*oBFXldW^;aL>SxZ=tmjFv{r|{}F+%NFUxX44 ziDBVL{6a8FeG)4(gTeaUhCp(|bksWdp}j7ctNMdwpb%ciQ#zJ6Nr4s1`<3a%+OKq3 zyuN%`jqr>8y}nfXwt7#nI5mU2xkT+Hs~2hU3?y_G&H!~vLuQqZ;aO8#%b1gs}A_^$UO=`v6~W(H>se6+;`v_&|jp;Qbuj zh=I!|dXUL-lSaD~Dj*^128>z8BHt>$9sqRjJHrHdJ+QErnGUIchOeXw3QjtY`%_~W zrqlQ(W35!*Uu%NBv)0NZ<28N9BGvCIy5Z-wG?@BuI=+{AuK|fLUJ|p3SeESPUSt4pR!g13$+*S>-t6OtGq5`ft9L?qPMFa?Il zDlQ?xWj5E4i|Tf7>DK9_6oHjy2ZFz;aIkuZj+C9m--d~*^#u-pn_vf2kc6872VNB1><8wZ-+GKv7W#vMNtwjMeY&O($s7`!cLzX zdE^4gB^uJA`NyXst>Hm>$u?BWOg+K|Q@06Dc3Jz`SAmE=Ii*99Nrc&XceRRHa2wrV{%2O9zQ1$c~< zK0~~E-S=yzI$;-qTt*>+CSnY%9BJSk84^~qq=IPvwWKF&38SZ7|80IC#p8uq4B+|D zFCDOLNkGMzoy-6Otv8z9>5ZME&4cJ|s<-D3nXsw2hE1$%>uPnY`xlHFL@x zP7)#3XZ?0KYd4c;%imjG{Y5t=mEo*?FtFl%2TtTZ0!6tb{Pqh*Y(39CcBt`*&_;&} zBDhr@066pw0$Npo?6TNKj4^EmZ~jjU|F+sLFhD~~d9W<1-_<#S9ZF>c*FAnzJp&bqQY!;P#+-j4N+p?3FK%-tt%|V9LR}mQ|&+p1)(^L)7?+ylE_DwM& z&+nFU6x8a~fv;x@zo{Fg_uz`=wn>@I10?wk6~J&~h77*|$FXus)t(SZm&AbdZ@Ufs zHQ-fJQgn7B_q;gpPx0g(lfs7)PO=IVplA(yYbHjCE1Lewo?cP(h3dm>+caKabr*HrP1t0l7 z>nkOHEbi4Qe3!uwrXq!oYqj%O>UP~Q$y}Ae&AxxYKGd@t3?FNCy|i?&n=4jJcS|e| zby=UGHP>koxq0Q^>;iJQ074a(a=m)g-93y6-TM(?KRXlI+|+2UZrX|jP!BlHE?Bw5 zFu1Am>Pz4G{&f6g?^P4~)rbC4U8&cWSR0LjZvU2v6o6ys5zuGFcF~#F)BzcwWq`O| z7G~FOKsAa^?Tdl3;Z6|NJnGQ^53S`v^cFNky@P4t1)%!mq|aLrC`u-T;VV>aw<5Be?EO;!WGRSYLL^gKC~;@1T(LR`6`8@=@|E~eE~21nF$?&MFU&q4$*DZWC=g}4 zEa@5%i!->@i96C)$tVDMOYu5I%&o-EhV4MV5s;vHH6@ngrSgNc&|7xG9D{x}&RyYX z{P{`gF51Pc)zqL#r1zfj0{7s@G~DHZm_OjUz@fJ{~Xa~)o>guVyeb2ng5Vq;0I zddK8Tu#SgEDoU@=oC*FT)}aRng-(BoFUw@O0~g#$HsZ1{Id02ps>*J4#Ir^Kayk@P zK-`i!gn8nXHDX5rKnXv3?#EzX`;sIe zKY080?aQ9_faIcBp?37YsKk1j;RZYp8Lud#pks_{Z8XhvSofIaQk*x7wX&^O(T!x& z&9BQLhe=|APpNm!y1G|pEyMDPSxtab$JehEoE;f?fuN z^p{_>PkQ8kT2w9G9=jA2TVt}l=dz7UW6x_E z=#HA({DsDl%7~Ad_33_t z97*mZGJP)V6DfADuGs)1AaPtoqHoIZZqX)QKRI8Q?&)=w`r@yGhpEk&!e%dtrr|V; zz@4MQCn})*YRT;m@p7sWLsMC_sdD18QLMwP+xh~A!~Rz*9e%nZ2FfISI>yRv%lbij z?CITUpk;RyP*I7P7cuE{UqD0Er;LrZSAChY(cYJF?)2m=Gr43o^+Scc&Bp3MJpR(h$<8oR4V1&TLcKBM}O(AOEePT{(V^tl-(KCG8sxDMQc&Q z1Vxk*iTYzF&0eI$)1kNTA{ona*D8OUOj6?ZJ(t@k=%2BLRy!z=6A6b`OIG-BE zbO9mTQu2A{Z*pUbu|{UgY{IWuJT=SqzMT$8#cQmW8}Gl2-bkt%oX;eh%GdbaKLKGk zE<6r7+Km%0R0p@zJ7l}$)QX-h0vcyZ>MZ&yl|L2Jmzi~Mo}{*%3idmbSMQUD*t-Z9 z^X`>B*k8p6IboT35|Y*T^o@QJnn79-e!4)kCJjeqrQZ>tZ#DJt1Rvau{E^wJg_uW5 zH9DETOYqNov9};raYS9!nlP>6hNY6i&Lbbs3FXhX7x%-Sga3o901zXh{&RC8mUZDb z483sc-HK49+Ta0d2h7$vpJP$ObwWbIi}_)B-A%Q|UpC7SVgdvbeOZE0={#y_5Y>Cs zia(2~yc@%&9DPihU|$W>YQ<2K)P4)6DZo={N;;h=KcBJ>pk>GsPIZ(}Pno}={}=Lv z(E*_$`VapiL%m-?jzY6bTSE+-w2>bHSr&AGA4uSYg&1%_AgRMK6uQ6IF4Ir;YoZpsQ41|)WX;A~q!IsR zQl|9}#r3Nt0XUW`&fHqcKQ&lI=tTt{S}h2MO)3B!uBh;i)+UxeBH*m?A0n;_-gK9c zq-yQX_HUgYCZJvRvH8r#mJ#DRO+1zAtdHK+ti!A+rvz&MYdn-40JAVVH8myPqn~Z} z5{mT7A{ia+j2x7sbbx_TX?8aOX!DaHl$Ib?F7*J7C%itzn518lf0jf*RZX2dus6jz zAG}t)FTq~TS+9L`bu+5{5sg!=!l4=0T0GDyTLYko-?|`OTQ&f)0*S-;C#k%wAnKV> z=F(0C(CrwSw|Xh`j(Mk_({#<@NKpy2sdV{@T5f)o+sATdl?>zYoGhV6Pi>?SyjR3Wx_(NpuU^!}mprGGG2Gf-S zfoFw4UlzK$lKN0k^3SH@Hu*}huZ~!lgfI=!*3T^$N^O@|7F_ATKD>vehGL1-u-yR7 zu$$tcZo}9cR@Z#Oi`aR}Qs6Nw<@M|5_tCgM9?Kx^INb_|CsVrVfTbE3!(>R*%aV^J z#1sr~R#V~ap8hulhJJlouR`vPWh0)M zo&p99bou9SH+<4R@}z0lW(-uCpnsxgWisob8U!S#+?T z_b(*8o^@C}%j5QA#}{L1P}pSnUtpHRF9wsEni}0ovb2+ZADbp7@Fgn&fCSqChS|&w zlA8)~53-iWGmkzE9Hp+ay^WtDvaPf-S=Q>-Pf{TM%3qw}XiO1Ub|WQ&JkHB_sscf> z^VLgRiX>+rXb(UB&$s?E@E=8Kn$um!CITCH7lvWJI!P@4DqNJKV`F6JcaYnkKtH>^ zUBnV6`oHd>B*{Lsaz4VD$IV&QzNzOb`ndm{E~Z)>kRxtbQD{mN(0=8e>``w#Q>s?`EeK*e+0{IPL;yYkDW!&nK*Mmc)FI?sSh36k;08 zlk!%LGIFF&T0cp3TL23&A6@Jpd^MHz7)~@37FZTF$-jY58sB{Dhx{M4O%d+TysnlO z!5GJSLD^Tg6q(9likL22YMiW59-pWIQJ8F;PWr-HY-A2I$KBZcN@=k|x~~1|ru(~P z=NhCbD!*@Ue3z@jOEOm*^Fu6yW|=_9SUhC$=Svw6y$ z|K3835It09PQSz+#Z86)u|WmFtUm>j1ssjj0AxM^90T-PE3(ANdejqNb^^@JUq-^4 z`Mf63TO7}CVL=<15rtE$V)WOvk_i^kNHRA!SJ!^d`>W~`V{>z6z_5q7o1P{_32Do7v$?AR>Nvn9{_lF7wqmNcj zBN}Xfetxw6dPF2GBlx3-H&A+tERsL;h8~)MA&w7bgl+1tHE|=Rz=^@_`#b=7jpS*Y zInZo?y9o3{R>_lfT1?hACMN1n<>7g%UZ}7+!P&`JIGNPM;7BAj9iAuq&vERbp)Mi~ zV~EFrX!Pf<6sQ`!h~{)>01~3F7Q*fbEHrBjwjZ^adtnS@jo!PYA9KHcb$bg+qoSfp zwP~pG`BM~me5kc(;UE8hM@$F{+R}g4Mvo(#2vC7Wyo`|Wj`~9srbh7!846Xfqss6< z^)V_tPh?c>6%!F;4Q5K3(6{|)Q9^cxu;vRS{SQZo9GwSV5 zq*NU!<)jlzsQ;hSs5 z$zShFa^Rw79FNWifQVo1bLkk#GF0fL5p*J-w<ZjsM$p9O$e|L>#&oomj2J%>gv9#!R(%uf&e+gn!>~|9dIZjPz(19Gt}4r$Z5&TN9m_d^*6%pjEBtpIXOAWt(@St zG3b2S74^93*#PvY`xpUDEAk2c5%h=y9^qp2J9XSIOl&_A?vjSIMUpL0y)t-YFzJu{ zw*vqVXE9$K@Te0fS3(E|-sxUyZvA9@HpZtkND*_ZP-m_X!>|ay!5b3;G&-()H=|$U z7Q!aju(D|tc42N=8La;o7l3O3a6ASqQvtmML>C~pF49%6u3{jG#R=6G)$pXGVU)?> zU)S}anb<Vg;u>-pB5Xypro|;=&Am`22mIQdB_g|x5v~meQWD0(a80uf>+dpM6 z@Ks5lVdgRif^hepfMT3chKaG>7ac*G?oE&e6?O=^Nx!I_e=pAS_E?O*!5JuN3w<@) zHxuC1LJ0t3qyVA({XW|kej&ncwj>0Z9FG&CQpO4FU62)e&-V6qB^OTNMdpsdS6NlG}r0{XFP}lPZ-V22)2z0FmnV z^HjD)j4*09qEOy zddYo@uqi%1{;eV2?q}$lVut-SzM`5)X>WtthqoDYw^CF8yZzkVKa|q zbOopzDG5qyXY9J;s3t>WKT^T$RISvZ=-B(<#H`-D4hXID;K&B7EB$6pcQAIYd2dgCa_P{(B3yxCM+ z6kvgf;Lv*_gjAK4zjlE%5hX@$S0&d1d{d{iTnwQG$Tm#GgYM*8xPxy0QES(dVd&dS zyqv&%@?~0%Im}ZGAN*7{*O-~*(&N8>{~kNyweP6;=bgNKV&?tLmc!wS@NB(vVO(P3 zKw(KU;K@oDB6)vsf7>#g67hWpSMNg^(a2Jhr}6dq-d?wf5A#t!T}dU~$!4nUc5A=6 zxw)c(!jF9q5`6S2z}6aJ?Gy0EkKEL_sUF|B0%mEwTAHAbKv9nfQlj-I-$0>8E!(c^ z8~U2q;Qw$1kR){Xaz=)CtoSWMhXI3oUM#kvc^|^JP31_SdA%V0#hQ5;(fDUA97>x= zznE^hQ_y*XkwzFIid;p?7HlwwgsI~ z>~XQb_}5wXO9KMHjOqM+A@4eG?DhDhzsLZG$V0-Qee9f_J3F>ASr|d3DI>ueU6jeP zMbEHMAENK)MSC+_V8NP%jfYqTy@`NJ*B=H5-39Zw0q2gm!I+LZlhe}!u@us$wWK7s zhrxnQY4Ay8960p-z~o&j=$L3Qw<^XW7FBrv{pEj`TO66K_Ue(=>U_p4uelZ=m-&bCb)h6W??G-^9y-M zUpr5KasT;93d8B&eT4$*k=vgFxB8dfctF30Ub;OsP#%1g2|KPPDO*vBTEj4L{+lRE6$c9J+aoio;-^nwd zYgCw+hqDe6{+=lV*Dou{^G|B+0k1Ck2AC#xp0N(I$D~y)Q*#6i48PR&ygBM=5=o_L zFd2HaNCjD=luP@ilmR7%jmnb<3hLo7XuiCK-#~m!V??9?AIQ6>trmEeM}a7~+et>G zdmke|3HhV{Z3CWS+|T~ySpgJIv!U(tvZfZh;gb0PWBC8h3gC>50|uP28D9hhM{*fb z%BFG*y}nv}Mi$nlxLc@q`yHo?h6mg2OMDNp}UoC z5a|x35fG4&4nab?yOC151eA~tDQToj8YCnHqz91}_+9kT=lQ=uo#~yeb(i6|~lYLHJ%i)RX18OQ0Fj>N;tM&SF|M6)Mjq85% ziNco_pBuA+=U;qIHotQ=|48_GT-Q3;Q(+hM?FdYS*>w+VZ z&a*zA&Knhe9up181p>mTk}s%v_OIGwnHD#QMM$Uqc9w*r{6aez`V=Ur6pw&N&JE~L z@b&3yAIFM(50{{jPKWt{I!<2c;L;ur9cge*S{hjcSiNU=Z;4+n?E4mAboOJBo8&5I ziRglNt-M=1y7&;(jlO>UY88NCDiMfq1j491#zTjYa5D&x&QnQDX`VIONXaIiK9LfC zbJBUy4odox;g*Yq{jEA*b!;ROyz|hC&{qiwbT^~@vc$n9c&;p6pAh_^3yeAMFI3O_ z+Kx(1K>tV#=-(R4&g|R#5kDtj+TCVUPJ0t_m;Up!&ozAnHwnkThb|PVv%qR%0Mx+T z&W8dM;ds!Go;IN1C@m!CeXqt#zw(dV9{WLde9DviD8q99{5B0X8km1}dj7ysp!$))n`*yY)HL;v;SBZTR7i+7Z;bYPoF+@eID+jWuc{wnW`}E^hP%~Gjlg!x*5VH zHjN}Y`@yZ8=)D?MZpz&ppL(4dMsMqY_hbnXyeKCxPBGix(tc!m6UEu0({o#Tz7j|5 zRAM9bY%u+vYIgcHjRd9ObD1TS(c3x^>;{dRWaQ*7M3A-^yz?(j9@QMrw@d5J3A&Qs zftyHOFSz~bU>cW2xXqMV$;wU{WvRKl64EkVo2+DgZ`JR47XdQ2Z>)Y9-bf^cXCn;@ zCP9}rCRidt=5I|!0W4^K1#R%Rb24a8-@`NW(S~A@^2nUrQU@Y07urqyvdcK5_O(~} zDw*%9-F83U0lBb>tkB>Eyc6fhMdwZGfgbB=RC&k@)_qVf`*bmF=%u{R*#wtr?(wS- z5nhev{UK|d9JxZ`SEcOUPf;F%kr znX6V%NViNr#4?A0fsIC$p%W7mX00EAj39u72Eoy5tv`{Q>mhdhuLs`74iMWs-H^Mw21I^`?)E z2eWb9KYY^A(t5i+*F~3EbS3T0S@0uu#9Pi%Iyz96IGFQ|B1ePF{*a&3#_(jAyTr4S z)|;y(Pd=7k_QzFa4;6m6w}VG`tx+EeEyiUEzc-6OhJ+3;TCVT+ zau~72NY<_k{po~;s*4U$;Mhx5G3J~2w6@^(tM8YN^Z8JD43->E=%L$W%c+N)(6J=W5GXQN`@eK&*oyniA2g)78(Ermn8F{sDJW z-A#)hm5z0glx6x|olVg-ej&GSQrEMj#h$_7Yrj55I125Id+CiZjZuqU8r@Bs zSAX-Ra5lgm{bEXb$7eGm%7Xu5{`)i!K{nBFf3bxGG*t_{O4&w#q^)h}@s`RB3>5kK z8+*vGLM89(>gqo4!~#jMG($;o9ISKT!Hy?@wLe_w(cACP;jwkzv>aBRjnpdxf1l3x zMk*>X!;hQXD~}Sy_&MOEObJ6Z9v3Y!?@ln=*o6*?? z%a1_TK;Ojx--5f$gaqG+0j-b;9Z6x>xYzq=ahb2q=A9+5FcE6m@bG-MHT9zPU0GRC z-Ec#o4fpSp1Jl;fu>1=N#X;?WpVs_oi0l?~Y4{Y~B z&y8Wn&hf=Eb{`hpNXZ3`Sm6;GH>m5IIDYHZd2t5VMSELAwV^CBUs^AZ=XA1qsQvU8 zz4n`DYQ5IN>0`Jakn!WKe&>N&eM9{|`A$nQMrfY9$B9)Lw2+l}7Cb zzJ>;&&aZSu4GGEU2NwiOmQ*+7VH2Lkh#u>_4D3g_&smE}Z8QcAhmvR0#yn%NhIz72PwnpB}Ysp(4 zheP*X2!y6z-1Mn5y6&YD#?^Pg_F+;hr=eX~6mJLK z`fB4|@>tfP^!KRwAEE*1T)KDWU)C;eZJ$6)T?+Tia zD5naDJ8}UoqMaCdXsfZyNG4$FspIUZAy_(i{dciy( z{`cA6d|?DsdlutMLTKT*#ou+g#wr`KgWxka!s6e5B=|vhRnmH1TYBe&KfU)7xT9!^ z^&KSHZZCmSo@ox&TUiE|Tb zHb0&I6!FMe~5am^68S736f=h2qAP~Vrt9nnYXKiHxzA;uH9T{AmEya%>zcKFv} zbV9x0`3*WjeW(JSi4?mR{6H9Ac)d)pTYI|GDfV5TG2k22sAWUXuHjA@ViUA9R4~W* zr;iq${v$d64Nddl=Wc!+qPG-#d@{vQKVB-npBF9J@yG$)_fw57Hiu|I$TA$>KS@OL zexP)!`hE>SeN<)sbIL$h%zKlzcX@Rh)`&;V8B~a{FZ$aK;m?MK+<7fU0^z|5Dzlc{ zHWS3_wLtQ1s#BoYFMUm?C2Km3Dw)>PT)J7r#w*yOc|KHi8%$5stgk?>khz=^Y5Zu< z_K`>A<;uP--P4fdVJDXB*apwwR=x_2D;)kdD5(NBLXBC}X@lSd{@vNe)L*j5`)%Tg z`Zgo(&W+bdgAe{hUYH=F$eT{RbS{w+?UAp&@#wUn-{lS&8K4GfvG$4L z&NZ%h{QGfH(6zxM1)VEqCW9e7%gtJE6$n=YOx*gjrgF@jR5!)F6PktLo($c@_0_lp zZ!F~?Y8Zw6N|7d!sVgZ7y|H&jihc^j6h)g%ydLHOF{(&2e%hQl`t35D=zPeS3|5VH z^D=N*|9i+ST-<;324?JFOrNfo)l4M?4T9t#QP<89#W&waZt+>9x<2kzEl#rpET>6& zc72|PJ4R~|cNMMSa6v$Jk&OK3asPhkGyxemE{z4)#KIq5NC-98&3{^;OYxe^6z_Fq z=pla7YMhi~DVM^BQOp4)Q6H2genq!_Z-7Dl&Nl({28xj)DY5ed(=ho{(pZ&P)vu4D z!9FL6{Pqa_Y@JK!UCA$tkp#xJ^uVvFcUVSC_3m}QR)vIZ{@(xAfETreFSX~34sLgb zd00!2gg(5DVBAksuyaIEJRG@lCMHJyoZrCh+pZ~f>b%YfgSt|LxoLKdz5XK=8uHjD z{KrMl69(h=Z#MQBlz42&Sgqy;>71b&19|}avFzi19{?8evteSL6&HISMkd_a5K)8G z4H|?1xl63kniX_h1%l>7eSJ2sy@mpP&&{`h5et33_VNJ% zX$T*=y>DXeV&h1D*u(>zP-OH#ge&6eq2_1%7&erLnREGv*>l#$r5}1$7qN>oZ41M) zNM{`gp4Z{b{`3k-R<>%82LW}DlJ{7^3DV(_o854=p*gUZE_vdrg2m7=Y@))Sa?vf? z!Ej5f+2hbcgQLV(PSoy0HtcX`Bwsn*>|mvf$LHLcRLr}c!ga>D%67I&LSKz(s}izD zg7I4o`x`5KWk(Fv9-NaN2ndk@XZpggGwvP7%+sn-3_9`)%?if*cW!M|zJtKr7?r^r z8tc`0?GJ<$tV=;7Im8S%;8U4qYc~76&2)b-8vfU>FID-~ZU{vH$t8@DRtf>b-t(gA z%g|_HHJ8JKf)lPI;UF1B*Z~d#f6*&eV7TEfwY+Ww+!+$Uw&@4e3J%6h=LGMQ1vf{n z)s@Qv@yiD&SY&34&wt_Ay?NcwHP-ZiL1;AcWt*9P;>T{8;TQ@M*hk9xVvORz7Rs?g8R0o?98=%MCh*Q!>U-8t62}SqAACqh+Q`(oev1?3JL^X_x5Go{oN$b{X_Sk=z3b za#FV6m7p={-ap~$cK{1`4SFrHs6=K6lM~c2lqecO-<_62GNTG>Z-v)lFb?!O>wyeSbQAQa z&NiF%;dual>WzAyT=yk~RO;u!RNf)IyKW0E$qi=}8?%89LF8yqxw|$~6Lc1{Am|>h zV_&uVpGLSYe~$5lhsHdE%^CU}P3nxzH#iNYi@E3ZJnM@YG7y{H1pw{g)TG`JW`JR4DsR1N^w8T2#NeoidZmXd9v%vYI`dz^RX^{yUUR%;P)@kF=q^p= zdun`0q|p$l1hiw&rF;YvUUnBGpYtEMExU9Qa+_;@wbQN(4xWFj9ucz{%ktb}9?5FYTb_ojffRtjkpw@tjEo@u)L$&)Pa?+*fWZW*KJ5^xNs zh;M^rka-&DHDFetN8K+)X9yg;Y>A8x<(`|&vR_IDb}JIY#Oe5 z4AP^ojoW2Whdk0$;U*3~t=Cr<=0{tT-!D(Tknb}SX3&%rAycL&ojjHzkPtr#a%EJ_ zV!NTgDV#q^eg{lQs>w0AEu26Y)C7lyNc7**W=YC`?mnLS_Auf1Nk>nKkq!hmGECh& ziuZzW#JHlDo}n1JdtoD0E|z#qw)6e(Z5M*DSAOLC;Yq9S4=P1uh5-g;n#@JLz2OE~ zu9I3}zqnnc7|#2C8qBLbytj_%SJ0b$4ZRO!@wt%~#qKRQzqI~*iC22eSeP7_w83)^ z+E!f76T}qBBv<;!Ts8jp=h-cp3 zP?h!hf!DS5#5)V>Swlr2ExaDoSiS2>|n6svoS@^7enPCP4%Cy!Bgbp*Z>#4<$k`IZwl z7**+ROmL0U$(0$DTqKWbh~VmlM(an%Nn75SlH>=0;|b)qwJ9#R^3j#YuXv1TKk*^- z8V+>A;S?x%YNuXOsAv-aLs6~B74u5L`P?QnHYlb@!%Is<`iOh76ZY7nb%P+Qx??FK zGWqg@>+aLqgTYsFNDVg^SXwNZJ$5JR)oQGG8o9EbT0rs)xR0LBca74?kaG$j&zPvYkoE> z=WP`3IU;(J;eqjB;D$UaF|p%sTDBlRFQX*@RG6NZ_Wb!qwx3-s?s3k8mkjGE%hV8W z;5A|1VN54Ya?jck%jvJd&adnS4+i=x5inoU-&rDRpPy6bE6YlFe6M!Fx{-78t(VB& zuIBbg=y%^{Dz1G&y78s6;55&@+vnGUh4Nuj<%&NXhf|Zg#l}xW_2Hv6U7b|;7kO*- zN;rrvP8zAR zhCccG;&V&w$UzRP{K4Wsc$xxk(GESkVb~s!c@e+N>saERZ1Qlbw!4-Lm*T__;xBKE z3$2KQCv>Jig?~C9@w=jz4#niY_n@A;M~~W0nEzj+4mdKk_?Q;_DRI!-xc9@= zAt_QU_e}o>dBw_AEjdN;0JD*zPHf>pB@ABQpYFV^l%COr>h7Uo9{4A7_Tr-(S)0^Y zdbb0NK&FBWnlN-_mNpBrlUlEi%B!r$8OB+mF&}no)6rtX(-8*E6MLH=Twi5R6mpxo^o4P%xw>rRfBDO%JQ@7i2XeO;kTUUs#jFr`qQiPp`=|u$sR2 zvo^9W<6Q^gkn;~x(6<^&J=WbodEqj7f@eZ1M$qR?A0u&Otc2S2!PQhhnr85T{>2qJ7YhWUgHg&_E$0vy) zNrJungv%>NNM@qr*s+Jw@*}r+N6NHdVoQAW9lUVh=g*cU|3)vw!HI-fBDNnl5a;Er z6u}%&WW3nGUvGUF`vySid8nz~@nbC#L$7bQu4XGjZcmhFsn&1$Q@Sq&SQO>l;-*f0 z2C7T%?tYjzW9>)s)Uu7)>sQu+OK?8I;k+6_a5(xb(4y03W50($|Igk{kJx-?f znZO*Z6OM^Q+dDtb>OPrVA|Li4X6uD(LK6L6=f zF_ox7wn}azLxGbA8Gr0MP{9?9-%r#6&V{=z7DP0x{HFthi)qQpgTG0dqx^}-k=6- zgdbNOLt|!Xb~`7G>ZwqG(@<=w?wjrwjFtruyQQ2P;vWX*Dv-G?f}BFGzX2 z=L=Jjrm}B@6G~#y;P$&ua}Tj3bf|rhw zZF(?4?C!jjiX^qcZrG#)N8-AoR_(^#^DB|S9FI+!d={)Yp*m2opM3mM90oNyt;eD= zOUcG`N*L{+MZj6>U)SrvSPGAW*Z`A{rnB|*@1SbfCFV{_5ek)Xk0%{BYXi~2FZGUZ z-J;KlLAw(PR*^dZ7AZcySDHT_-d8_y^?e5)G)Im2=IyyS6y$!_Ab!R5(I{rj!L)d9(+c$-{SDTCI{6N*seEGT z92*3#x#*Ku`Kzd1>}7AgY~5E^0h7;vZyVM+EPn01iWVle@{Hd-a9PKwr1n#II?EtA ziZBp}432H%^e?}t^Eh1Z)3waJ10$H^8X*lK{DX>jn79 z?^iJ5K?K_H+1!+7LoS#U8IuU_&lz=j$~*AWcBdQ-6+u}p?UXtAJc9b;ord0LoV4ML zi6O&I)1=ONAv}bATpKCR2zqVFM$7A-PO_B6Hy?jioToNxJLD$GYG{Myh*-^ZViqz#w&V zBX+QMc&+Fc4|2Tok}%h;&xVXCyng&xkM&dcyuH=tP+>Eh8XqAx`SKYH-UFHr5g@|| zR11Nvvw2@xcCyen9oVWNAXS-Y0vT&lf{?x4lmG? zxEQ(8rTc->?St5dC)Tw}G38^!AsLdQye#RKnDM$sMnS|@2kM;^3kHqO+)fT7X?F(( zXJ4FsXl@Fw!At^YAHBSy# zNU_CgY0Vf7rMfe|y9-i}`xueowXVBd1+OiHz$0p`pfVmR${7Nqz?-Y9%OXoCcul|7 zHg!KBT1V?(-o1r2k|}T4hgkiHwCIs&#}l#{X8K)Aue={5imSLCv7~mP^VvB3M^JWW zNwP3}U5%yHo;Vz-X&{&}%l0|uroD2|hE75a&;% z2&5+<;by1cKj|W!UMpS8OY3@x&NfzY`?bW5;5dmywdv12wNuRoBvP7zyJ0{f8>oWb zojdiL<#!Xb)wVG9ypZ_$5Qpc3LAWVqsTDq2E!BGE^Am*l6s~&&Qj2%!Bkyc!5YWPQ zg%&P1T)B`UVxCld?oaNZaB+ViMyk%XCD!28(DGHjz2}*s^&W6+ra%%Fu*b5UB?{*v-M*#h4et!9aE7CgJ>a7KY{a8vOaJl9HtX?S)9{tjijrV_ z$$~aA`_`o%Umyv<9o%@k;Q|@*coaddTTeg_p%dw0v7z4R0*hrWlMBRh?o$yTZAsEU zIKc+s^iatvz$xr4wAA7<`hauzu+`VcscC6Jo<5)uZN`282=8~i$XxuMH~ad)tT0v^ z#2Ai$*OdGr3AFxSIslp)^I{{ln)@Dl5KR*;gybw*Lkh354N z{hO<}JOjb{RF(jJ!GedB1==1LBJ93){sc#}oo0H1U!Uoom7m-hZLsl*ey^j`pk1at zH=C4XGtxHZ_@tn9?lkkqM%v3~@^{0|Z_S*yOY6AbTE1nUvo%?qu8NJtX!8NRyELQj z9jIi{gAiybN9DR&Pvy{tD91L&sVm#&@YCyP>keUr2~#$fsZ;E&$`Kslor<e0B07V1}d~moLwZv+R!4tB+ke2G$5P1n%Hc?BAScy}5l{(_! zY+RWIM)$b8LK_o0+Ec#5%K7_V^(B|K-fS;e`*7jHmIlw?qYPx;Cy;)|!evC+K@=Qn zhGh=$hi;@vAKs5-aqVGdzq|(=$&qzTAudpJmPlImZw*mZH9zS9e#Gj*zosdoLE(l_ z$%c~~0R2txdXqwD#SG*IM|@>N6kl-}#*U!`GLqlCKlf z@5*fNY*WPIU;s3l4N zccBm;aJtq4yusRL;ay4;Av-p__=|eyRb|SZzJ}^m4F%sQZo6jDY^RbuiSrFLU>L{A zb`kMy^5L`B8Tc&P*d1XCEe;p#wQ7eh=?4~;v{JV}eNaXqLN%ICt*&HK<~Qkzh-JJP zEw6%XOo-b(99%QUUrw@Bx&6tr0W(|T<}<`ntfVB%o8)A=rHDI!Mc(b~PQQ1gHIN=} zwkhGzS(If8%?qa{C+z39ouR3B{J9aYH`Jt~D+F=<)l2<76u}Oi-+nP8-UE7wLWe5# zXFo4RfqD^%OeF{mX)emVtjb|aRg$>j#G66(&`#~S$= z`w{K@O?^3enz@mp3VFK`W{YX80HQ@Rk!xF)kPj-cmEp0?ktVRtowKf|(`C;@y0@mw z?UQibf+l|}OKj<1;~+Rc;A%%(LTm$)65J-Fq(8kYNHqRo>h6+wp%*xwv4m}ss$3tP zJ?$GlEVZ1t*nc=gogMyq8u;Vh4o}EdDq_r@U=K}9DL9KW;TZ44E_cH_yf9r>+#Yl# z&=}!9tnBJA&N38)_0ohl$a3R%598huXw`tN6eUM|&uIMl>d<@vb$qS1h4>V`H1*sH zZ+Z#TH$P=FnKuzD`YP#5Qtbx(DmRYg;f4qkct(c|@RsqUgaB4@%z z8dQg!kJ%$xqp;lh+wel}F8O{3gJSnl%W+-~x{O^GRBf-a`pr!n1pg;VgE1qD#C4!{ zkX&(Mp6C0%$uwKDruTP3puX#6F#g;lb$_+^+f3mM?0wycxn;(kX}4ViJ44Q3UQCns zCz~+VM>ef0l}DdZU2xq)v@Bpf3XDp)&;0tr3%nDHOBSWR*k8LE`EI=CWS}#nTkj0= zC(keHMaJQ-gXQl^q%dtQ#ssDy^}%^coZi{INP4cDdFyN3(+`o}-$Z7w9As?G;OG-h z^hqfJgqa$HgN_^SQAMTHyg`Ph=z0Fh1l@FEr^iF=AGw0z9eSE9uOvwn&45Ttv1Z_n zllYmYb0GZZ(wX|VjWC}}m+C^RY+=s2e+qVh4xmv&I=;m`{}PzdzO=KOSZg76v6tsY zKdDCbQNI^iJHP5^FF$HgJ8A7V(n-`iY_lj=#%9OPvvyCH=PjGJG0Cd7B!(JNo2|61EB&jva7(&)l)1d6Q2<9Kb0`6@eQ^sE7D>tjT@Cm0 z?PK!{ty0K9W!Kcl$;((DF|Z^jonqAJm*+;+Z9=}QKipL67Ch=HGZ1Dho~r~ciCRAR zlX{3CUMyRb8#X^gw@Ex3y*(Yom4zFuF8{n)MeJS{;!-srac1is^GXapJbF}a$o`Ld zs4tR`b&%iC*84_i<1Z(EzvX=up5e2y$M5^Aheo%tm5P+hYjdfdsBxsSh3$K?EQ)24 z^Cc;g?Xxs*?Y!zU{`mfdM^_W13Q&VlL-#_Fb{YL(+A-uHiq9$}Biv}GgjNshS?GWWkA_KbeJ$)@)0bB34RxXUJy5T8Nv zJT+%96R|-8&e^wyrT1UY;=gQ!O>@gXHlJRQSxYhGMm!;pAlz90Lmty0ru(;)z6e|2 z(#o}V6)W2yMihyzRPMSX%wFQs#J6ELQuaZ0(vinuNkW);&E=PlZ*4fg2#r{QO zgogRfy)P+KjHNmh+aW&o%^u5m4Gv+RStco=qMA4y!NN7{;mJv&j5D7(8Pm34WVjYy zajB3E9Fe}J4z@;V7AdAfyiYY$98NF&hgTuXxAF~zBIItVR)On?f0@YX0owz5%jOH0Np^GQdRe95&9m**MKYXL zJvrjHMD2v=^ziE)y1qJNBiWoejn4bo5WO$dfn`{wH`~+` zAyQ7GIRq|mePpuXPl|Do10+VBMB_UyKl0)=vOjQIAt9w@YNtQrTVm7wP`VHM#1ze( z`cwDak};0`7~!CWGO51EGE5El00d(6Hm|_0qc0Gh~xLX^S@0g_D!}&0JF9K5XC< zXQ?Sb0O;$~d29C&p+6dcf)DRX|jOK#3vlK#(~j~)qZ4FZMct;yLS+&3k^@hLQZ0!A@w zCK*mPSn`sxMU;`@nWp#HTVYl6wS}`wyF7cs)ZM#})W!B#F_GX5c~*}|ddTvEY4TT6 zJh3efzeP3H4Xp1~+T-Ga260@SNB^0O@fY$G0pd7+s@cn}9fR35ZEqD6quz{r4JdzP z4{WGgI1941eC{oIo?gUL>z~-Ws&El{316uiIK>^P@g`9`x-V_;8Am+J&NW>2mGpf% zVfN^j%9tIRPbSM;sjRwHj<=fK7uNKfj|;v7#n-Dnj>4Z$H3UwvWV@Q)>T|= zFKeR$5ekDXX~4Y*N&iy`{iFH){WjxaYP6V#v7k+HqGq%}mD#s_dP>`wF=nl*ilP=IAL-BZ0!p|uckEfR+Fi3KQ9j2nCg2SAmOj+XA`ndul0~>h6j;o_32m}8|3-AYm_hJE| zndT?|B?MI-GPF}#P!q@!_wDCWt%vXt&^~?N+`?khH5?^mJ5t@>pWpapA!#AGf2$Um z&ub?!k#*t{Dw`7NAEz)YWj^dEFO9H5vYrSiLVjR5kTfJ8=JVh7$Um=-0|+UR*I(}^ z=%qYtXNlaipRKyX@WylUOTm@^>D|KW>9RKKO^PQ=Di*I_vvbWJx}NPS0C3$^)e(e2 zOORLloR)ynzS3-n%0}os!}dDmW}9FXA47j|%%- z|K`odLESD`lyw(%JvU+JJl(@3^y(6$mRL@$c|#{Jtw#lDsr9O;>LbO@!3;Ih;wCCJ zW=&nrA|%Dfq)s`rhGuDD1K%M_&u$sF#|}Mv-jHoO2(T&R(g}r%SC0tU*OT5`3R#S! zrfefPTw#Cw8`SZ>0WWE9poUQAZ*Tk?hgd-FYYHmeU7^^NPdiv+s6-nR9?oalA*I3> z-#vUAiL10*`Ps=g@wZI3$_>?UF!LID*hgFS zLBkQNo|$@kBzSG$M$+R~w1nz)X$$%JX9iPZg^Fy&y8%RsU(s)px;Fs`GTeimb>Hn; z1YV)c3ywmhnb$uWn=tIGm}VtaN*!@?1Jb zr4bV&PNEi9$9cj*SKKjwlPC$+hrT@jC>EZK;@V#;oj`b*!<9rB7LfVR_6wN5^9hJM zBtC#Vk0$O0oLf1$ zNfLlEOLfOR3(t95agqSdb(P20XP94{GLg3_72_qGAvDlVX(d_inj01YU5X$o=IPbM zpjzaa6PqC3KhWsUS-AO@Nge@d7O3(~$7prhUPdPgBH7^%HdD%u)Y>hOX|sd6s3Yma z8Hd=O=1i$cK?6Cd*`3;tTw)jEWb=KeWgzNUg|{NAd2vh~3ds z3$HCG$%H&)L{R0rn{g4PLN2MU6yTs6gCN@7Piy?k`T8Gi{2xv6S7JnW*J8I5&`xQ( zLL$Ta*chh9&Bh{G!o_4U!i2l1Cd&p@f&Wmr`!$DB3YT8_ealWxR~8eZ`@kvpV3!Pi zfIgj3n4(PU!UmUU*Yi!Q{DN|8;%>l0d3wrqKl-~B#-xmfR&>92He54eMRs^;+-0Y< zr2prjs`*3Cg-!Q3OV@IG&(+k&kx1CXevf;QKsu8I#xY_zrD=4czCJ3$Rx|NPx@0(|k;Zno%r6YW zYl^@P^ah?#NI{pvUeE3*#ezcE4KkgG_w&8DUEkP=I@<~HnC>GL1urYrK|_w0aMwX= zC<<|_a3M`BHL8JNKnI1h>ll}(Kh5Zi_lW;?A=N(2p zj5#6^(gYe}Hq3I8A&+>p{ZbaKci~b=j4kpzFstO-<0j*RvtLakr!*a$;MGv zZ!`#dKP78{PM+p+sDK9!mtvARI#*!0S%wK%61S2)cWJUB=d+co}x%Hi86}t#pUfI7+9>kRIBb8aJovuwTIhIRe z6X#oTW2vzQ=}3vi$TA@tR!f#`p3_=%PY|4nO^r80vtSHx2hBe#9uB_LF!Qy3NxY4&p#R<*#jPmC)lB;c`^=LeVFLe5WtbF2_n&ST}OgKi&e}1{Y zim_@{xS>PhGV3A%i8@recy8ihJorE(iY1tCtFEh#No33!Qy;7!P(l*+bfna=UC8xN zt=$6D;h6)0;2%MMFIT{g3LA8Z1LE!vqY1*4szeAF$tfL%efQQ>%};8d3S0|NbW%uU zvG5i6&~u#ex^Va3-+O3|EZ^1M2Q8CGlVb`8x99p9*$s&nxce;&sg z6I>>kh~f3BAh8o+V@y;65TAU6Z<$s_rl7t-iJ?i0fz06lav9+OHWWziJ3Qsw<)3hJ92N24@-?GnF2wz#l^`Fvm5Sx^zgOs!$A zNNxgs+JGiT1V97{y!owz{wR)_bKGYdFTwdm9i_r{!#AQgDj-yt+H zgf7+wzNi{w3ZO1*DIz3V`fUB7DM2^5?J9pwFZm%%K(K`NV;7cW2DQ|B8+Y|NKiz2p!JbQ;*{DYSTA6$bVbd z%pjl391w{kotcUvfs{g#pKVSkp zVofQDkcu#~$Y_0`$*)s23X#vj4h~a{%l6G7z=XxutFo5F!(vS4`E}&OXes)X9FgYPQrCV0_nd z>%qpU)UJCPYwM=A8I_t1SlXxTZw*aj$$W3Ra25JpfTkqCjGqcC3{y+wL3CIphpMUq5f|GZlewm^EvMYug5wVc3<$iv zl;yXRqctj$F@jvr651g9M*rJ~Y%!o~`N;);SupBfZ8FRE#)OUbDAWS(3*7{+gFzphm&`;Y*IX2nxdxCvdOCs>qZC1P>1hMv!wE|qVP^YjM^)~d?m^)e zP1J6+$Ra$mUsTNBv-syueuFl1_(HN|6c+VB`y)~d)W4EFa(26g#VG$iw9x;};v*f9 zazsh+MfKB!x9hP-^Djl@`^fvz{@6^(-SVTIz$i}w>VF)ULiLFV?9hb~HPoSg^iTvD zu9RWg(A~sqPjUXMT#nMWS6V(8as*LW8LViaRYNg6RQUh7NV&HW9F;bhD7Oa1QKZAy zfwP6&tgYPE@oKZyio1Qt?%mM#Ux$c9g6CJP=o6y3ZM?{dd=4@NKkULB|$0#11g@X;{}(T)6bp}k&0`<<~RpDYv7D$TCEYR&5PA{Ap%5vCApzi!6!rz{zJzt9FUC&N3`U`);9DQ?XP;SL1>63TTjFLc^P__ zwfG~2Mw_(t&>FQk1QV;)SWJ=71c2hIs9h0sNIA3+<}&4r)GI0ZQ<+B0I{aSg>*W+G(ksRu}@cPhTs;06fCp-J|$#}JC&^FNG077Gkf zkL%Derm&0wvdUlQh>r0le*^|u&W6Kic!DDQe*^x~1K=66PQl8|+((YXbJMJs$mmai z|G$BTxfbtO}s~yh{9YnEsQFfe;2vmt;#u z#17JqvE5Q+N6f<)ghzJr>TDy!MNFa6#%$>CN`5(o{hu3|h5#vLfPi`is8CE@j$=#L z79)S*ixat(g;j6r?32PyWi3P{(QY*_21wQW>=(&*=(8d{DmIyzBbg&6l8*K77fpT~ zyB#MK08WlZnaqea?{qTniFV`0i9#JoG7s0IIBq0e=4e8cu{q7Fat?jk$>RXuiK#@}GohEmdE5DuBaQTgT%I%i9k9x!%C}Ue zu)ZZi-HB!c-+=!3(WScPni@WN&-5!hi%c#Q^^%c-legJ2ZA8StMLVin6+;GJ} zU(Ysy_BGP%6Ec?ss#Y<^pz?XyIJx2!Fb3)dim-C@HK&5`fJyDf)Ct6~yoUN@Cb&MD zwCq-X_z}jBpMc9u9i~A-%g&91_X6@g!r#+&7BH>xYvTffN%M?xE*&X!nVt)NkX()0 z$35i33HkDS$)yXXt^d3gfS_;MV3qcGJxOKDE3h!u6{>l2y2}HY20W@#|5U89-ZmTM;Wt+ zs+?QZ@>_`&D0sa*NOq2F1C82T3!D4y!JUR=^_TZ@j8OJXU+c0)=j_k!M7;mD8Ufq{ zGK>cQR%ie2c?l$SJT;su*77DEMhH)Z|1b8$^Hs^1mKz931{BSoE0K9PB^5>cYDyw4 z8h72$0JomefXg^FDOhqs;@gdK6XfW<8x{>o*U?nEO*o?T7x@ z9G3@EIFtIT9L4`d8ZuH6oy@?s;}K9h@v$eM+F>4Ox5+MrP(si(TMwmTveNrssN5WGamRcS-_^TzH3c?iQAz}P*3OEH=P@;#M zP+ogv5wzZu6cf67YJ;Qw{~)#eAyt|F=*F<_8v?Jf*m`#Hr10W*E;Dgyc^Lsr5_yCf zA-pM}3Yw~1Z>)P|gZ19PQ0RWQiv&o4j@dz>F<6&Tif~E2O;`!`nw3j>@xP?ye-@eu zIBTAonYzpViY$|tpC0{hWXX9S7C9!^q9&Zhm^e$Pn}r$C`RA7XgrtCv5dQb;uMA-FFv|qZo9s}X!5>zdnBy(xjpD|2*&sDZC*B?qMFPT( z1R(V91DqATMuAEqXn)#Rq?!5Us?B7C^n+y16~4^Qf7E#W|Izi9QBnQ-`?vHUH7MO7 zA>G}eNF&nS-Q7qxh|(!YcXzka9YZ7C&5-xz{LcBF^M7!!`fEP%$O zTA$O6MhZgQiugP`-}hD>_bRgwELvPlT2ytK+-`TZY14a^X&etz{?+0M4-;SM4N+O} z{H`+g`&gx2j4mQL+3xFihR@{l%lD%RKMh&QOXq+3*@`0R%TPiy`i)?OP&$(SE)7uU zYtYo#;lI~vuujCeDYWj=g^@3TN)hIyBGcL$*rAr!6=mr#hxj?!h0PH&g z5{xp#-_ra6+=*s)mB4bhi}Tw@*kI{b9Y=t~GMMi{dSIj^Fz{1bU;OMeB$KUV;gGX_ zt>ffRK$0*=7B5Eh_sKn{x_b)^+w>0E?GEKx-@Vw5zD4b`vxa;H6uk%9#1L!{?%QYk zl+xQ;hI*H!QE4tNCJm|@TMw~WW^Ib-m?M%TuRfYDTaaJnaeB7UioLPo_w2^YO9<80 z!|$L+adTS(GND5^1xp6KIgJ6lt#M&zcf66tcf2=)mo-n!mlLAi)0<^G8ChH1pR?J9Ah|11MY z?K7JrbKHflQQ7|lOQ8StudK9QU>oy%cqjJvRRbAfocR5#bP2OI~i!!YqdDoMAAjvvw9eb+IO08)#hTr)5 za6zd=mC<4YKq>t#2+Da@!L8-UAMc2+!n zdA2z)^e15}tIFreW1&VGG%Si{NXrmmr93WVQcTqP7Be9akZn%p$>8$d?f%LG4s%$8 zr{1>;@xUFzM5z;4@D~uJ+uk8$sKeXZ1Xs+P%=F|b7M zCE%D++qCoiJ9A8%>OQ(jf#9$s>3z-d&4=pCGUm-cJNqFtMyS-VqO$li<}06?qli_H zYlfO2;`v9=`8CJ;8(%158O6~M0Y7&<`k1!e=)lJTR~fH+^UAG+QC=+*B#kF1md2Cl z`SY6Tg_|Iy2)z|Yu$7#Nic^OlEbt8enD`}m&)k{*&*A;<8#!!Bx}PlR;of*vhC<@V zW)GZCQ1m_ERLU(Z{PaR|)TlL&dw94QWAl6RhORz8nO$$XlMOFnt7*r52@Ro;ybVPW zEApJJGoR>Ar#wadVl`8oz^kC^3pLp^XmH+B0$h+@&TjGyVQrc>vj$EQ;*-;&rZl+70O*gE6U1LF%M-ux;U z>ZY8X03Y#qW9(rd8sUG+tSwNk7TLenk&eWu>1z>(EXQFb>SlquYj@-aLh$Z!oL9k$!^~AbyKR@A zy2}tkTb9)yg?N+_JKG~^Lq6N18T0^KtP#_8u3U#6C|jC-zf}=dxAO(lenEBAXXw)z zB4MI|sDQM?8R8m}w?pUfy5Ad?SH;G3X#QYt)_#V((Reahz)%k)A#o7~S`2y|#UCyC`GDMPC??b`W~k;k33~px;Fk7iW6Q5bKvb<@{hWFx zJXGN(Ku=H2W19r5{_lJYXL?2INEpJ`=DMN{)jg?{G#t$>S`gstaW z(PGk%jUhABTxUFIC7xL zva6ZJvvEq&az#T_eBOy4B^t&7g%-D6972WnMwl33uI@nnq~Z8kwdF$;Pe15A@8&sX zwe5=G)OPWS>7QQRB$>}OAxgDQMjE{9l0_R~ff+09a>dhhY#xWg>=jS345ZvDmK>$b z#4C+`E7*Vdf-0Nbc%pTSF8|w~Td-jxCdTcX~7(5s?PYH-jt={;#R#4(NHqWQoTM7IuLC?(8Nz3d%k%#&bwIFx_V@AI7ZBs z{)}c}uZXm8wf|#?732Hr2`ifCW6I+guJLb(-k`kSyc)sN8Ci;NshyMg4atl{2`>)8&H$vyykxyJ8M)M1RZW+EM_0I7p#%a(UV8D}IK8*?h!%l1g5~VnR z_yU=RiQ7!IDi43lOU4y&BZ`zDxavQ6Hq1UY_yV*DA`m$J*vP{I@6;grw{b5&O%`%0 zA3z)6pbLELAD3p^^*Dh;SX^x<2A1(!OPt%8ZiKuD1HcmM!)m64tyA_<_S!6Z+5l5; z*mhVe1Ou;W41ahaxRnRJ-r$(#5_g5el81&x5-%ph27E;T5JSX7eF3`qN0A=iFjSrb z18}*0`Z)LBDzpL2dVuCYs32Rj{?OI*1Q?>(slQtAwR+i;WD~V=R7JBEzA%4Jc@Ii! zhI!$-4%Y%PaDvK|#qravc!cnNcrxz!2Fdh!DK>JuyKyCJTcP?5UB zJ?V{15v+rYJ)dxP@D5mS!5;`sv;*ESdaT)V+M5sTvQ8ex4plkt_+} z5O5ZLYimDpsSz+9=yDy|;XmK%nNga?zUpkTIeC;Rxd=2!(hVni^^XIN%cTgTM7nN& z@>Q%jN-)36s`Jorq$kf!cFz`p~E<4JEWX!42G#E`u9rVWWD zm~*M#r~$qDw5$r&u8EYnQBPCXC!%0?{JP8kILFj$dF&#JEbM8q-l5Nu=5o1#4Vp}t zyPSdhY4b_&XgS7g1uw;6CajUex-_dp-E^5#Nmb!V!`09I+XBYKs7{BD{c`;yzj6tI zjPA1*e$Q%F1jdJY%Ri{Bx;9MxX&FX1b0IybkW&t;@sb}5-bPVHUca^UWX_~a?5@p? z($o_F1gH)0bWb4`L8|JgL+iEK_i$4@?~X=2zPJjR_nAZ$3`T6)#?Q`Wy_Xx~6?043 z8uKG0M_FY!aI+4jJdPi|)7cg{t!D5!AzV0EutMlIqYyWrP!c?8$gtCzD~ruISj~7E zOI9p!Da~s5v@P^dT$;wo?278LTAG&m9Xi%Gw$>re*d&6k5A&OhT6x7mR{Bnibsnf;WhV{8d0aCUgg@ty;{y_V#v#|p`nT8!wWwlv=&MZ74H z|M`QU(23In46hDrm0_A*OfX7ib0q>K?Jba+i>o3&c|`Qfr>uq1q~Ye4HrjzC*^Y0x z7=34*SRuTRxh=*K_b zFR8G^4hVe!*h0Qs>@!3+7nU&oOl z00#6h+pGMZS5Fa4K`y}xLILvuvuXwRw!&ASill-}&p$O8f8Jn{U|lGRVg=$MS2Vmd zu+s%|oYUE>YM@e{9r6eJC>gYghuyHHF*ft=EdFl@l_BlO6)Ijbl%=+Ar&~tRw^VJlDzmnEUA72Pmr?rQ-MqPcnObCbw#%Hmu7#wskGV5LQ&CIY z;=-ZpYUOt)M?lin!SHOaq+NgKx4Rn#_xufCO_q={m5-L?p%MYM7}?{c`xZ-(gtMm{8A;f8WgOIoRqSF{5Ml_C1arMwvJ zK*?nLJ*7d|25-)KYM7(ReY!E3Hsr#);ozCVC4(jPfH>|-ZQ#Os^y$IOS$qJ8^H5)$-k4o%JmK9sy>Hg8Ta9?dYGKaqMYl5szG9RfNbVI0@7t2 z*Y;DqI5{oj^~g~?U3)_j{+u@g+&7PFOrd^`&_I$eV=#BJy}TPs8d6hlF@E&ZTsn&B zmdAO-K8=Blc-(db&tqzdg+!E^W^WH)*FfKWZRkIZl~?Dk7fLhX`fmXPN&rUJ?q5so zHmo!Oqr7@9MqW=Ld)ofs?AZeO+^Ky%+XbcLS{ID~Hd*7k{|;rWfMItpV8V5Cd?AsE zb&0NZ2Kot@qZ^*~cJ^!ouror1`ioKiu`m!ALKf3?==A<|9Ndnu1gm?P;AtnEBVZ6G z=-Nn7(`EJP?(uN{A&NId69;DiA!F-W%JidV?IfK>t`M1jzV~}pIBti8Z`6)(5K#oN zxq|@mZ(TKjUfHuu1-2(3f__e20A^?@w?2iTijsR@k&_9-clCMh*UwwE*^)QI8p&odE4bQoF#Qk-EDC zV`pYlkrlzc=Oou*1)p2PW+|k41&Se_H!$8CrYub#F-DDzTdL zNS4vhZ2hU4GG4Yrk>iYx-6KJvQhUZA&MmYs`q6&7%sdjfsSMFP-&U)>-r>zp#es`S zN}Nmo8-%^ohnar;y-lx8!?NCdzXW+WRK;p6v8uHYj>JZVFka`KA-^^~h`jL}f=^wS z{}z3GXy0x13!jkNFXh!L*;=v8E0m-^-E+b&5;>tJVq+b@{|c!MJxFc}R(!KtXnAYg zH)qnV^~1>X&oSkbX{)?ty(OL87{AiuvetD!QR{?n%MLac>-9b5GkbUVg=dtr``Jee zZ$suTytH)R%G$OI?-6PlU1D4<1(Uo5snFlLV|p}R0g~TtHaB?5hb(O z>hVaPQLBRfj-q^*TMdd*CW4Y0!hz=cBR7b32+u?B!||RiC;?Q~l$|n8sba z-f?-mhn^t9;N;oEfcD8J|8u*bc$Ml$yXZs{SjZy2llO=T&SeX_-3KBw_09HW;~T%n z5%&;-(T-WS3>viNV~gu-(mdf|)|Kere=$5|<$k??u<*UZoHX5q2An*|`?N^k)09W~ zoT$k?{GKMd)chgVpdSApDFLwZ{P_wbf;x_*@BVh>y7rUnEkF?dh!%knoM=sW{NQeG z=7pr+@o!xS$@6($3wj461uqN0xe5uy2d(>CAIzG3=5W><-vUq{c5gpox&Um2Q0gT@ zl4HtJ;_hApTS@_}kWS2Lmen_?B*?e=@*H7sr=9ZvrbMtrMFClu0*UVHAdJjA$2Czd zR3|R-R${Ra-H{*?UiVY9sY(|xOREIBy=azA99)15FhYqodoAFpj~z|~=856mPUc~j z&$t-GLWg3sizjQLB90ZYppv7{3?XH6Ed#<=kmjv?R|$2PXk&nXLWH3v#{Qhg6$jdo z@8eyHTo6qV4XUg-^}bNE~1$Mnv`oKEkHz`H)`-ONV_S%wGVcD%4nP#_GqkBlUCqiue_eYr-{)uae-V^K~ zudr)ynZvMy;8*=!F87-*6l@CNUKS$Ku%$}ipRa^%G+wXqm#-@V6gs7v$8Hqn#u9c2 z06-r=niG!q4<#Q(a@P_SQ0iK!u@8{&rm>H%7T$e3Dmv`o`kN{#`FrEBVS5J)TDIQV z1{^Bt#4`X*_zl!-rtLSgJ0RzLsJ|m*T;WX!o+MTEn-ZsHdE~ESLrus!+0WpErfSk=)l?g6=kc`&t z;k9>_tW*@ezjAifDtBn;P0{r{l&JraEzbJpjeBRb!Mk^Z zXq|9kcyKiOYRA%p(Uq6WH`Td^zRNu5hk@RGX%mEQnPSz_O#M+pLKz}E2F~HfT1Uf$ zXpt&*yQIglxNRwurt^|&ZVFfXZEh=8_p~)*mHFG-W2FKg`XAMv(hCwr$!aL;Tm~cZ zLdp@EIB71AflUxi-S#%7;R>8-0#b*cjbmk=_a~So=qsODhE))|uxAfT+m-5k8EeY@ zT=^`8&Du-qoA;U+7Cj)mhr>1@r>O}V<0p5Ec4mFWwr4lDgd&ILSFujHeSME6Zj!fU z+ry$s)=y*u9JAS2s;L-Z&q4<6or^YZtem#40&D-3M!)n0U`~G)gzCck)I#5fF|+4Y zvkDrzA9j0m_N7`4qwc&xtU^Jifn7$h7fbVqU6SIoVyLeT-4)9*6qd7_6@|M;JW^ic z8vu_8H`(^XCh2n+XK3gWSaJ;;zNSsgFh!fbiP~|^E5}KoN$|V2_(dBs2GM*B|FL<{ z7dKH*q zEZD1Lpw3o-%2PuaiMgl8dznylVjv$%`X%Kn8rksf+`p)I6JvWbkm^khxjVT;24pN1qolKtGU4sE@3?zXWWovc4 z&eY?r?q97&LSaotSfb0~8iB=cfd8Y;xSNMs%gqGJiaR}t(H>)>b$#qU za8P7@F7s~DDHM2Y#4Uu@Vyjw3RcC#LAy_2~tyKkXYJxPiI`Q=u6A|iHoXW~!dA$|N zHi`r8$08Elt(qn651&<|GU$ecQfW`4{23qJTiH)0btc#A$bnY96lxkSES&!6SLNdM zVMkS~!v?(?4JC0XB+ulrc&h7uq`=dw?0tQWCaOo_@={f@XU z#IQU0N29|TotIkEm2&%mRsIOul}eF@v4FsG!W=L_>yl(#co2;#s>gOlHmD1c{4~8E z!<}1YGw|O0kpWi-rj;?G@$dg&Gy#$@c<{6ahlwvXp4g^deY)vXv^i3=_aMwUF6I@UtmS-) zypUR_lM1zpO#a{2oBof$o_u&o!Bc*%t6dO13auM{{uv03=r=+MwmCzE?82QJmeX*C zy$2fLJq+Q8N@iA{mY%MNgbGKQb1sE6Rb1qWa;gWvI;1lA*r@n`6r z)lSJrdU#v_Qi`s9&OmB$5AF<{f(ELlberXm<-pau7 zf0&VwpVRu*jn_@->-;do>#~N^!8SYWk(ZI_vl`i~X{*8Km060%E=O<~unz$L2yUx_-qBz8qjrW z-xW1vb$5Zqu85Mr23MkFrs7CNg2fEC_f3%g5&-)&BM}atA)H}OvI#9#sW0Gt{@XqK zSgj*brc}~@{Wz;x7qWFw?J5&(3K?j!fLi|huFvZjX1`KdE!uc8|Mh#@ZG$H{=Qq(8 z8~WX9&&TGir*KIfm15g_=%Q2?pw0Amyslb2(dh$PiZgTfB& zFWQ&Cop8GDP%>i@)B!w0!XROR!DnDHD(h+D=lJ<=XRC%$e-x`BS@-oif7T$28F*VW zAQVl&zE!rZk)`9U7*2O&zYQH;Cj>En4#bQJrpSYU9p9|Oa(Wkf`Q@;1m1?cYo0p@E)Ja_dts>jZ<#`wh~(oUOT0G${Mg!#u`2f^(4Pt_IrM$E}6R# zIp}<|gD=3I5Z;Dyko^4G@E72~<+tOq3q)!C#xhE_67DOAc}A=(y1Jdclb3<$0Q@X5 zJZdXY2Iyr!s=zA8jw3p0OA zI(ya@$bxu=VL*$S_{QmG-RXr%ZU3t=U!?cq+}U|<;Aj_wmCJ%dJi2SCdpSvClEAMk z;j(CRb6cskq}{ALJM!KutYhu>O=~dgK=6VMAufXxjg;JgjMZc72M5FS42z-EBLn24 z%gWgj4X1B5?-`%BhbqJ~<5siWAslmCn+C{&B`iqu`FSPA+rn8~9!VX-qNxvCEJere zDfmwA#||e{CI{FL%h`n?%67`kiAGLT8y^{dmAl&yl%vTH4ocLEClrUfQe~|4BDWzM zpdzm4tz+KzZ9mfqb=_|Z?S0fnD4u0}ThCR&j8e3=Xv4+5AT}@WGV)-aYFD%QxMPy0 zR?3-!cdg7hJU$| zR^Zr14#R2B_cRLFpQ;}<{U6ky?21eRF9=a!w+~Jx=u+C)z3?7LXG~dk?-7n;3E^$` zeS(^83T~<3w$|!{*7GS++0G~(i2%H0z5syk6e4T@0z(j_je+{c{3a)o3An$4qRGWW z@M1}Q3L~MeWKmGwf|q+6&UV-@#OPH8hCtBG`AQ(!^)Y`o8<8XPi%TOkjJF*U)$h=GE&;c-}h;v)!qKqPEAZkss*X`^X3PO3tw<&g159;pJ$ z%IDo~^qG1T2CZ3JKaq-M`Z@tajxzaySyPwClNg?DcbTTL%YjpEon;rJ7SE|85-!4| zgqzBu{O-gbZh$t!Lj_Z&WZxaHNOUb=###kONVMKM;ZE=-@#BCC^G#8`QHr~&Q$s$i zLQbkpss*RgUCqs{neM$FzTX`&6YR03Q&k($NlU(Rk*Afea02nq$!2{??66cR+6KjPLTve&szb$R!u_*H2`a0JKEg*FUOqqA6;d>#^TY4c z_uYth=&Yww>#7abT~GLJyp}6aOuXkJP0kI}ejA##H|1U+@#Kq^3oq(1IVF7+>~cGf z2@w2;V?_qz2TvBV*TW@H8K{^T9U`<(LwKJQBeV{41l(rUsoii=8SMSbT<|a;QI=tH zCje#NT+>^Bl`SZNiT%cqKO+HGk0J8bNcdqIXfapV@Xl3`32w0VM6%%#f@da{9LIL(*shCnx(=oaBc=YFa2}e!SNv@IWF`+O3 z^hJ&@4W>_7MpI)9&jzTb+$l!%VFKE7>H`Q+(xSN&ej7h_rMsPGiuxq!dRiP31VdwA zSd3dydckfOK?tC7P(oOhxUo}OrX+zaCJCGE7a+pw>yZOIIj|pr$}sTvGI3(NSz{0n zufxZ|tRJog7r2nOt^&8Tc|T$tjxJA=EgzygOgDcO#g~)bmXmf!(3Wy6^OeLUT?vWi ze)w(zO(_TVdrtW57F}$=<-Omwn*swes63Kn0$o#cIt>vPx^w2tu`+I?hX>pObSg{d znwda?Th+!sBXS%bv+>U?nsobsh1e5nv8hS6mrR`P`FmTB^+60H55gOT!{xxr&^}3G z^K;wfS`8ebFRJCmTiMm;LnlTq1_X8iGYGuyd)JB z!MUF>KYYf7^!3*bVYg9S)8{;WfJd3{EB_f5Kn!n`r|~|3!+%_h3>iH6>2}O3u_h(s z&{W2iyCv;LVC-->o%f2{#BGd!)vfY}yEF>6T6_|-mo6ie(rPTKLgudn_UHG_-ygp% zIQEn58UJ#Wc4_&U)UhcNj>U=SS~GBzeiocN=JaIpSs(P~eg)c}IC~4D?G(nSXE?CI z{mE5`b3iw7h39+-5qiJ+!(=mKY$ybh4}|wv^m=Q6klJ-E8yy&M_`ygr`GFTngaJY8 z_qP(5IjAUw5_O|vgO2unVS~eVY7vOTl@P zT*t^?;A0spy$|`_PFxs$Ti@TCgJx|fc$Ai4iJR`?gCwkzzb!R&KRWBdA6cR)>+6t0>?VL1F&DTkT*?q6$G2(`aSjIBzQL9Ho^DaIF48mYym%qa# zLo~=F4?_XRk^T89{_$f7LHa;v6Ar>~=`iF4#NJ#^rrh}*)Ap6Y6)Z{>fDd`k?G%Z_ zsKoR-?xdg4PC1bc9JmIHg;KuxLc15uop$L=*pOjv%RLtn=^}CmFMun8Wiw`*xwIrx z8%FlXutXZCU<|W4&Cf7Ye?${-DwSh-0nw-0zq3~tgWmF$F5n4-9phZXGgsB6VWEw__97X0zd{G7?rv85$It zQ@2i}7eo{y!@>F{=XWK%CP&M#-% z{_c1r2Hq0=U9q@AJxZ+GR3w32`L&qyFDf>MRj%AKCw%FI!cZn|M|>%%stgc*X0kKo zWODV*Yip1If@>X3LX3(zNDZ{3YJNqN67TSaf9+Z1ef?Hha`gf;7{1g%bWYg3W*&bvQn7Y6!*7)*++x&W} z!(C6Ep~8PcAT|MMjG_y|zlwVc#T@HT*q5l7KD(sI$TeLuw9KnJGe{B_J9!8@z3H%> z>)6u{FSDqLSv;E*Sh#xFfwiT19r@0vi?n(@(ah!w|Cr%m)jo4E1VQVU7PArrK||8y zXi#!%eidSKkJupBAjGWEQKoV~!&et?*|u18B;wntshD-MiP+E)B1n6`Dwj}7IyV?Y zV>;CDz}!^!G?-Oy#nbK}m?V}fe0&ujITnAL&xO*ke8TiiklBx_QgwJWjl3n_hnion zDm=o$KTu(c~iFa6aKFu{iS66~jJ zNJrB06%MF}-*8lYz+cFF#hil(1zN2^pHXwt(80rGENcY8Dg@`Ad!}J`%_S}>hOOO=k4Y;S1=!W(VqYLerw(hhP z-yDTJ-U6LZw}71#7*s5=w`*2Q}NJEJkd(1jP;49QP z+%vvTs*=o2G^Z=pcvAmATWT)QSya~2De<{a%j5-#=7Xt7E8jS$gcvaONH9aUpG|SK zEHjsNhT%F!7r&Hw5r*5XC_2_3e{X$6qPj@K{_cHjp>z}B-~aPgAOYdRW{@Ru{ zAS;T&^GZ0JigE6+f>|DfrDj=@rE~8Jy!nQTsL;w|4%Z&GO`NYz~ z{Si9(*YDXeafu|!+f|MCe2$vajv%NOuP0)dNVCJSrC0t_qTmUm;zVfsvv?&B3a6** zcdm*R3t=p5TuR0#yV_Fj-jp2eo}Axc%u3~mBI~S<*Wo`ro#PR?aXYS*?yGB14{H0q zHytwn*6mPHMQ@S}m#Z9~$~Nt=RoWp)OIC$|GhGF4szen1`G+|vX?Bf$^#%yj*?mK> z_1mopZgRR>zo(zwXu-;G!-KGXo4M4nN0enSNWnGVwh=dX?pmHrT6fn~?;R&$O&uxt z(L%B^Y#Q^Tm`trfKg+0>8Gef^OfBSgG4{MOT0iM;dLF3kU8F7CA*n$z+LjvhpWsvTVGx`ZY}b zmJdcq+vI1Lc~o?eEPj|tFUDg`w-p@pa<1IMF(v z><$b(|GS_DTFcX*SBUOoTuYJbM-HgPT^T7j(&<~FSE?3Fqv=}?xS`Lqo@D9;oF@f1 z*NM}6^pA#|+u<&$9rp$Ie~$=qUM>0cbicAY>6t{bU3C6Rb_R?;io0CdnIE$hDqQY> z_#;(~F8xy>Mn*ZtPjGQ)IIy%sM74u3NgjAS!Gr!*@D%Vt@am;02VeOmQ7^lriF)7K z)Xf0W5zfvie`mFeE0MonlLOZPz06D+dmP+V&+FG>L_x2Q_=p2nzz8(*>4m*9K*08& zmw0jQlx(3TxXR9+uT?64ze2df`oX-FWFmh%dHsI|{si=j{(Sc5;sXcH+eZG2{jYd% zwAp#)?72Zh0_vrWd`QV=gBo8!=QC=0$nadld%<6=#BYXDk#N-2J5kEfy)JhR8k!xc zsy8!x0{lJxcGY2^3;@w%-I}dFq~*JnjVNI2s(2hSiq2v*u~!njpw`BIus*Vd4bm}w zEw-w0<#-oocAzb85KcVOabwJItA5_!2!uZqwzMuO*^T{9@UzPLk!Eo4YU%8iomYr4 zpf&S4`5-EaQsp`-igFt5q((dPK@l=GEegcyXE3V)XXMV?UIld?F^K^uF)h7pU%!6~5;l6Q5$KF34jMsU6VvWa#w}jwZRDM6ur~EeR7@b6@CMoUab$IT{8qj!Au% z)!<)t&aU;H(RX4K(LcOg*iw!JtUd$^9gRq%#(9#BD!&1jQdC>1JxwCob zHZMm)g{9osJDVnBR##K_g0#rTL+@Q3(q+qLLF}hZR?6J--@F&Kw1`OnCh?et*i3lM zUdV<2k|3DZR*Hw(D6Y{zXmjlo)D&S;Hwq z^=eLoxCl`fQ;@!mK)P&YXs40X+<-K|RQ>TcVsO1E>4d{wmq4K6AV60kcF!KEZ0!EY z&j4Qvv&X6*3u6(Mjn^46Z-CwBECeF@gaPvk2MaH`&)Wc8tr$AfhlAL+Gm=&SFVM&o zG!Ks({4!#aeg=#L`#Hq3;J1n-IJFZhon!)&`lm|*=4DexV7!Yx5rLjHmZe8>#-&Su z3`t8pXosT=oKl&1y90th;U*2f;-mZ#Cje1sVu|xHI*K^EaJi$$!MPxI-$^t~%@71G znv)(oG`@GHifH;{7ZbV*Q}Tf!b14aE6MR3nP;ew|SrXz{RvL@zbfJUA$GK}YA`Ooh zUpWxixVo;8H=+{pC~8c5bPwt1WV;giT+i@LXvs*7%wbCsS4{aQtjt05c{AK?ADDgDW7@!^M6bWW}OhwhsNegC#Kv7@Fj4#HafOBEb>(e_}dCE}vtp?zwjAv%8&3}?z25#WAj1}%+vz2mm& zP_|H&EUqm*k-{mkIIoA42hWGpp$?JXG#}o5{y}B*o33cKD$n_DpS8eb|C4q{)AM1% z5~EkHV_EU{`J$9%P3Z!Mx)7AUCApR;=&$N`}`%{qp9Qb0;7s1H_NEp(bxJ3m31UE1Fm{oG^3T; z{x=!wc@ou}xU!0~(O^Rb=zQQrn?47xbsHr}E13zh0&JNnI1fze% zGf)qr9LAs0Ku*o8W5qjiP?KcwowGo^ecX6Z@pda?DA@e-eF$9FiVSXW9ye6A$ibVm z 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 Lb}j1)!uS6Hh(YFD literal 79547 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 From 8ab30b0c7f99adf4a2a4f0806bf7f1167552652a Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 13 Dec 2024 13:52:01 -0700 Subject: [PATCH 336/395] updating readme --- .../solutions/genai/bedrock_org/README.md | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 6f9e5bda6..7e9091a7b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -20,28 +20,36 @@ The architecture follows best practices for security and scalability and is desi ![Architecture Diagram](./documentation/bedrock-org.png) -This section provides a detailed explanation of the resources shown in the architecture diagram: - -1. **CloudFormation**: Used to define and deploy all the resources in the solution. -2. **CloudWatch Log Group**: Logs for Lambda functions to monitor execution details. -3. **SNS Topic (Alarms)**: For publishing CloudWatch alarm notifications. -4. **SNS Topic (DLQ)**: Dead-letter queue to handle failed Lambda invocations. -5. **KMS Key**: Used to encrypt resources such as SNS topics and SQS queues. -6. **CloudWatch Filters**: Monitors specific log events based on configured patterns. -7. **CloudWatch Alarms**: Triggers notifications based on predefined thresholds. -8. **CloudWatch Link**: Links metrics across accounts and regions. -9. **Bedrock Lambda Function**: Core function responsible for deploying resources. -10. **Audit (Security Tooling) Account**: - - **CloudWatch Dashboard**: Provides an overview of the security state. - - **CloudWatch Sink**: Receives metrics and logs from other accounts. - - **Resource Table**: Maintains metadata for tracking deployed resources. -11. **Bedrock Regions**: - - **CloudWatch Filters**: Region-specific event monitoring. - - **CloudWatch Alarms**: Region-specific alarm configurations. - - **SNS Topic**: Publishes notifications within a region. - - **Config Rules**: Enforces compliance policies. - - **Config Lambdas**: Functions to evaluate and remediate non-compliance. - - **KMS Key**: Encrypts resources in the region. +This section provides a detailed explanation of the resources shown in the updated architecture diagram: + +### Organization Management Account +1. **AWS CloudFormation (1.1)**: Used to define and deploy all resources in the solution. +2. **CloudWatch Lambda Role (1.2)**: Role for enabling CloudWatch access by the Lambda function in the global region. +3. **SNS Topic (1.3)**: Publishes notifications for alarms and other configured events. +4. **Bedrock Lambda Function (1.4)**: Core function responsible for deploying resources and managing configurations across accounts and regions. +5. **CloudWatch Log Group (1.5)**: Logs for monitoring the execution of the Lambda function. +6. **Dead-Letter Queue (DLQ) (1.6)**: Handles failed Lambda invocations. +7. **CloudWatch Filters (1.7)**: Filters specific log events to track relevant activities. +8. **CloudWatch Alarms (1.8)**: Triggers notifications based on preconfigured thresholds. +9. **SNS Topic (1.9)**: Handles notifications for region-specific monitoring. +10. **CloudWatch Link (1.10)**: Links CloudWatch metrics across accounts and regions for centralized observability. +11. **KMS Key (1.11)**: Encrypts sensitive resources such as SNS topics and log data. + +### All Bedrock Accounts +1. **CloudWatch Sharing Role (2.1)**: Role enabling CloudWatch metrics sharing in the global region. +2. **CloudWatch Filters (2.2)**: Region-specific filters to monitor log events for compliance and security. +3. **CloudWatch Alarms (2.3)**: Configured to trigger notifications for specific metric thresholds in each region. +4. **SNS Topic (2.4)**: Publishes notifications for alarms and events in the respective regions. +5. **CloudWatch Link (2.5)**: Links metrics from regional accounts back to the Organization Management Account. +6. **KMS Key (2.6)**: Encrypts region-specific resources such as SNS topics and logs. +7. **Rule Lambda Roles (2.7)**: Lambda execution roles for AWS Config rules in the global region. +8. **Config Rules (2.8)**: Enforces governance and compliance policies in each region. +9. **Config Lambdas (2.9)**: Evaluates and remediates non-compliance with governance policies. + +### Audit (Security Tooling) Account +1. **Resource Table (3.1)**: Maintains metadata for tracking deployed resources and configurations. +2. **CloudWatch Dashboard (3.2)**: Provides a centralized view of the security and compliance state across accounts and regions. +3. **CloudWatch Sink (3.3)**: Aggregates logs and metrics from other accounts and regions for analysis and auditing. --- From 4210e63510423951f62dc4b9d001a459ae2b74c1 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 13 Dec 2024 14:04:00 -0700 Subject: [PATCH 337/395] update readme --- .../solutions/genai/bedrock_org/README.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 7e9091a7b..4a9cfbc2f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -10,7 +10,7 @@ ## Introduction -This solution provides an automated framework for deploying Bedrock organizational controls using AWS CloudFormation. It leverages a Lambda function to configure and deploy AWS Config rules, CloudWatch metrics, and other resources necessary to monitor and enforce governance policies across multiple AWS accounts and regions in an organization. +This solution provides an automated framework for deploying Bedrock organizational security controls using AWS CloudFormation. It leverages a Lambda function to configure and deploy AWS Config rules, CloudWatch metrics, and other resources necessary to monitor and enforce governance policies across multiple AWS accounts and regions in an organization. The architecture follows best practices for security and scalability and is designed for easy extensibility. @@ -23,27 +23,27 @@ The architecture follows best practices for security and scalability and is desi This section provides a detailed explanation of the resources shown in the updated architecture diagram: ### Organization Management Account -1. **AWS CloudFormation (1.1)**: Used to define and deploy all resources in the solution. +1. **AWS CloudFormation (1.1)**: Used to define and deploy resources in the solution. 2. **CloudWatch Lambda Role (1.2)**: Role for enabling CloudWatch access by the Lambda function in the global region. -3. **SNS Topic (1.3)**: Publishes notifications for alarms and other configured events. +3. **SNS Topic (1.3)**: SNS publish to Lambda. Handles fanout configuration of the solution. 4. **Bedrock Lambda Function (1.4)**: Core function responsible for deploying resources and managing configurations across accounts and regions. 5. **CloudWatch Log Group (1.5)**: Logs for monitoring the execution of the Lambda function. 6. **Dead-Letter Queue (DLQ) (1.6)**: Handles failed Lambda invocations. 7. **CloudWatch Filters (1.7)**: Filters specific log events to track relevant activities. 8. **CloudWatch Alarms (1.8)**: Triggers notifications based on preconfigured thresholds. -9. **SNS Topic (1.9)**: Handles notifications for region-specific monitoring. +9. **SNS Topic (1.9)**: Publishes notifications for alarms and events. 10. **CloudWatch Link (1.10)**: Links CloudWatch metrics across accounts and regions for centralized observability. -11. **KMS Key (1.11)**: Encrypts sensitive resources such as SNS topics and log data. +11. **KMS Key (1.11)**: Encrypts SNS topic. ### All Bedrock Accounts -1. **CloudWatch Sharing Role (2.1)**: Role enabling CloudWatch metrics sharing in the global region. +1. **CloudWatch Sharing Role (2.1)**: Role enabling CloudWatch metrics sharing. 2. **CloudWatch Filters (2.2)**: Region-specific filters to monitor log events for compliance and security. -3. **CloudWatch Alarms (2.3)**: Configured to trigger notifications for specific metric thresholds in each region. +3. **CloudWatch Alarms (2.3)**: Configured to trigger notifications for specific metric thresholds. 4. **SNS Topic (2.4)**: Publishes notifications for alarms and events in the respective regions. 5. **CloudWatch Link (2.5)**: Links metrics from regional accounts back to the Organization Management Account. -6. **KMS Key (2.6)**: Encrypts region-specific resources such as SNS topics and logs. -7. **Rule Lambda Roles (2.7)**: Lambda execution roles for AWS Config rules in the global region. -8. **Config Rules (2.8)**: Enforces governance and compliance policies in each region. +6. **KMS Key (2.6)**: Encrypts SNS topic. +7. **Rule Lambda Roles (2.7)**: Lambda execution roles for AWS Config rules. +8. **Config Rules (2.8)**: Enforces governance and compliance policies. 9. **Config Lambdas (2.9)**: Evaluates and remediates non-compliance with governance policies. ### Audit (Security Tooling) Account From 75c45b914d536427b7685f59e961d290895c3e09 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 13 Dec 2024 14:12:41 -0700 Subject: [PATCH 338/395] update readme --- .../solutions/genai/bedrock_org/README.md | 243 +++++++++++++++++- 1 file changed, 234 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 4a9cfbc2f..ff4b2bd2a 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -5,6 +5,7 @@ - [Deployed Resource Details](#deployed-resource-details) - [Implementation Instructions](#implementation-instructions) - [References](#references) +- [JSON Parameters Explanation](#json-parameters-explanation) --- @@ -23,15 +24,15 @@ The architecture follows best practices for security and scalability and is desi This section provides a detailed explanation of the resources shown in the updated architecture diagram: ### Organization Management Account -1. **AWS CloudFormation (1.1)**: Used to define and deploy resources in the solution. -2. **CloudWatch Lambda Role (1.2)**: Role for enabling CloudWatch access by the Lambda function in the global region. -3. **SNS Topic (1.3)**: SNS publish to Lambda. Handles fanout configuration of the solution. -4. **Bedrock Lambda Function (1.4)**: Core function responsible for deploying resources and managing configurations across accounts and regions. -5. **CloudWatch Log Group (1.5)**: Logs for monitoring the execution of the Lambda function. -6. **Dead-Letter Queue (DLQ) (1.6)**: Handles failed Lambda invocations. -7. **CloudWatch Filters (1.7)**: Filters specific log events to track relevant activities. -8. **CloudWatch Alarms (1.8)**: Triggers notifications based on preconfigured thresholds. -9. **SNS Topic (1.9)**: Publishes notifications for alarms and events. +- **(1.1) AWS CloudFormation**: Used to define and deploy resources in the solution. +- **CloudWatch Lambda Role (1.2)**: Role for enabling CloudWatch access by the Lambda function in the global region. +- **SNS Topic (1.3)**: SNS publish to Lambda. Handles fanout configuration of the solution. +- **Bedrock Lambda Function (1.4)**: Core function responsible for deploying resources and managing configurations across accounts and regions. +- **CloudWatch Log Group (1.5)**: Logs for monitoring the execution of the Lambda function. +- **Dead-Letter Queue (DLQ) (1.6)**: Handles failed Lambda invocations. +- **CloudWatch Filters (1.7)**: Filters specific log events to track relevant activities. +- **CloudWatch Alarms (1.8)**: Triggers notifications based on preconfigured thresholds. +- **SNS Topic (1.9)**: Publishes notifications for alarms and events. 10. **CloudWatch Link (1.10)**: Links CloudWatch metrics across accounts and regions for centralized observability. 11. **KMS Key (1.11)**: Encrypts SNS topic. @@ -124,3 +125,227 @@ Once the stack is deployed, the Bedrock Lambda function (`sra-bedrock-org`) will - [CloudWatch Metrics and Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/WhatIsCloudWatch.html) - [AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) - [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html) + + +## JSON Parameters Explanation + +This section explains the parameters in the CloudFormation template that require JSON string values. Each parameter's structure and purpose are described in detail to assist in their configuration. + +### `pBedrockModelEvalBucketRuleParams` +- **Purpose**: Configures a rule to validate a Bedrock Model Evaluation bucket. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "BucketName": "bucket-name" + } + } +- **Fields**: + - `deploy`: Whether the rule should be deployed (`true` or `false`). + - `accounts`: List of account IDs to apply the rule. + - `regions`: List of regions to apply the rule. + - `input_params.BucketName`: Name of the evaluation bucket. + +--- + +### `pBedrockGuardrailsRuleParams` +- **Purpose**: Enforces governance guardrails for Bedrock resources. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "content_filters": "true|false", + "denied_topics": "true|false", + "word_filters": "true|false", + "sensitive_info_filters": "true|false", + "contextual_grounding": "true|false" + } + } +- **Fields**: + - `deploy`: Whether the rule should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `input_params`: Specifies guardrail options (`true` or `false` for each filter). + +--- + +### `pBedrockInvocationLogCWRuleParams` +- **Purpose**: Validates CloudWatch logging for model invocations. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "check_retention": "true|false", + "check_encryption": "true|false" + } + } +- **Fields**: + - `deploy`: Whether the rule should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `input_params.check_retention`: Ensures log retention is configured. + - `input_params.check_encryption`: Ensures logs are encrypted. + +--- + +### `pBedrockInvocationLogS3RuleParams` +- **Purpose**: Validates S3 logging for model invocations. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "check_retention": "true|false", + "check_encryption": "true|false", + "check_access_logging": "true|false", + "check_object_locking": "true|false", + "check_versioning": "true|false" + } + } +- **Fields**: + - `deploy`: Whether the rule should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `input_params.check_retention`: Ensures bucket retention policies are configured. + - `input_params.check_encryption`: Ensures bucket encryption is enabled. + - `input_params.check_access_logging`: Ensures bucket access logging is enabled. + - `input_params.check_object_locking`: Ensures bucket object locking is enabled. + - `input_params.check_versioning`: Ensures bucket versioning is enabled. + +--- + +### `pBedrockCWEndpointsRuleParams` +- **Purpose**: Validates CloudWatch VPC endpoints. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": {} + } +- **Fields**: + - `deploy`: Whether the rule should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `input_params`: This field is currently empty. + +--- + +### `pBedrockS3EndpointsRuleParams` +- **Purpose**: Validates S3 VPC endpoints. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": {} + } +- **Fields**: + - `deploy`: Whether the rule should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `input_params`: This field is currently empty. + +--- + +### `pBedrockServiceChangesFilterParams` +- **Purpose**: Tracks changes to services in CloudTrail logs. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "filter_params": { + "log_group_name": "log-group-name" + } + } +- **Fields**: + - `deploy`: Whether the filter should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `filter_params.log_group_name`: Name of the log group to monitor for changes. + +--- + +### `pBedrockBucketChangesFilterParams` +- **Purpose**: Monitors S3 bucket changes in CloudTrail logs. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "filter_params": { + "log_group_name": "log-group-name", + "bucket_names": ["bucket1", "bucket2"] + } + } +- **Fields**: + - `deploy`: Whether the filter should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `filter_params.log_group_name`: Name of the log group to monitor. + - `filter_params.bucket_names`: List of bucket names to track. + +--- + +### `pBedrockPromptInjectionFilterParams` +- **Purpose**: Filters prompt injection attempts in logs. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "filter_params": { + "log_group_name": "log-group-name", + "input_path": "path.to.input" + } + } +- **Fields**: + - `deploy`: Whether the filter should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `filter_params.log_group_name`: Name of the log group to monitor. + - `filter_params.input_path`: Path to the input field to check. + +--- + +### `pBedrockSensitiveInfoFilterParams` +- **Purpose**: Filters sensitive information from logs. +- **Structure**: + { + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "filter_params": { + "log_group_name": "log-group-name", + "input_path": "path.to.sensitive.data" + } + } +- **Fields**: + - `deploy`: Whether the filter should be deployed. + - `accounts`: List of account IDs. + - `regions`: List of regions. + - `filter_params.log_group_name`: The name of the log group to filter. + - `filter_params.input_path`: Path to the data field containing sensitive information. + +--- + +### `pBedrockCentralObservabilityParams` +- **Purpose**: Configures central observability for Bedrock accounts. +- **Structure**: + { + "deploy": "true|false", + "bedrock_accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"] + } +- **Fields**: + - `deploy`: Whether central observability should be deployed. + - `bedrock_accounts`: List of Bedrock account IDs. + - `regions`: List of regions. From 52d3bc681af043157c385e6737e3003c35be5ba2 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 13 Dec 2024 14:29:08 -0700 Subject: [PATCH 339/395] updating diagram --- .../bedrock_org/documentation/bedrock-org.png | Bin 286687 -> 287370 bytes .../documentation/bedrock-org.pptx | Bin 256206 -> 256277 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.png b/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.png index dc77896b33b5f627a8948839256033ca0e12c0ad..65c8ca8b4630cc6b633296e69e1134e41e41bae3 100644 GIT binary patch literal 287370 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
El46R_d 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�y2j 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 literal 286687 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

;-=IEEIJX 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 z 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 Lb}j1)!uS6Hh(YFD diff --git a/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.pptx b/aws_sra_examples/solutions/genai/bedrock_org/documentation/bedrock-org.pptx index ffeed772cffe5dc7ff922be60c1f9cabe3c44fdb..2d3b301f273a2e4b6ed510b855175ad6def071d4 100644 GIT binary patch delta 18320 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 delta 18333 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 Date: Wed, 18 Dec 2024 09:28:04 -0700 Subject: [PATCH 340/395] fix logic issue --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 8df046cf6..56d40df96 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -91,9 +91,7 @@ def create_table(self, table_name: str) -> None: provisioned_throughput: ProvisionedThroughputTypeDef = {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} # Create table - while retries < max_retries: - self.LOGGER.info(f"Create table attempt {retries+1} of {max_retries}...") - + self.LOGGER.info(f"Creating {table_name} dynamodb table...") try: self.DYNAMODB_CLIENT.create_table( TableName=table_name, KeySchema=key_schema, AttributeDefinitions=attribute_definitions, ProvisionedThroughput=provisioned_throughput From 6c1a61ffdc5602e3f6037ce9e0cfa18a4cf00313 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 18 Dec 2024 09:36:42 -0700 Subject: [PATCH 341/395] updating default value --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index e56cb4bc4..69fbc038b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -72,7 +72,7 @@ Parameters: Description: The email address to notify when an alarm is triggered AllowedPattern: ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ConstraintDescription: Must be a valid email address - Default: 'liamschn+bedrockalarm@amazon.com' + Default: 'alerts@examplecorp.com' pSRAStagingS3BucketName: Description: From 3b354731611507b0d32f318d3cc4e3dee72a50c0 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 09:41:31 -0700 Subject: [PATCH 342/395] skip filter deploy if log group doesn't exist --- .../genai/bedrock_org/lambda/src/app.py | 5 ++++ .../bedrock_org/lambda/src/sra_cloudwatch.py | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 5ea70fa4f..a66e7516d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1058,6 +1058,11 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope cloudwatch.CWLOGS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "logs", region) cloudwatch.CLOUDWATCH_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "cloudwatch", region) LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter_name} CloudWatch metric filter...") + search_log_group, log_group_arn = cloudwatch.find_log_group(filter_params["log_group_name"]) + if search_log_group is False: + LOGGER.info(f"Log group {filter_params['log_group_name']} not found! Skipping {filter_name} filter deployment...") + LIVE_RUN_DATA[f"{filter_name}_CloudWatch"] = f"Log group {filter_params['log_group_name']} not found! Skipped {filter_name} filter deployment." + continue deploy_metric_filter( region, acct, filter_params["log_group_name"], filter_name, filter_pattern, f"{filter_name}-metric", "sra-bedrock", "1" ) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 71ab4d680..de84b8f7c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -568,3 +568,30 @@ def delete_dashboard(self, dashboard_name: str) -> None: except ClientError as e: self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {e}") from None + + def find_log_group(self, log_group_name: str) -> tuple[bool, str]: + """Find the CloudWatch log group for SRA in the organization. + + Args: + log_group_name (str): name of the log group + + Raises: + ValueError: unexpected error + + Returns: + tuple[bool, str]: True if the log group is found, False if not, and the log group ARN + """ + try: + response = self.CWLOGS_CLIENT.describe_log_groups(logGroupNamePrefix=log_group_name) + for log_group in response["logGroups"]: + if log_group["logGroupName"] == log_group_name: + self.LOGGER.info(f"CloudWatch log group {log_group_name} found: {log_group['arn']}") + return True, log_group["arn"] + self.LOGGER.info(f"CloudWatch log group {log_group_name} not found") + return False, "" + except ClientError as error: + if error.response["Error"]["Code"] == "ResourceNotFoundException": + self.LOGGER.info(f"CloudWatch log group {log_group_name} not found. Error code: {error.response['Error']['Code']}") + return False, "" + self.LOGGER.info(self.UNEXPECTED) + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None \ No newline at end of file From 2e482524df363206f98566d9cc34ccaba3d966e8 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 09:48:43 -0700 Subject: [PATCH 343/395] fixing flake8 issues --- .../genai/bedrock_org/lambda/src/app.py | 34 ++++++++++--------- .../bedrock_org/lambda/src/sra_cloudwatch.py | 2 +- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a66e7516d..6aa83b282 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1060,8 +1060,9 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter_name} CloudWatch metric filter...") search_log_group, log_group_arn = cloudwatch.find_log_group(filter_params["log_group_name"]) if search_log_group is False: - LOGGER.info(f"Log group {filter_params['log_group_name']} not found! Skipping {filter_name} filter deployment...") - LIVE_RUN_DATA[f"{filter_name}_CloudWatch"] = f"Log group {filter_params['log_group_name']} not found! Skipped {filter_name} filter deployment." + search_message = f"Log group {filter_params['log_group_name']} not found! Skiped {filter_name} filter deployment..." + LOGGER.info(search_message) + LIVE_RUN_DATA[f"{filter_name}_CloudWatch"] = search_message continue deploy_metric_filter( region, acct, filter_params["log_group_name"], filter_name, filter_pattern, f"{filter_name}-metric", "sra-bedrock", "1" @@ -2299,6 +2300,8 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001 global LAMBDA_START global LAMBDA_FINISH global LAMBDA_RECORD_ID + global DRY_RUN + LAMBDA_START = dynamodb.get_date_time() LOGGER.info(event) LOGGER.info({"boto3 version": boto3.__version__}) @@ -2328,7 +2331,6 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001 LOGGER.info("DELETE EVENT!!") # Set DRY_RUN to False if we are deleting via CloudFormation (should do this with Terraform as well); stack will be gone. if RESOURCE_TYPE != "Other": - global DRY_RUN DRY_RUN = False delete_event(event, context) @@ -2353,20 +2355,20 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001 "end_time": LAMBDA_FINISH, "lambda_result": "SUCCESS", } + if DRY_RUN is False: + item_found, find_result = dynamodb.find_item( + STATE_TABLE, + SOLUTION_NAME, + { + "arn": context.invoked_function_arn, + }, + ) - item_found, find_result = dynamodb.find_item( - STATE_TABLE, - SOLUTION_NAME, - { - "arn": context.invoked_function_arn, - }, - ) - - if item_found is True: - sra_resource_record_id = find_result["record_id"] - update_state_table_record(sra_resource_record_id, lambda_data) - else: - LOGGER.info(f"Lambda record not found in {STATE_TABLE} table so unable to update it.") + if item_found is True: + sra_resource_record_id = find_result["record_id"] + update_state_table_record(sra_resource_record_id, lambda_data) + else: + LOGGER.info(f"Lambda record not found in {STATE_TABLE} table so unable to update it.") return { "statusCode": 200, diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index de84b8f7c..526af715b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -594,4 +594,4 @@ def find_log_group(self, log_group_name: str) -> tuple[bool, str]: self.LOGGER.info(f"CloudWatch log group {log_group_name} not found. Error code: {error.response['Error']['Code']}") return False, "" self.LOGGER.info(self.UNEXPECTED) - raise ValueError(f"Unexpected error executing Lambda function. {error}") from None \ No newline at end of file + raise ValueError(f"Unexpected error executing Lambda function. {error}") from None From a45c887afc1df492255cf9fe9ead444be5b5edd3 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 10:02:34 -0700 Subject: [PATCH 344/395] fixing dry_run/state_table issue --- .../genai/bedrock_org/lambda/src/app.py | 160 ++++++++++-------- 1 file changed, 88 insertions(+), 72 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 6aa83b282..376b76638 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -979,29 +979,30 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope DRY_RUN_DATA["KMSAliasCreate"] = "DRY_RUN: Create SRA alarm KMS key alias" else: LOGGER.info(f"Found SRA alarm KMS key: {alarm_key_id}") - # Add KMS resource records to sra state table - add_state_table_record( - "kms", - "implemented", - "alarms sns kms key", - "key", - f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", - acct, - region, - alarm_key_id, - alarm_key_id, - ) - add_state_table_record( - "kms", - "implemented", - "alarms sns kms alias", - "alias", - f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", - acct, - region, - ALARM_SNS_KEY_ALIAS, - alarm_key_id, - ) + if DRY_RUN is False: + # Add KMS resource records to sra state table + add_state_table_record( + "kms", + "implemented", + "alarms sns kms key", + "key", + f"arn:aws:kms:{region}:{acct}:key/{alarm_key_id}", + acct, + region, + alarm_key_id, + alarm_key_id, + ) + add_state_table_record( + "kms", + "implemented", + "alarms sns kms alias", + "alias", + f"arn:aws:kms:{region}:{acct}:alias/{ALARM_SNS_KEY_ALIAS}", + acct, + region, + ALARM_SNS_KEY_ALIAS, + alarm_key_id, + ) # 4b) SNS topics for alarms sns.SNS_CLIENT = sts.assume_role(acct, sts.CONFIGURATION_ROLE, "sns", region) @@ -1048,9 +1049,10 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info(f"{SOLUTION_NAME}-alarms SNS topic already exists.") alarm_topic_arn = topic_search # add SNS state table record - add_state_table_record( - "sns", "implemented", "sns topic for alarms", "topic", alarm_topic_arn, acct, region, f"{SOLUTION_NAME}-alarms" - ) + if DRY_RUN is False: + add_state_table_record( + "sns", "implemented", "sns topic for alarms", "topic", alarm_topic_arn, acct, region, f"{SOLUTION_NAME}-alarms" + ) # 4c) Cloudwatch metric filters and alarms if DRY_RUN is False: @@ -1141,7 +1143,8 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 oam_sink_arn = search_oam_sink[1] LOGGER.info(f"CloudWatch observability access manager sink found: {oam_sink_arn}") # add OAM sink state table record - add_state_table_record("oam", "implemented", "oam sink", "sink", oam_sink_arn, ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, "oam_sink") + if DRY_RUN is False: + add_state_table_record("oam", "implemented", "oam sink", "sink", oam_sink_arn, ssm_params.SRA_SECURITY_ACCT, sts.HOME_REGION, "oam_sink") # 5b) OAM Sink policy in security account cloudwatch.SINK_POLICY = CLOUDWATCH_OAM_SINK_POLICY["sra-oam-sink-policy"] @@ -1225,16 +1228,17 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 ) xacct_role_arn = search_iam_role[1] # add cross account role state table record - add_state_table_record( - "iam", - "implemented", - "cross account sharing role", - "role", - xacct_role_arn, - bedrock_account, - iam.get_iam_global_region(), - cloudwatch.CROSS_ACCOUNT_ROLE_NAME, - ) + if DRY_RUN is False: + add_state_table_record( + "iam", + "implemented", + "cross account sharing role", + "role", + xacct_role_arn, + bedrock_account, + iam.get_iam_global_region(), + cloudwatch.CROSS_ACCOUNT_ROLE_NAME, + ) # 5d) Attach managed policies to CloudWatch-CrossAccountSharingRole IAM role cross_account_policies = [ @@ -1287,7 +1291,8 @@ def deploy_central_cloudwatch_observability(event: dict) -> None: # noqa: CCR00 LOGGER.info(f"CloudWatch observability access manager link found in {bedrock_account} in {bedrock_region}") oam_link_arn = search_oam_link[1] # add OAM link state table record - add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") + if DRY_RUN is False: + add_state_table_record("oam", "implemented", "oam link", "link", oam_link_arn, bedrock_account, bedrock_region, "oam_link") def deploy_cloudwatch_dashboard(event: dict) -> None: @@ -1333,16 +1338,17 @@ def deploy_cloudwatch_dashboard(event: dict) -> None: DRY_RUN_DATA["CloudWatchDashboardCreate"] = "DRY_RUN: Create CloudWatch observability dashboard" else: LOGGER.info(f"Cloudwatch dashboard already exists: {search_dashboard[1]}") - add_state_table_record( - "cloudwatch", - "implemented", - "cloudwatch dashboard", - "dashboard", - search_dashboard[1], - ssm_params.SRA_SECURITY_ACCT, - sts.HOME_REGION, - SOLUTION_NAME, - ) + if DRY_RUN is False: + add_state_table_record( + "cloudwatch", + "implemented", + "cloudwatch dashboard", + "dashboard", + search_dashboard[1], + ssm_params.SRA_SECURITY_ACCT, + sts.HOME_REGION, + SOLUTION_NAME, + ) def remove_cloudwatch_dashboard() -> None: @@ -1399,21 +1405,24 @@ def create_event(event: dict, context: Any) -> str: execution_role_arn = lambdas.get_lambda_execution_role(os.environ["AWS_LAMBDA_FUNCTION_NAME"]) execution_role_name = execution_role_arn.split("/")[-1] LOGGER.info(f"Adding state table record for lambda IAM execution role: {execution_role_arn}") - add_state_table_record( - "iam", "implemented", "lambda execution role", "role", execution_role_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, execution_role_name - ) - # add lambda function state table record - LOGGER.info(f"Adding state table record for lambda function: {context.invoked_function_arn}") - LAMBDA_RECORD_ID = add_state_table_record( - "lambda", - "implemented", - "bedrock solution function", - "lambda", - context.invoked_function_arn, - sts.MANAGEMENT_ACCOUNT, - sts.HOME_REGION, - context.function_name, - ) + if DRY_RUN is False: + # add lambda execution role state table record + LOGGER.info(f"Adding state table record for lambda execution role: {execution_role_name}") + add_state_table_record( + "iam", "implemented", "lambda execution role", "role", execution_role_arn, sts.MANAGEMENT_ACCOUNT, sts.HOME_REGION, execution_role_name + ) + # add lambda function state table record + LOGGER.info(f"Adding state table record for lambda function: {context.invoked_function_arn}") + LAMBDA_RECORD_ID = add_state_table_record( + "lambda", + "implemented", + "bedrock solution function", + "lambda", + context.invoked_function_arn, + sts.MANAGEMENT_ACCOUNT, + sts.HOME_REGION, + context.function_name, + ) # 1) Stage config rule lambda code (global/home region) deploy_stage_config_rule_lambda_code() @@ -1999,7 +2008,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: # noqa: CFQ001, CC if role_arn is None: role_arn = "" # add IAM role state table record - add_state_table_record("iam", "implemented", "role for config rule", "role", role_arn, account_id, "Global", rule_name) + if DRY_RUN is False: + add_state_table_record("iam", "implemented", "role for config rule", "role", role_arn, account_id, "Global", rule_name) iam.SRA_POLICY_DOCUMENTS["sra-lambda-basic-execution"]["Statement"][0]["Resource"] = iam.SRA_POLICY_DOCUMENTS[ # noqa: ECE001 "sra-lambda-basic-execution" @@ -2028,9 +2038,10 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: # noqa: CFQ001, CC else: LOGGER.info(f"{rule_name}-lamdba-basic-execution IAM policy already exists") # add IAM policy state table record - add_state_table_record( - "iam", "implemented", "policy for config rule role", "policy", policy_arn, account_id, "Global", f"{rule_name}-lamdba-basic-execution" - ) + if DRY_RUN is False: + add_state_table_record( + "iam", "implemented", "policy for config rule role", "policy", policy_arn, account_id, "Global", f"{rule_name}-lamdba-basic-execution" + ) policy_arn2 = f"arn:{sts.PARTITION}:iam::{account_id}:policy/{rule_name}" iam_policy_search2 = iam.check_iam_policy_exists(policy_arn2) @@ -2047,7 +2058,8 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: # noqa: CFQ001, CC else: LOGGER.info(f"{rule_name} IAM policy already exists") # add IAM policy state table record - add_state_table_record("iam", "implemented", "policy for config rule", "policy", policy_arn2, account_id, "Global", rule_name) + if DRY_RUN is False: + add_state_table_record("iam", "implemented", "policy for config rule", "policy", policy_arn2, account_id, "Global", rule_name) policy_attach_search1 = iam.check_iam_policy_attached(rule_name, policy_arn) if policy_attach_search1 is False: @@ -2128,7 +2140,8 @@ def deploy_lambda_function(account_id: str, rule_name: str, role_arn: str, regio LOGGER.info(f"{rule_name} already exists in {account_id}. Search result: {lambda_function_search}") lambda_arn = lambda_function_search # add Lambda state table record - add_state_table_record("lambda", "implemented", "lambda for config rule", "lambda", lambda_arn, account_id, region, rule_name) + if DRY_RUN is False: + add_state_table_record("lambda", "implemented", "lambda for config rule", "lambda", lambda_arn, account_id, region, rule_name) return lambda_arn @@ -2179,7 +2192,8 @@ def deploy_config_rule(account_id: str, rule_name: str, lambda_arn: str, region: LOGGER.info(f"{rule_name} config rule already exists.") config_rule_arn = config_rule_search[1]["ConfigRules"][0]["ConfigRuleArn"] # add Config rule state table record - add_state_table_record("config", "implemented", "config rule", "rule", config_rule_arn, account_id, region, rule_name) + if DRY_RUN is False: + add_state_table_record("config", "implemented", "config rule", "rule", config_rule_arn, account_id, region, rule_name) def deploy_metric_filter( @@ -2211,7 +2225,8 @@ def deploy_metric_filter( else: LOGGER.info(f"Metric filter {filter_name} already exists.") # add metric filter state table record - add_state_table_record("cloudwatch", "implemented", "log metric filter", "filter", metric_filter_arn, acct, region, filter_name) + if DRY_RUN is False: + add_state_table_record("cloudwatch", "implemented", "log metric filter", "filter", metric_filter_arn, acct, region, filter_name) def deploy_metric_alarm( # noqa: CFQ002 @@ -2279,7 +2294,8 @@ def deploy_metric_alarm( # noqa: CFQ002 else: LOGGER.info(f"Metric alarm {alarm_name} already exists.") # add metric alarm state table record - add_state_table_record("cloudwatch", "implemented", "cloudwatch metric alarm", "alarm", alarm_arn, acct, region, alarm_name) + if DRY_RUN is False: + add_state_table_record("cloudwatch", "implemented", "cloudwatch metric alarm", "alarm", alarm_arn, acct, region, alarm_name) def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001 From 7db8cba43ba1ed2124580d9f7733838fc4d095de Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 10:14:09 -0700 Subject: [PATCH 345/395] skipping checkov error --- aws_sra_examples/terraform/solutions/providers.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/terraform/solutions/providers.tf b/aws_sra_examples/terraform/solutions/providers.tf index deacd7c9d..61b48dbc9 100644 --- a/aws_sra_examples/terraform/solutions/providers.tf +++ b/aws_sra_examples/terraform/solutions/providers.tf @@ -4,6 +4,7 @@ ######################################################################## terraform { + # checkov:skip=CKV_TF_3:Ensure state files are locked required_providers { aws = ">= 5.1.0" } From 39a6b38867ba2f35c29912c6ded595eedf20ce29 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 10:24:29 -0700 Subject: [PATCH 346/395] updating perms --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 69fbc038b..1a75a9637 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -488,6 +488,7 @@ Resources: - 'logs:DescribeMetricFilters' - 'logs:TagResource' - 'logs:Link' + - 'logs:DescribeLogGroups' Resource: - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:metric-filter:*' - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*' From 314c66afe89f80d14092d393ae1cc89a4157a251 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 11:32:23 -0700 Subject: [PATCH 347/395] spelling error --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 376b76638..15d2793b4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1062,7 +1062,7 @@ def deploy_metric_filters_and_alarms(region: str, accounts: list, resource_prope LOGGER.info(f"Filter deploy parameter is 'true'; deploying {filter_name} CloudWatch metric filter...") search_log_group, log_group_arn = cloudwatch.find_log_group(filter_params["log_group_name"]) if search_log_group is False: - search_message = f"Log group {filter_params['log_group_name']} not found! Skiped {filter_name} filter deployment..." + search_message = f"Log group {filter_params['log_group_name']} not found! Skipped {filter_name} filter deployment..." LOGGER.info(search_message) LIVE_RUN_DATA[f"{filter_name}_CloudWatch"] = search_message continue From effa7b7c0ae6c1a645044a21816b6ffac996084c Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 12:38:32 -0700 Subject: [PATCH 348/395] fix constraint description --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 1a75a9637..0090f9253 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -109,10 +109,10 @@ Parameters: Type: String Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock IAM User Access Config Rule Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*{.*}}$ ConstraintDescription: "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), - 'regions' (array of region names), and 'input_params' object/dict (can be empty or contain 'BucketName'). Arrays can be empty. + 'regions' (array of region names), and 'input_params' object/dict. Arrays can be empty. Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {}} or {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" From b5ba4b6b45ea822b0b6fe2bbf032e67f1ab5fad7 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 17:17:11 -0700 Subject: [PATCH 349/395] fix multiple accounts for eval job --- .../sra_bedrock_check_eval_job_bucket/app.py | 24 +++++++++++++++---- .../templates/sra-bedrock-org-main.yaml | 4 ++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index ab64cfadd..db2d687e1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -29,6 +29,7 @@ # Define the AWS Config rule parameters RULE_NAME = "sra-bedrock-check-eval-job-bucket" SERVICE_NAME = "bedrock.amazonaws.com" +BUCKET_NAME = "" def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: U100, CCR001, C901 @@ -41,14 +42,23 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: Returns: tuple[str, str]: The compliance status and annotation """ + global BUCKET_NAME LOGGER.info(f"Evaluate Compliance Event: {event}") # Initialize AWS clients s3 = boto3.client("s3") - + sts = boto3.client("sts") + account = sts.get_caller_identity().get("Account") # Get rule parameters params = ast.literal_eval(event["ruleParameters"]) LOGGER.info(f"Parameters: {params}") - bucket_name = params.get("BucketName", "") + LOGGER.info(f"Account: {account}") + buckets = params.get("Buckets", {account: ""}) + LOGGER.info(f"Buckets: {buckets}") + buckets = ast.literal_eval(buckets) + bucket_name = buckets.get(account, "") + LOGGER.info(f"Bucket Name: {bucket_name}") + BUCKET_NAME = bucket_name + check_retention = params.get("CheckRetention", "true").lower() != "false" check_encryption = params.get("CheckEncryption", "true").lower() != "false" check_logging = params.get("CheckLogging", "true").lower() != "false" @@ -56,6 +66,8 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: check_versioning = params.get("CheckVersioning", "true").lower() != "false" # Check if the bucket exists + if bucket_name == "": + return build_evaluation("NOT_APPLICABLE", "No bucket name provided") if not check_bucket_exists(bucket_name): return build_evaluation("NOT_APPLICABLE", f"Bucket {bucket_name} does not exist or is not accessible") @@ -64,6 +76,7 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: # Check retention if check_retention: + LOGGER.info(f"Checking retention policy for bucket {bucket_name}") try: retention = s3.get_bucket_lifecycle_configuration(Bucket=bucket_name) if not any(rule.get("Expiration") for rule in retention.get("Rules", [])): @@ -75,6 +88,7 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: # Check encryption if check_encryption: + LOGGER.info(f"Checking encryption for bucket {bucket_name}") try: encryption = s3.get_bucket_encryption(Bucket=bucket_name) if "ServerSideEncryptionConfiguration" not in encryption: @@ -86,6 +100,7 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: # Check logging if check_logging: + LOGGER.info(f"Checking logging for bucket {bucket_name}") logging = s3.get_bucket_logging(Bucket=bucket_name) if "LoggingEnabled" not in logging: compliance_type = "NON_COMPLIANT" @@ -93,6 +108,7 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: # Check object locking if check_object_locking: + LOGGER.info(f"Checking object locking for bucket {bucket_name}") try: object_locking = s3.get_object_lock_configuration(Bucket=bucket_name) if "ObjectLockConfiguration" not in object_locking: @@ -104,6 +120,7 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: # Check versioning if check_versioning: + LOGGER.info(f"Checking versioning for bucket {bucket_name}") versioning = s3.get_bucket_versioning(Bucket=bucket_name) if versioning.get("Status") != "Enabled": compliance_type = "NON_COMPLIANT" @@ -157,12 +174,11 @@ def lambda_handler(event: dict, context: Any) -> None: LOGGER.info(f"Lambda Handler Event: {event}") evaluation = evaluate_compliance(event, context) config = boto3.client("config") - params = ast.literal_eval(event["ruleParameters"]) config.put_evaluations( Evaluations=[ { "ComplianceResourceType": "AWS::S3::Bucket", - "ComplianceResourceId": params.get("BucketName"), + "ComplianceResourceId": BUCKET_NAME, "ComplianceType": evaluation["ComplianceType"], # type: ignore "Annotation": evaluation["Annotation"], # type: ignore "OrderingTimestamp": evaluation["OrderingTimestamp"], # type: ignore diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 0090f9253..0e9855f7e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -96,9 +96,9 @@ Parameters: pBedrockModelEvalBucketRuleParams: Type: String - Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"BucketName": "model-invocation-log-bucket-444455556666-us-west-2"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"Buckets": {"444455556666": "model-invocation-log-bucket-444455556666"},"CheckRetention": "true", "CheckEncryption": "true", "CheckLogging": "true", "CheckObjectLocking": "true", "CheckVersioning": "true"}}' Description: Bedrock Model Evaluation Job Config Rule Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*(\{\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*)?},\s*"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$ ConstraintDescription: "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), 'regions' (array of region names), and 'input_params' object (can be empty or contain 'BucketName'). Arrays can be empty. From e97443042dfad31737c5948b816d2e419eefcbac Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 19 Dec 2024 17:27:42 -0700 Subject: [PATCH 350/395] update param validation --- .../solutions/genai/bedrock_org/lambda/src/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 15d2793b4..cdbd2a684 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -164,7 +164,9 @@ def load_sra_cloudwatch_dashboard() -> dict: "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*(\{\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*)?},\s*' + + r'"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*' + + r'"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$', "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' From e7a0feffa0ea28987a808b2823e2b6188af3f12d Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 20 Dec 2024 11:35:51 -0700 Subject: [PATCH 351/395] fix regex --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index cdbd2a684..ba89e85da 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -168,7 +168,7 @@ def load_sra_cloudwatch_dashboard() -> dict: + r'"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*' + r'"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$', "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketName"\s*:\s*"([a-zA-Z0-9-]*)"\s*)?})\}$', + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*\{(\s*"content_filters"\s*:\s*"(true|false)")?(\s*,\s*"denied_topics"\s*:\s*' + r'"(true|false)")?(\s*,\s*"word_filters"\s*:\s*"(true|false)")?(\s*,\s*"sensitive_info_filters"\s*:\s*"(true|false)")?(\s*,\s*' diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 0e9855f7e..0fb8d584f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -109,7 +109,7 @@ Parameters: Type: String Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {}}' Description: Bedrock IAM User Access Config Rule Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*{.*}}$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$ ConstraintDescription: "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), 'regions' (array of region names), and 'input_params' object/dict. Arrays can be empty. From 340b3042bc391238b5dd2c41b50fd0daf4a2e454 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 20 Dec 2024 11:43:56 -0700 Subject: [PATCH 352/395] update constraintdescription --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 0fb8d584f..d134bbfea 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -101,9 +101,9 @@ Parameters: AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*(\{\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*)?},\s*"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$ ConstraintDescription: "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), - 'regions' (array of region names), and 'input_params' object (can be empty or contain 'BucketName'). Arrays can be empty. - Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {\"s3BucketName\": \"my-bucket\"}} or - {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {}}" + 'regions' (array of region names), and 'input_params' object/dict. Arrays can be empty. + Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {\"Buckets\": {\"123456789012\": \"model-invocation-log-bucket-123456789012\"}, \"CheckRetention\": \"true\", \"CheckEncryption\": \"true\", \"CheckLogging\": \"true\", \"CheckObjectLocking\": \"true\", \"CheckVersioning\": \"true\"}} or + {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {\"Buckets\": {}, \"CheckRetention\": \"true\", \"CheckEncryption\": \"true\", \"CheckLogging\": \"true\", \"CheckObjectLocking\": \"true\", \"CheckVersioning\": \"true\"}}" pBedrockIAMUserAccessRuleParams: Type: String From 086780756b6708fabb617a066ebcf7544db79d7f Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 20 Dec 2024 13:45:08 -0700 Subject: [PATCH 353/395] updating regex --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index ba89e85da..67ae4e291 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -164,7 +164,7 @@ def load_sra_cloudwatch_dashboard() -> dict: "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*(\{\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*)?},\s*' + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*\{(\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*,?\s*)*\},\s*' + r'"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*' + r'"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$', "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index d134bbfea..973313173 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -98,7 +98,7 @@ Parameters: Type: String Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"Buckets": {"444455556666": "model-invocation-log-bucket-444455556666"},"CheckRetention": "true", "CheckEncryption": "true", "CheckLogging": "true", "CheckObjectLocking": "true", "CheckVersioning": "true"}}' Description: Bedrock Model Evaluation Job Config Rule Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*(\{\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*)?},\s*"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*\{(\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*,?\s*)*\},\s*"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$ ConstraintDescription: "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), 'regions' (array of region names), and 'input_params' object/dict. Arrays can be empty. From 1736d4218187122724e233235a4051e2c8147fa9 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 13 Jan 2025 14:19:19 -0700 Subject: [PATCH 354/395] fix ast error; fix deployment to multi-region bug --- .../solutions/genai/bedrock_org/README.md | 13 ++++++++--- .../sra_bedrock_check_eval_job_bucket/app.py | 10 ++++----- .../templates/sra-bedrock-org-main.yaml | 22 ++++++++++++++----- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index ff4b2bd2a..1dc6b77ff 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -85,7 +85,7 @@ aws cloudformation create-stack \ ParameterKey=pBedrockOrgLambdaRoleName,ParameterValue=sra-bedrock-org-lambda-role \ ParameterKey=pBedrockAccounts,ParameterValue='["123456789012","234567890123"]' \ ParameterKey=pBedrockRegions,ParameterValue='["us-east-1","us-west-2"]' \ - ParameterKey=pBedrockModelEvalBucketRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"BucketName": "evaluation-bucket"}}' \ + ParameterKey=pBedrockModelEvalBucketRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"BucketNamePrefix": "evaluation-bucket","CheckRetention": "true", "CheckEncryption": "true", "CheckLogging": "true", "CheckObjectLocking": "true", "CheckVersioning": "true"}}' \ ParameterKey=pBedrockIAMUserAccessRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ ParameterKey=pBedrockGuardrailsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' \ ParameterKey=pBedrockVPCEndpointsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' \ @@ -109,6 +109,7 @@ aws cloudformation create-stack \ - Always validate the JSON parameters for correctness to avoid deployment errors. - Ensure the --capabilities CAPABILITY_NAMED_IAM flag is included to allow CloudFormation to create the necessary IAM resources. - An example test fork URL for `pSRARepoZipUrl` is - `https://github.com/liamschn/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip` +- The eval job bucket config rule will append `--` to the `BucketNamePrefix` parameter provided to get the existing bucket name(s). Ensure any S3 eval job bucket names to be checked match this naming convention. 2. Monitor the stack creation progress in the AWS CloudFormation Console or via CLI commands. @@ -132,14 +133,20 @@ Once the stack is deployed, the Bedrock Lambda function (`sra-bedrock-org`) will This section explains the parameters in the CloudFormation template that require JSON string values. Each parameter's structure and purpose are described in detail to assist in their configuration. ### `pBedrockModelEvalBucketRuleParams` -- **Purpose**: Configures a rule to validate a Bedrock Model Evaluation bucket. +- **Purpose**: Configures a rule to validate a Bedrock Model Evaluation bucket. NOTE: `--` will be appended to get the existing bucket name(s). Ensure any S3 eval job bucket names to be checked match this naming convention. - **Structure**: { "deploy": "true|false", "accounts": ["account_id1", "account_id2"], "regions": ["region1", "region2"], "input_params": { - "BucketName": "bucket-name" + "BucketNamePrefix": "bucket-name" + "CheckRetention": "true|false", + "CheckEncryption": "true|false", + "CheckLogging": "true|false", + "CheckObjectLocking": "true|false", + "CheckVersioning": "true|false", + } } - **Fields**: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index db2d687e1..03dbdfc40 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -31,7 +31,6 @@ SERVICE_NAME = "bedrock.amazonaws.com" BUCKET_NAME = "" - def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: U100, CCR001, C901 """Evaluate the S3 bucket for the compliance. @@ -47,15 +46,16 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: # Initialize AWS clients s3 = boto3.client("s3") sts = boto3.client("sts") + session = boto3.Session() + region = session.region_name account = sts.get_caller_identity().get("Account") # Get rule parameters params = ast.literal_eval(event["ruleParameters"]) LOGGER.info(f"Parameters: {params}") LOGGER.info(f"Account: {account}") - buckets = params.get("Buckets", {account: ""}) - LOGGER.info(f"Buckets: {buckets}") - buckets = ast.literal_eval(buckets) - bucket_name = buckets.get(account, "") + bucket_prefix = params.get("BucketNamePrefix", "") + LOGGER.info(f"Bucket Prefix: {bucket_prefix}") + bucket_name = bucket_prefix + "-" + account + "-" + region LOGGER.info(f"Bucket Name: {bucket_name}") BUCKET_NAME = bucket_name diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 973313173..5e77c8dee 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -96,14 +96,24 @@ Parameters: pBedrockModelEvalBucketRuleParams: Type: String - Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"Buckets": {"444455556666": "model-invocation-log-bucket-444455556666"},"CheckRetention": "true", "CheckEncryption": "true", "CheckLogging": "true", "CheckObjectLocking": "true", "CheckVersioning": "true"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"BucketNamePrefix": "model-invocation-log-bucket","CheckRetention": "true", "CheckEncryption": "true", "CheckLogging": "true", "CheckObjectLocking": "true", "CheckVersioning": "true"}}' Description: Bedrock Model Evaluation Job Config Rule Parameters - AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*\{(\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*,?\s*)*\},\s*"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$ + AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketNamePrefix"\s*:\s*(\s*"[a-zA-Z0-9-]+"\s*),\s*"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$ ConstraintDescription: - "Must be a valid JSON string containing: 'deploy' (true/false), 'accounts' (array of account numbers), - 'regions' (array of region names), and 'input_params' object/dict. Arrays can be empty. - Example: {\"deploy\": \"true\", \"accounts\": [\"123456789012\"], \"regions\": [\"us-east-1\"], \"input_params\": {\"Buckets\": {\"123456789012\": \"model-invocation-log-bucket-123456789012\"}, \"CheckRetention\": \"true\", \"CheckEncryption\": \"true\", \"CheckLogging\": \"true\", \"CheckObjectLocking\": \"true\", \"CheckVersioning\": \"true\"}} or - {\"deploy\": \"false\", \"accounts\": [], \"regions\": [], \"input_params\": {\"Buckets\": {}, \"CheckRetention\": \"true\", \"CheckEncryption\": \"true\", \"CheckLogging\": \"true\", \"CheckObjectLocking\": \"true\", \"CheckVersioning\": \"true\"}}" + "The parameter value must be a valid JSON object with the following structure: + { + 'deploy': 'true' or 'false', + 'accounts': an array of numeric AWS account IDs, + 'regions': an array of valid AWS region identifiers, + 'input_params': { + 'BucketNamePrefix': a valid bucket name prefix, + 'CheckRetention': 'true' or 'false', + 'CheckEncryption': 'true' or 'false', + 'CheckLogging': 'true' or 'false', + 'CheckObjectLocking': 'true' or 'false', + 'CheckVersioning': 'true' or 'false' + } + }. Ensure all keys and values conform to the specified types and format." pBedrockIAMUserAccessRuleParams: Type: String From a4831301b104ef6c2e615eee231233347c509ffe Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 14 Jan 2025 14:18:15 -0700 Subject: [PATCH 355/395] add error handling for entityalreadyexists --- .../genai/bedrock_org/lambda/src/sra_iam.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 0b397dd10..822e7df18 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -104,9 +104,13 @@ def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> Dictionary output of a successful CreateRole request """ self.LOGGER.info("Creating role %s.", role_name) - return self.IAM_CLIENT.create_role( - RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", "Value": solution_name}] - ) + try: + return self.IAM_CLIENT.create_role( + RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", "Value": solution_name}] + ) + except ClientError as error: + if error.response["Error"]["Code"] == "EntityAlreadyExists": + self.LOGGER.info(f"{role_name} role already exists!") def create_policy(self, policy_name: str, policy_document: dict, solution_name: str) -> CreatePolicyResponseTypeDef: """Create IAM policy. @@ -120,9 +124,13 @@ def create_policy(self, policy_name: str, policy_document: dict, solution_name: Dictionary output of a successful CreatePolicy request """ self.LOGGER.info(f"Creating {policy_name} IAM policy") - return self.IAM_CLIENT.create_policy( - PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", "Value": solution_name}] - ) + try: + return self.IAM_CLIENT.create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", "Value": solution_name}] + ) + except ClientError as error: + if error.response["Error"]["Code"] == "EntityAlreadyExists": + self.LOGGER.info(f"{policy_name} policy already exists!") def attach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadataTypeDef: """Attach policy to IAM role. From 5c85369a375dd79271d4cd6bf0686d9abe9f2497 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 14 Jan 2025 15:43:17 -0700 Subject: [PATCH 356/395] update example bucketname in template --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 5e77c8dee..349036feb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -226,7 +226,7 @@ Parameters: pBedrockBucketChangesFilterParams: Type: String - Default: '{"deploy": "true", "accounts": ["111122223333"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["model-invocation-log-bucket-444455556666"]}}' + Default: '{"deploy": "true", "accounts": ["111122223333"], "regions": ["us-west-2"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["model-invocation-log-bucket-444455556666-us-west-2"]}}' Description: Bedrock S3 Bucket Changes Filter Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"filter_params"\s*:\s*\{"log_group_name"\s*:\s*"[^"\s]+",\s*"bucket_names"\s*:\s*\[((?:"[^"\s]+"(?:\s*,\s*)?)+)\]\}\}$ ConstraintDescription: > From 5f2a85702858e3da8a4978f527a8e1085dd120d9 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 14 Jan 2025 15:44:48 -0700 Subject: [PATCH 357/395] update example bucketnameprefix --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index 349036feb..e6a77a191 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -96,7 +96,7 @@ Parameters: pBedrockModelEvalBucketRuleParams: Type: String - Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"BucketNamePrefix": "model-invocation-log-bucket","CheckRetention": "true", "CheckEncryption": "true", "CheckLogging": "true", "CheckObjectLocking": "true", "CheckVersioning": "true"}}' + Default: '{"deploy": "true", "accounts": ["444455556666"], "regions": ["us-west-2"], "input_params": {"BucketNamePrefix": "model-eval-job-bucket","CheckRetention": "true", "CheckEncryption": "true", "CheckLogging": "true", "CheckObjectLocking": "true", "CheckVersioning": "true"}}' Description: Bedrock Model Evaluation Job Config Rule Parameters AllowedPattern: ^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketNamePrefix"\s*:\s*(\s*"[a-zA-Z0-9-]+"\s*),\s*"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$ ConstraintDescription: From 19ded41a63c2397639262193661bb75ca37301d0 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 15 Jan 2025 10:20:05 -0700 Subject: [PATCH 358/395] update regex for param validation --- .../solutions/genai/bedrock_org/lambda/src/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index 67ae4e291..a2ff79383 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -164,9 +164,9 @@ def load_sra_cloudwatch_dashboard() -> dict: "SRA-BEDROCK-ACCOUNTS": r'^\[((?:"[0-9]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-REGIONS": r'^\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\]$', "SRA-BEDROCK-CHECK-EVAL-JOB-BUCKET": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' - + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"Buckets"\s*:\s*\{(\s*"[0-9]+"\s*:\s*"[a-zA-Z0-9-]*"\s*,?\s*)*\},\s*' - + r'"CheckRetention"\s*:\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*' - + r'"CheckObjectLocking"\s*:\s*"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$', + + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\s*(?:"BucketNamePrefix"\s*:\s*(\s*"[a-zA-Z0-9-]+"\s*),\s*"CheckRetention"\s*' + + r':\s*"(true|false)",\s*"CheckEncryption"\s*:\s*"(true|false)",\s*"CheckLogging"\s*:\s*"(true|false)",\s*"CheckObjectLocking"\s*:\s*' + + r'"(true|false)",\s*"CheckVersioning"\s*:\s*"(true|false)"\s*)}})$', "SRA-BEDROCK-CHECK-IAM-USER-ACCESS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' + r'\[((?:"[a-z0-9-]+"(?:\s*,\s*)?)*)\],\s*"input_params"\s*:\s*(\{\})\}$', "SRA-BEDROCK-CHECK-GUARDRAILS": r'^\{"deploy"\s*:\s*"(true|false)",\s*"accounts"\s*:\s*\[((?:"[0-9]+"(?:\s*,\s*)?)*)\],\s*"regions"\s*:\s*' From 1cc00d9f31ee5e7852ba1cdae9f868948e2ce97f Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 16 Jan 2025 10:21:31 -0700 Subject: [PATCH 359/395] fix mypy error --- .../solutions/genai/bedrock_org/lambda/src/sra_iam.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 822e7df18..71466ef10 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -14,7 +14,7 @@ import os import urllib.parse from time import sleep -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import boto3 from botocore.config import Config @@ -111,6 +111,7 @@ def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> except ClientError as error: if error.response["Error"]["Code"] == "EntityAlreadyExists": self.LOGGER.info(f"{role_name} role already exists!") + return cast(CreateRoleResponseTypeDef, {"Role": {"Arn": "error"}}) def create_policy(self, policy_name: str, policy_document: dict, solution_name: str) -> CreatePolicyResponseTypeDef: """Create IAM policy. @@ -131,6 +132,7 @@ def create_policy(self, policy_name: str, policy_document: dict, solution_name: except ClientError as error: if error.response["Error"]["Code"] == "EntityAlreadyExists": self.LOGGER.info(f"{policy_name} policy already exists!") + return cast(CreatePolicyResponseTypeDef, {"Policy": {"Arn": "error"}}) def attach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadataTypeDef: """Attach policy to IAM role. From 9bbbbaa999c50514d3dcf67e29c3bf868a1417f4 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 16 Jan 2025 11:01:12 -0700 Subject: [PATCH 360/395] fix flake8 issue --- .../lambda/rules/sra_bedrock_check_eval_job_bucket/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index 03dbdfc40..f23c26697 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -31,6 +31,7 @@ SERVICE_NAME = "bedrock.amazonaws.com" BUCKET_NAME = "" + def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: U100, CCR001, C901 """Evaluate the S3 bucket for the compliance. From 139629530bc084bdab8f25e4fe3107f7914eec04 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 24 Jan 2025 20:54:20 -0700 Subject: [PATCH 361/395] CreateRoleResponseTypeDef and CreatePolicyResponseTypeDef error fix --- .../genai/bedrock_org/lambda/src/sra_iam.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 71466ef10..783454ef1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -14,7 +14,7 @@ import os import urllib.parse from time import sleep -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import boto3 from botocore.config import Config @@ -23,7 +23,7 @@ if TYPE_CHECKING: from mypy_boto3_cloudformation import CloudFormationClient from mypy_boto3_iam.client import IAMClient - from mypy_boto3_iam.type_defs import CreatePolicyResponseTypeDef, CreateRoleResponseTypeDef, EmptyResponseMetadataTypeDef + from mypy_boto3_iam.type_defs import EmptyResponseMetadataTypeDef from mypy_boto3_organizations import OrganizationsClient @@ -92,7 +92,7 @@ class SRAIAM: }, } - def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> CreateRoleResponseTypeDef: + def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> dict: """Create IAM role. Args: @@ -105,15 +105,15 @@ def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> """ self.LOGGER.info("Creating role %s.", role_name) try: - return self.IAM_CLIENT.create_role( + return dict(self.IAM_CLIENT.create_role( RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", "Value": solution_name}] - ) + )) except ClientError as error: if error.response["Error"]["Code"] == "EntityAlreadyExists": self.LOGGER.info(f"{role_name} role already exists!") - return cast(CreateRoleResponseTypeDef, {"Role": {"Arn": "error"}}) + return {"Role": {"Arn": "error"}} - def create_policy(self, policy_name: str, policy_document: dict, solution_name: str) -> CreatePolicyResponseTypeDef: + def create_policy(self, policy_name: str, policy_document: dict, solution_name: str) -> dict: """Create IAM policy. Args: @@ -126,13 +126,13 @@ def create_policy(self, policy_name: str, policy_document: dict, solution_name: """ self.LOGGER.info(f"Creating {policy_name} IAM policy") try: - return self.IAM_CLIENT.create_policy( + return dict(self.IAM_CLIENT.create_policy( PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", "Value": solution_name}] - ) + )) except ClientError as error: if error.response["Error"]["Code"] == "EntityAlreadyExists": self.LOGGER.info(f"{policy_name} policy already exists!") - return cast(CreatePolicyResponseTypeDef, {"Policy": {"Arn": "error"}}) + return {"Policy": {"Arn": "error"}} def attach_policy(self, role_name: str, policy_arn: str) -> EmptyResponseMetadataTypeDef: """Attach policy to IAM role. From 86b532420b886f520c0bb2d029169b050750160a Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 24 Jan 2025 22:56:38 -0700 Subject: [PATCH 362/395] working on access denied / encrypted guardrail issue --- .../rules/sra_bedrock_check_guardrail_encryption/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index 186e4d327..0889d2382 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -13,6 +13,7 @@ from typing import Any import boto3 +from botocore.exceptions import ClientError # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -58,7 +59,9 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 return "NON_COMPLIANT", f"The following Bedrock guardrails are not encrypted with a KMS key: {', '.join(unencrypted_guardrails)}" return "COMPLIANT", "All Bedrock guardrails are encrypted with a KMS key" - except Exception as e: + except ClientError as e: + if e.response['Error']['Code'] == 'AccessDeniedException': + return "NON_COMPLIANT", "Access denied to Bedrock guardrails. If encryption is enabled, ensure the IAM role has the necessary permissions to use the KMS key." LOGGER.error(f"Error evaluating Bedrock guardrails encryption: {str(e)}") return "ERROR", f"Error evaluating compliance: {str(e)}" From 8622368a6ad5f08413953d4deae289e66aa7d3cb Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 25 Jan 2025 14:15:31 -0700 Subject: [PATCH 363/395] handling access denied encrypted guardrail error --- .../rules/sra_bedrock_check_guardrail_encryption/app.py | 5 ++++- .../lambda/rules/sra_bedrock_check_guardrails/app.py | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index 0889d2382..f4b881045 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -61,7 +61,10 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 except ClientError as e: if e.response['Error']['Code'] == 'AccessDeniedException': - return "NON_COMPLIANT", "Access denied to Bedrock guardrails. If encryption is enabled, ensure the IAM role has the necessary permissions to use the KMS key." + LOGGER.info(f"Access denied. If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key.") + return "NON_COMPLIANT", ( + "Access denied. If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key." + ) LOGGER.error(f"Error evaluating Bedrock guardrails encryption: {str(e)}") return "ERROR", f"Error evaluating compliance: {str(e)}" diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 6c83d8d09..a1d69ce29 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -15,6 +15,7 @@ from typing import Any import boto3 +from botocore.exceptions import ClientError # Setup Default Logger LOGGER = logging.getLogger(__name__) @@ -95,6 +96,10 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 except bedrock.exceptions.ResourceNotFoundException: LOGGER.warning(f"Guardrail {guardrail_name} (ID: {guardrail_id}) not found") + except ClientError as e: + if e.response['Error']['Code'] == 'AccessDeniedException': + LOGGER.info(f"Access denied to guardrail {guardrail_name} (ID: {guardrail_id}). If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key.") + non_compliant_guardrails[guardrail_name] = ["(access_denied; see log for details)"] except Exception as e: LOGGER.error(f"Error checking guardrail {guardrail_name} (ID: {guardrail_id}): {str(e)}") @@ -108,9 +113,9 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 LOGGER.info(f"Account is COMPLIANT. {annotation}") else: compliance_type = "NON_COMPLIANT" - annotation = "No Bedrock guardrails contain all required features. Missing features per guardrail:\n" + annotation = "No Bedrock guardrails contain all required features. " for guardrail, missing in non_compliant_guardrails.items(): # type: ignore - annotation += f"- {guardrail}: missing {', '.join(missing)}\n" + annotation += f" [{guardrail} is missing {', '.join(missing)}]" LOGGER.info(f"Account is NON_COMPLIANT. {annotation}") evaluation = { From 2e7ff10e11d111bcd315a381e6017f9b51123088 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 25 Jan 2025 15:20:06 -0700 Subject: [PATCH 364/395] error handling update --- .../lambda/rules/sra_bedrock_check_guardrails/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index a1d69ce29..9e071a787 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -96,8 +96,8 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 except bedrock.exceptions.ResourceNotFoundException: LOGGER.warning(f"Guardrail {guardrail_name} (ID: {guardrail_id}) not found") - except ClientError as e: - if e.response['Error']['Code'] == 'AccessDeniedException': + except ClientError as client_error: + if client_error.response['Error']['Code'] == 'AccessDeniedException': LOGGER.info(f"Access denied to guardrail {guardrail_name} (ID: {guardrail_id}). If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key.") non_compliant_guardrails[guardrail_name] = ["(access_denied; see log for details)"] except Exception as e: From 382cf161723783fbff811b1d162bb5133eca3c19 Mon Sep 17 00:00:00 2001 From: liamschn Date: Sat, 25 Jan 2025 15:20:23 -0700 Subject: [PATCH 365/395] fix NoSuchLifecycleConfiguration issue --- .../app.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index 2399298cd..b33b2a3d3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -30,6 +30,8 @@ config_client = boto3.client("config", region_name=AWS_REGION) s3_client = boto3.client("s3", region_name=AWS_REGION) +# Global variables +BUCKET_NAME = "" def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ004, CCR001, C901 """Evaluate if Bedrock Model Invocation Logging is properly configured for S3. @@ -41,6 +43,7 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 tuple[str, str]: Compliance status and annotation message. """ + global BUCKET_NAME # Parse rule parameters params = json.loads(json.dumps(rule_parameters)) if rule_parameters else {} check_retention = params.get("check_retention", "true").lower() == "true" @@ -57,7 +60,7 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 LOGGER.info(f"Bedrock Model Invocation S3 config: {s3_config}") bucket_name = s3_config.get("bucketName", "") LOGGER.info(f"Bedrock Model Invocation S3 bucketName: {bucket_name}") - + BUCKET_NAME = bucket_name if not s3_config or not bucket_name: return "NON_COMPLIANT", "S3 logging is not enabled for Bedrock Model Invocation Logging" @@ -65,9 +68,14 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 issues = [] if check_retention: - lifecycle = s3_client.get_bucket_lifecycle_configuration(Bucket=bucket_name) - if not any(rule.get("Expiration") for rule in lifecycle.get("Rules", [])): - issues.append("retention not set") + try: + lifecycle = s3_client.get_bucket_lifecycle_configuration(Bucket=bucket_name) + if not any(rule.get("Expiration") for rule in lifecycle.get("Rules", [])): + issues.append("retention not set") + except botocore.exceptions.ClientError as client_error: + if client_error.response['Error']['Code'] == 'NoSuchLifecycleConfiguration': + LOGGER.info(f"No lifecycle configuration found for S3 bucket: {bucket_name}") + issues.append("lifecycle not set") if check_encryption: encryption = s3_client.get_bucket_encryption(Bucket=bucket_name) @@ -98,12 +106,11 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 return "INSUFFICIENT_DATA", f"Error evaluating Object Lock configuration: {str(error)}" if issues: - return "NON_COMPLIANT", f"S3 logging enabled but {', '.join(issues)}" + return "NON_COMPLIANT", f"S3 logging to {BUCKET_NAME} enabled but {', '.join(issues)}" return "COMPLIANT", f"S3 logging properly configured for Bedrock Model Invocation Logging. Bucket: {bucket_name}" - - except Exception as e: - LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(e)}") - return "INSUFFICIENT_DATA", f"Error evaluating compliance: {str(e)}" + except botocore.exceptions.ClientError as client_error: + LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(client_error)}") + return "INSUFFICIENT_DATA", f"Error evaluating compliance: {str(client_error)}" def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 From 0df50f178bb3a2cd0375a1ab5af5dfac5f109d27 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 27 Jan 2025 14:29:26 -0700 Subject: [PATCH 366/395] switch to on-demand dynamodb --- .../genai/bedrock_org/lambda/src/sra_dynamodb.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 56d40df96..10b8a0d26 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from mypy_boto3_dynamodb.client import DynamoDBClient from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource - from mypy_boto3_dynamodb.type_defs import AttributeDefinitionTypeDef, KeySchemaElementTypeDef, ProvisionedThroughputTypeDef + from mypy_boto3_dynamodb.type_defs import AttributeDefinitionTypeDef, KeySchemaElementTypeDef class SRADynamoDB: @@ -88,13 +88,15 @@ def create_table(self, table_name: str) -> None: {"AttributeName": "solution_name", "AttributeType": "S"}, # String type {"AttributeName": "record_id", "AttributeType": "S"}, # String type ] - provisioned_throughput: ProvisionedThroughputTypeDef = {"ReadCapacityUnits": 5, "WriteCapacityUnits": 5} # Create table self.LOGGER.info(f"Creating {table_name} dynamodb table...") try: self.DYNAMODB_CLIENT.create_table( - TableName=table_name, KeySchema=key_schema, AttributeDefinitions=attribute_definitions, ProvisionedThroughput=provisioned_throughput + TableName=table_name, + KeySchema=key_schema, + AttributeDefinitions=attribute_definitions, + BillingMode='PAY_PER_REQUEST' # Changed to on-demand capacity mode ) self.LOGGER.info(f"{table_name} dynamodb table created successfully.") except Exception as e: From 20e9a0e4b19d8659f3dce8796d18e69f7401aafc Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 27 Jan 2025 14:29:59 -0700 Subject: [PATCH 367/395] update comment --- .../solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index 10b8a0d26..c772c6042 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -96,7 +96,7 @@ def create_table(self, table_name: str) -> None: TableName=table_name, KeySchema=key_schema, AttributeDefinitions=attribute_definitions, - BillingMode='PAY_PER_REQUEST' # Changed to on-demand capacity mode + BillingMode='PAY_PER_REQUEST' # on-demand capacity mode ) self.LOGGER.info(f"{table_name} dynamodb table created successfully.") except Exception as e: From a24afae8cfc782f134b6ddb8fc445784ff630e46 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 27 Jan 2025 15:57:11 -0700 Subject: [PATCH 368/395] ensuring the policy template remains a template --- .../solutions/genai/bedrock_org/lambda/src/sra_s3.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 77e89b198..eb4d258cc 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -102,16 +102,17 @@ def apply_bucket_policy(self, bucket: str) -> None: Args: bucket (str): Name of the S3 bucket to apply the policy to """ - self.LOGGER.info(self.BUCKET_POLICY_TEMPLATE) - for sid in self.BUCKET_POLICY_TEMPLATE["Statement"]: + self.LOGGER.info(f"Original policy template: {self.BUCKET_POLICY_TEMPLATE}") + policy_template = json.loads(json.dumps(self.BUCKET_POLICY_TEMPLATE)) + for sid in policy_template["Statement"]: if isinstance(sid["Resource"], list): sid["Resource"] = list(map(lambda x: x.replace("BUCKET_NAME", bucket), sid["Resource"])) # noqa C417 else: sid["Resource"] = sid["Resource"].replace("BUCKET_NAME", bucket) - self.LOGGER.info(self.BUCKET_POLICY_TEMPLATE) + self.LOGGER.info(f"Updated policy template: {policy_template}") bucket_policy_response = self.S3_CLIENT.put_bucket_policy( Bucket=bucket, - Policy=json.dumps(self.BUCKET_POLICY_TEMPLATE), + Policy=json.dumps(policy_template), ) self.LOGGER.info(bucket_policy_response) From 5a18c93353d13106f7354d0fd077b5e13c98e052 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 27 Jan 2025 17:13:03 -0700 Subject: [PATCH 369/395] invalidparameterexception arn validation failed handling --- .../solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py index 526af715b..4d965b161 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_cloudwatch.py @@ -481,6 +481,9 @@ def create_oam_link(self, sink_arn: str) -> str: if error.response["Error"]["Code"] == "ConflictException": self.LOGGER.info(f"Observability access manager link for {sink_arn} already exists") return self.find_oam_link(sink_arn)[1] + if error.response["Error"]["Code"] == "InvalidParameterException": + self.LOGGER.info(f"Arn validation may have failed for {sink_arn}") + return "error" self.LOGGER.info(self.UNEXPECTED) raise ValueError(f"Unexpected error executing Lambda function. {error}") from None From f8525ea0bd0d064626f3947d29b6b375a7c80994 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 27 Jan 2025 20:56:28 -0700 Subject: [PATCH 370/395] ensure global region used for iam resources --- aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py index a2ff79383..17d2edc1c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/app.py @@ -1989,7 +1989,7 @@ def deploy_iam_role(account_id: str, rule_name: str) -> str: # noqa: CFQ001, CC IAM role ARN """ global CFN_RESPONSE_DATA - iam.IAM_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "iam", REGION) + iam.IAM_CLIENT = sts.assume_role(account_id, sts.CONFIGURATION_ROLE, "iam", iam.get_iam_global_region()) LOGGER.info(f"Deploying IAM {rule_name} execution role for rule lambda in {account_id}...") role_arn = "" iam_role_search = iam.check_iam_role_exists(rule_name) From bc90b199b8692542dae5ff2a1fff4b5b64e014eb Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 11:06:13 -0700 Subject: [PATCH 371/395] update permissions for other accts --- .../templates/sra-bedrock-org-main.yaml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index e6a77a191..f2ce4e84d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -500,8 +500,8 @@ Resources: - 'logs:Link' - 'logs:DescribeLogGroups' Resource: - - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:metric-filter:*' - - !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*' + - !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:metric-filter:*' + - !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:*' PolicyName: !Sub '${pSRASolutionName}-logs-policy' - PolicyDocument: Version: '2012-10-17' @@ -525,9 +525,10 @@ Resources: - 'oam:DeleteLink' - 'oam:TagResource' Resource: - - !Sub 'arn:${AWS::Partition}:oam:${AWS::Region}:${AWS::AccountId}:link/*' - - !Sub 'arn:${AWS::Partition}:oam:${AWS::Region}:${AWS::AccountId}:/ListLinks*' - - !Sub 'arn:${AWS::Partition}:oam:${AWS::Region}:*:sink/*' # sink on security account + - !Sub 'arn:${AWS::Partition}:oam:*:${AWS::AccountId}:link/*' + - !Sub 'arn:${AWS::Partition}:oam:*:${AWS::AccountId}:/ListLinks*' + - !Sub 'arn:${AWS::Partition}:oam:*:${AWS::AccountId}:/ListLinks' + - !Sub 'arn:${AWS::Partition}:oam:*:*:sink/*' # sink on security account PolicyName: !Sub '${pSRASolutionName}-oam-policy' - PolicyDocument: Version: '2012-10-17' @@ -597,7 +598,7 @@ Resources: Action: - 'applicationinsights:Link' Resource: - - !Sub 'arn:${AWS::Partition}:applicationinsights:${AWS::Region}:${AWS::AccountId}:application/*' + - !Sub 'arn:${AWS::Partition}:applicationinsights:*:${AWS::AccountId}:application/*' PolicyName: !Sub '${pSRASolutionName}-appinsights-policy' - PolicyDocument: Version: '2012-10-17' @@ -606,7 +607,7 @@ Resources: Action: - 'internetmonitor:Link' Resource: - - !Sub 'arn:${AWS::Partition}:internetmonitor:${AWS::Region}:${AWS::AccountId}:monitor/*' + - !Sub 'arn:${AWS::Partition}:internetmonitor:*:${AWS::AccountId}:monitor/*' PolicyName: !Sub '${pSRASolutionName}-internetmonitor-policy' Tags: From 1414f076fcda8bc5069fe71cc602ec85c30b1413 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:14:03 -0700 Subject: [PATCH 372/395] updating README --- .../solutions/genai/bedrock_org/README.md | 404 +++++++++--------- 1 file changed, 209 insertions(+), 195 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 1dc6b77ff..37eea1fc3 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -4,6 +4,7 @@ - [Introduction](#introduction) - [Deployed Resource Details](#deployed-resource-details) - [Implementation Instructions](#implementation-instructions) +- [Security Controls](#security-controls) - [References](#references) - [JSON Parameters Explanation](#json-parameters-explanation) @@ -25,32 +26,32 @@ This section provides a detailed explanation of the resources shown in the updat ### Organization Management Account - **(1.1) AWS CloudFormation**: Used to define and deploy resources in the solution. -- **CloudWatch Lambda Role (1.2)**: Role for enabling CloudWatch access by the Lambda function in the global region. -- **SNS Topic (1.3)**: SNS publish to Lambda. Handles fanout configuration of the solution. -- **Bedrock Lambda Function (1.4)**: Core function responsible for deploying resources and managing configurations across accounts and regions. -- **CloudWatch Log Group (1.5)**: Logs for monitoring the execution of the Lambda function. -- **Dead-Letter Queue (DLQ) (1.6)**: Handles failed Lambda invocations. -- **CloudWatch Filters (1.7)**: Filters specific log events to track relevant activities. -- **CloudWatch Alarms (1.8)**: Triggers notifications based on preconfigured thresholds. -- **SNS Topic (1.9)**: Publishes notifications for alarms and events. -10. **CloudWatch Link (1.10)**: Links CloudWatch metrics across accounts and regions for centralized observability. -11. **KMS Key (1.11)**: Encrypts SNS topic. +- **(1.2) CloudWatch Lambda Role**: Role for enabling CloudWatch access by the Lambda function in the global region. +- **(1.3) SNS Topic**: SNS publish to Lambda. Handles fanout configuration of the solution. +- **(1.4) Bedrock Lambda Function**: Core function responsible for deploying resources and managing configurations across accounts and regions. +- **(1.5) CloudWatch Log Group**: Logs for monitoring the execution of the Lambda function. +- **(1.6) Dead-Letter Queue (DLQ)**: Handles failed Lambda invocations. +- **(1.7) CloudWatch Filters**: Filters specific log events to track relevant activities. +- **(1.8) CloudWatch Alarms**: Triggers notifications based on preconfigured thresholds. +- **(1.9) SNS Topic**: Publishes notifications for alarms and events. +- **(1.10) CloudWatch Link**: Links CloudWatch metrics across accounts and regions for centralized observability. +- **(1.11) KMS Key**: Encrypts SNS topic. ### All Bedrock Accounts -1. **CloudWatch Sharing Role (2.1)**: Role enabling CloudWatch metrics sharing. -2. **CloudWatch Filters (2.2)**: Region-specific filters to monitor log events for compliance and security. -3. **CloudWatch Alarms (2.3)**: Configured to trigger notifications for specific metric thresholds. -4. **SNS Topic (2.4)**: Publishes notifications for alarms and events in the respective regions. -5. **CloudWatch Link (2.5)**: Links metrics from regional accounts back to the Organization Management Account. -6. **KMS Key (2.6)**: Encrypts SNS topic. -7. **Rule Lambda Roles (2.7)**: Lambda execution roles for AWS Config rules. -8. **Config Rules (2.8)**: Enforces governance and compliance policies. -9. **Config Lambdas (2.9)**: Evaluates and remediates non-compliance with governance policies. +- **(2.1) CloudWatch Sharing Role**: Role enabling CloudWatch metrics sharing. +- **(2.2) CloudWatch Filters**: Region-specific filters to monitor log events for compliance and security. +- **(2.3) CloudWatch Alarms**: Configured to trigger notifications for specific metric thresholds. +- **(2.4) SNS Topic**: Publishes notifications for alarms and events in the respective regions. +- **(2.5) CloudWatch Link**: Links metrics from regional accounts back to the Organization Management Account. +- **(2.6) KMS Key**: Encrypts SNS topic. +- **(2.7) Rule Lambda Roles**: Lambda execution roles for AWS Config rules. +- **(2.8) Config Rules**: Enforces governance and compliance policies. +- **(2.9) Config Lambdas**: Evaluates and remediates non-compliance with governance policies. ### Audit (Security Tooling) Account -1. **Resource Table (3.1)**: Maintains metadata for tracking deployed resources and configurations. -2. **CloudWatch Dashboard (3.2)**: Provides a centralized view of the security and compliance state across accounts and regions. -3. **CloudWatch Sink (3.3)**: Aggregates logs and metrics from other accounts and regions for analysis and auditing. +- **(3.1) Resource Table**: Maintains metadata for tracking deployed resources and configurations. +- **(3.2) CloudWatch Dashboard**: Provides a centralized view of the security and compliance state across accounts and regions. +- **(3.3) CloudWatch Sink**: Aggregates logs and metrics from other accounts and regions for analysis and auditing. --- @@ -103,7 +104,7 @@ aws cloudformation create-stack \ ``` #### Notes: -- Replace alerts@examplecorp.com, my-staging-bucket, and other parameter values with your specific settings. +- Replace alerts@examplecorp.com, my-staging-bucket, evaluation-bucket, invocation-log-group, and other parameter values with your specific settings. - Ensure the JSON strings (e.g., pBedrockAccounts, pBedrockModelEvalBucketRuleParams) are formatted correctly and match your deployment requirements. - This example assumes the CloudFormation template file is saved in the templates directory. Adjust the --template-body path if necessary. - Always validate the JSON parameters for correctness to avoid deployment errors. @@ -118,7 +119,38 @@ aws cloudformation create-stack \ Once the stack is deployed, the Bedrock Lambda function (`sra-bedrock-org`) will automatically deploy all the resources and configurations across the accounts and regions specified in the parameters. --- +## Security Controls + +### AWS Config Rules + +| Security Control | Description | JSON Parameter | +|-----------------|-------------|----------------| +| Model Evaluation Bucket Compliance | Validates S3 bucket configurations for model evaluation jobs | [pBedrockModelEvalBucketRuleParams](#pbedrockmodelevalruleparams) | +| IAM User Access Control | Ensures proper IAM access controls for Bedrock services | [pBedrockIAMUserAccessRuleParams](#pbedrockiamuseraccessruleparams) | +| Bedrock Guardrails | Validates content filtering, topic restrictions, and other guardrails | [pBedrockGuardrailsRuleParams](#pbedrockguardrailsruleparams) | +| VPC Endpoint Configuration | Checks required VPC endpoints for Bedrock services | [pBedrockVPCEndpointsRuleParams](#pbedrockvpcendpointsruleparams) | +| CloudWatch Logging Compliance | Validates CloudWatch logging configuration for invocations | [pBedrockInvocationLogCWRuleParams](#pbedrockinvocationlogcwruleparams) | +| S3 Logging Compliance | Validates S3 logging configuration for invocations | [pBedrockInvocationLogS3RuleParams](#pbedrockinvocationlogs3ruleparams) | +| CloudWatch Endpoint Validation | Ensures proper CloudWatch VPC endpoint setup | [pBedrockCWEndpointsRuleParams](#pbedrockcwendpointsruleparams) | +| S3 Endpoint Validation | Ensures proper S3 VPC endpoint setup | [pBedrockS3EndpointsRuleParams](#pbedrocks3endpointsruleparams) | +| Guardrail Encryption | Validates KMS encryption for Bedrock guardrails | [pBedrockGuardrailEncryptionRuleParams](#pbedrockguardrailencryptionruleparams) | + +> **Important Note**: The Config rule Lambda execution role needs to have access to any KMS keys used to encrypt Bedrock guardrails. Make sure to grant the appropriate KMS key permissions to the Lambda role to ensure proper evaluation of encrypted guardrail configurations. + +### CloudWatch Metrics, Filters, and Alarms +| Security Control | Description | JSON Parameter | +|-----------------|-------------|----------------| +| Service Changes Monitoring | Tracks changes to Bedrock service configurations | [pBedrockServiceChangesFilterParams](#pbedrockservicechangesfilterparams) | +| Bucket Changes Monitoring | Monitors changes to associated S3 buckets | [pBedrockBucketChangesFilterParams](#pbedrockbucketchangesfilterparams) | +| Prompt Injection Detection | Monitors for potential prompt injection attempts | [pBedrockPromptInjectionFilterParams](#pbedrockpromptinjectionfilterparams) | +| Sensitive Information Detection | Monitors for potential sensitive data exposure | [pBedrockSensitiveInfoFilterParams](#pbedrocksensitiveinfofilterparams) | + +### Centralized Observability +| Security Control | Description | JSON Parameter | +|-----------------|-------------|----------------| +| Central Observability | Configures cross-account/region metric aggregation | [pBedrockCentralObservabilityParams](#pbedrockcentralobservabilityparams) | +--- ## References - [AWS SRA Generative AI Deep-Dive](https://docs.aws.amazon.com/prescriptive-guidance/latest/security-reference-architecture/gen-ai-sra.html) - [AWS CloudFormation Documentation](https://docs.aws.amazon.com/cloudformation/index.html) @@ -133,226 +165,208 @@ Once the stack is deployed, the Bedrock Lambda function (`sra-bedrock-org`) will This section explains the parameters in the CloudFormation template that require JSON string values. Each parameter's structure and purpose are described in detail to assist in their configuration. ### `pBedrockModelEvalBucketRuleParams` -- **Purpose**: Configures a rule to validate a Bedrock Model Evaluation bucket. NOTE: `--` will be appended to get the existing bucket name(s). Ensure any S3 eval job bucket names to be checked match this naming convention. +- **Purpose**: Configures a rule to validate Bedrock Model Evaluation buckets. NOTE: `--` will be appended to get the existing bucket name(s). Ensure any S3 eval job bucket names to be checked match this naming convention. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "input_params": { - "BucketNamePrefix": "bucket-name" - "CheckRetention": "true|false", - "CheckEncryption": "true|false", - "CheckLogging": "true|false", - "CheckObjectLocking": "true|false", - "CheckVersioning": "true|false", - - } +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "BucketNamePrefix": "bucket-name", + "CheckRetention": "true|false", + "CheckEncryption": "true|false", + "CheckLogging": "true|false", + "CheckObjectLocking": "true|false", + "CheckVersioning": "true|false" } -- **Fields**: - - `deploy`: Whether the rule should be deployed (`true` or `false`). - - `accounts`: List of account IDs to apply the rule. - - `regions`: List of regions to apply the rule. - - `input_params.BucketName`: Name of the evaluation bucket. +} +``` ---- +### `pBedrockIAMUserAccessRuleParams` +- **Purpose**: Validates IAM user access to Bedrock resources. +- **Structure**: +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": {} +} +``` ### `pBedrockGuardrailsRuleParams` - **Purpose**: Enforces governance guardrails for Bedrock resources. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "input_params": { - "content_filters": "true|false", - "denied_topics": "true|false", - "word_filters": "true|false", - "sensitive_info_filters": "true|false", - "contextual_grounding": "true|false" - } +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "content_filters": "true|false", + "denied_topics": "true|false", + "word_filters": "true|false", + "sensitive_info_filters": "true|false", + "contextual_grounding": "true|false" } -- **Fields**: - - `deploy`: Whether the rule should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `input_params`: Specifies guardrail options (`true` or `false` for each filter). +} +``` ---- +### `pBedrockVPCEndpointsRuleParams` +- **Purpose**: Validates VPC endpoints for Bedrock services. +- **Structure**: +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "check_bedrock": "true|false", + "check_bedrock_agent": "true|false", + "check_bedrock_agent_runtime": "true|false", + "check_bedrock_runtime": "true|false" + } +} +``` ### `pBedrockInvocationLogCWRuleParams` - **Purpose**: Validates CloudWatch logging for model invocations. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "input_params": { - "check_retention": "true|false", - "check_encryption": "true|false" - } +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "check_retention": "true|false", + "check_encryption": "true|false" } -- **Fields**: - - `deploy`: Whether the rule should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `input_params.check_retention`: Ensures log retention is configured. - - `input_params.check_encryption`: Ensures logs are encrypted. - ---- +} +``` ### `pBedrockInvocationLogS3RuleParams` - **Purpose**: Validates S3 logging for model invocations. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "input_params": { - "check_retention": "true|false", - "check_encryption": "true|false", - "check_access_logging": "true|false", - "check_object_locking": "true|false", - "check_versioning": "true|false" - } +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": { + "check_retention": "true|false", + "check_encryption": "true|false", + "check_access_logging": "true|false", + "check_object_locking": "true|false", + "check_versioning": "true|false" } -- **Fields**: - - `deploy`: Whether the rule should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `input_params.check_retention`: Ensures bucket retention policies are configured. - - `input_params.check_encryption`: Ensures bucket encryption is enabled. - - `input_params.check_access_logging`: Ensures bucket access logging is enabled. - - `input_params.check_object_locking`: Ensures bucket object locking is enabled. - - `input_params.check_versioning`: Ensures bucket versioning is enabled. - ---- +} +``` ### `pBedrockCWEndpointsRuleParams` - **Purpose**: Validates CloudWatch VPC endpoints. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "input_params": {} - } -- **Fields**: - - `deploy`: Whether the rule should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `input_params`: This field is currently empty. - ---- +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": {} +} +``` ### `pBedrockS3EndpointsRuleParams` - **Purpose**: Validates S3 VPC endpoints. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "input_params": {} - } -- **Fields**: - - `deploy`: Whether the rule should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `input_params`: This field is currently empty. +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": {} +} +``` ---- +### `pBedrockGuardrailEncryptionRuleParams` +- **Purpose**: Validates KMS encryption configuration for Bedrock guardrails. +- **Structure**: +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "input_params": {} +} +``` ### `pBedrockServiceChangesFilterParams` -- **Purpose**: Tracks changes to services in CloudTrail logs. +- **Purpose**: Tracks changes to Bedrock services in CloudTrail logs. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "filter_params": { - "log_group_name": "log-group-name" - } +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "filter_params": { + "log_group_name": "aws-controltower/CloudTrailLogs" } -- **Fields**: - - `deploy`: Whether the filter should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `filter_params.log_group_name`: Name of the log group to monitor for changes. - ---- +} +``` ### `pBedrockBucketChangesFilterParams` - **Purpose**: Monitors S3 bucket changes in CloudTrail logs. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "filter_params": { - "log_group_name": "log-group-name", - "bucket_names": ["bucket1", "bucket2"] - } +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "filter_params": { + "log_group_name": "aws-controltower/CloudTrailLogs", + "bucket_names": ["bucket1", "bucket2"] } -- **Fields**: - - `deploy`: Whether the filter should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `filter_params.log_group_name`: Name of the log group to monitor. - - `filter_params.bucket_names`: List of bucket names to track. - ---- +} +``` ### `pBedrockPromptInjectionFilterParams` - **Purpose**: Filters prompt injection attempts in logs. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "filter_params": { - "log_group_name": "log-group-name", - "input_path": "path.to.input" - } +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "filter_params": { + "log_group_name": "model-invocation-log-group", + "input_path": "input.inputBodyJson.messages[0].content" } -- **Fields**: - - `deploy`: Whether the filter should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `filter_params.log_group_name`: Name of the log group to monitor. - - `filter_params.input_path`: Path to the input field to check. - ---- +} +``` +**Note**: `input_path` is based on the base model used (e.g., Claude or Titan). Check the invocation log InvokeModel messages for details. ### `pBedrockSensitiveInfoFilterParams` - **Purpose**: Filters sensitive information from logs. - **Structure**: - { - "deploy": "true|false", - "accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"], - "filter_params": { - "log_group_name": "log-group-name", - "input_path": "path.to.sensitive.data" - } +```json +{ + "deploy": "true|false", + "accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"], + "filter_params": { + "log_group_name": "model-invocation-log-group", + "input_path": "input.inputBodyJson.messages[0].content" } -- **Fields**: - - `deploy`: Whether the filter should be deployed. - - `accounts`: List of account IDs. - - `regions`: List of regions. - - `filter_params.log_group_name`: The name of the log group to filter. - - `filter_params.input_path`: Path to the data field containing sensitive information. - ---- +} +``` +**Note**: `input_path` is based on the base model used (e.g., Claude or Titan). Check the invocation log InvokeModel messages for details. ### `pBedrockCentralObservabilityParams` - **Purpose**: Configures central observability for Bedrock accounts. - **Structure**: - { - "deploy": "true|false", - "bedrock_accounts": ["account_id1", "account_id2"], - "regions": ["region1", "region2"] - } -- **Fields**: - - `deploy`: Whether central observability should be deployed. - - `bedrock_accounts`: List of Bedrock account IDs. - - `regions`: List of regions. +```json +{ + "deploy": "true|false", + "bedrock_accounts": ["account_id1", "account_id2"], + "regions": ["region1", "region2"] +} +``` From daff71a1954fa8c1676880310812595d9f0079a1 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:19:40 -0700 Subject: [PATCH 373/395] re organizing README --- .../solutions/genai/bedrock_org/README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 37eea1fc3..88a828389 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -103,7 +103,15 @@ aws cloudformation create-stack \ --capabilities CAPABILITY_NAMED_IAM ``` -#### Notes: +2. Monitor the stack creation progress in the AWS CloudFormation Console or via CLI commands. + +### Post-Deployment +Once the stack is deployed, the Bedrock Lambda function (`sra-bedrock-org`) will automatically deploy all the resources and configurations across the accounts and regions specified in the parameters. + +### Important Notes: + +Please read the following notes before deploying the stack to ensure successful deployment. + - Replace alerts@examplecorp.com, my-staging-bucket, evaluation-bucket, invocation-log-group, and other parameter values with your specific settings. - Ensure the JSON strings (e.g., pBedrockAccounts, pBedrockModelEvalBucketRuleParams) are formatted correctly and match your deployment requirements. - This example assumes the CloudFormation template file is saved in the templates directory. Adjust the --template-body path if necessary. @@ -111,12 +119,7 @@ aws cloudformation create-stack \ - Ensure the --capabilities CAPABILITY_NAMED_IAM flag is included to allow CloudFormation to create the necessary IAM resources. - An example test fork URL for `pSRARepoZipUrl` is - `https://github.com/liamschn/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip` - The eval job bucket config rule will append `--` to the `BucketNamePrefix` parameter provided to get the existing bucket name(s). Ensure any S3 eval job bucket names to be checked match this naming convention. - - -2. Monitor the stack creation progress in the AWS CloudFormation Console or via CLI commands. - -### Post-Deployment -Once the stack is deployed, the Bedrock Lambda function (`sra-bedrock-org`) will automatically deploy all the resources and configurations across the accounts and regions specified in the parameters. +- The Config rule Lambda execution role needs to have access to any KMS keys used to encrypt Bedrock guardrails. Make sure to grant the appropriate KMS key permissions to the Lambda role to ensure proper evaluation of encrypted guardrail configurations. --- ## Security Controls From 674a9600e70e237e9d831acf63b3436e55b9f3ff Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:21:15 -0700 Subject: [PATCH 374/395] updating readme --- aws_sra_examples/solutions/genai/bedrock_org/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 88a828389..497dba669 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -58,6 +58,7 @@ This section provides a detailed explanation of the resources shown in the updat ## Implementation Instructions You can deploy this solution using the AWS Management Console or AWS CLI. +Read the [Important Notes](#important-notes) section before deploying the stack. ### Deploying via AWS Management Console 1. Open the [CloudFormation Console](https://console.aws.amazon.com/cloudformation). From dbfd184d1b63f1795edf6cf4f988ed814d25d26c Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:22:32 -0700 Subject: [PATCH 375/395] updating readme --- aws_sra_examples/solutions/genai/bedrock_org/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 497dba669..3fd2ad69e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -22,7 +22,7 @@ The architecture follows best practices for security and scalability and is desi ![Architecture Diagram](./documentation/bedrock-org.png) -This section provides a detailed explanation of the resources shown in the updated architecture diagram: +This section provides a detailed explanation of the resources shown in the updated architecture diagram. More details on the resources can be found in the [Security Controls](#security-controls) section. ### Organization Management Account - **(1.1) AWS CloudFormation**: Used to define and deploy resources in the solution. From ad49fde3a0758de8de52f4bc881424ac991f79ae Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:24:28 -0700 Subject: [PATCH 376/395] reorganizing readme --- .../solutions/genai/bedrock_org/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 3fd2ad69e..d7938b9d8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -155,15 +155,6 @@ Please read the following notes before deploying the stack to ensure successful | Central Observability | Configures cross-account/region metric aggregation | [pBedrockCentralObservabilityParams](#pbedrockcentralobservabilityparams) | --- -## References -- [AWS SRA Generative AI Deep-Dive](https://docs.aws.amazon.com/prescriptive-guidance/latest/security-reference-architecture/gen-ai-sra.html) -- [AWS CloudFormation Documentation](https://docs.aws.amazon.com/cloudformation/index.html) -- [AWS Config Rules](https://docs.aws.amazon.com/config/latest/developerguide/evaluate-config.html) -- [CloudWatch Metrics and Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/WhatIsCloudWatch.html) -- [AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) -- [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html) - - ## JSON Parameters Explanation This section explains the parameters in the CloudFormation template that require JSON string values. Each parameter's structure and purpose are described in detail to assist in their configuration. @@ -374,3 +365,12 @@ This section explains the parameters in the CloudFormation template that require "regions": ["region1", "region2"] } ``` + +--- +## References +- [AWS SRA Generative AI Deep-Dive](https://docs.aws.amazon.com/prescriptive-guidance/latest/security-reference-architecture/gen-ai-sra.html) +- [AWS CloudFormation Documentation](https://docs.aws.amazon.com/cloudformation/index.html) +- [AWS Config Rules](https://docs.aws.amazon.com/config/latest/developerguide/evaluate-config.html) +- [CloudWatch Metrics and Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/WhatIsCloudWatch.html) +- [AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) +- [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html) From 73a7b3c1359b07cca6efa9736d99d8cebd40de24 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:30:05 -0700 Subject: [PATCH 377/395] updating readme - links --- aws_sra_examples/solutions/genai/bedrock_org/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index d7938b9d8..882ba02a8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -159,7 +159,7 @@ Please read the following notes before deploying the stack to ensure successful This section explains the parameters in the CloudFormation template that require JSON string values. Each parameter's structure and purpose are described in detail to assist in their configuration. -### `pBedrockModelEvalBucketRuleParams` +### pBedrockModelEvalBucketRuleParams - **Purpose**: Configures a rule to validate Bedrock Model Evaluation buckets. NOTE: `--` will be appended to get the existing bucket name(s). Ensure any S3 eval job bucket names to be checked match this naming convention. - **Structure**: ```json From 5ae4118a5b1b05ce25926a05917df29990817c85 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:31:25 -0700 Subject: [PATCH 378/395] update readme - link --- aws_sra_examples/solutions/genai/bedrock_org/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 882ba02a8..1cf590938 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -129,7 +129,7 @@ Please read the following notes before deploying the stack to ensure successful | Security Control | Description | JSON Parameter | |-----------------|-------------|----------------| -| Model Evaluation Bucket Compliance | Validates S3 bucket configurations for model evaluation jobs | [pBedrockModelEvalBucketRuleParams](#pbedrockmodelevalruleparams) | +| Model Evaluation Bucket Compliance | Validates S3 bucket configurations for model evaluation jobs | [pBedrockModelEvalBucketRuleParams](#pbedrockmodelevalbucketruleparams) | | IAM User Access Control | Ensures proper IAM access controls for Bedrock services | [pBedrockIAMUserAccessRuleParams](#pbedrockiamuseraccessruleparams) | | Bedrock Guardrails | Validates content filtering, topic restrictions, and other guardrails | [pBedrockGuardrailsRuleParams](#pbedrockguardrailsruleparams) | | VPC Endpoint Configuration | Checks required VPC endpoints for Bedrock services | [pBedrockVPCEndpointsRuleParams](#pbedrockvpcendpointsruleparams) | From 1494070052ce3bc78a24534622bb41b53a2d7ae5 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:32:50 -0700 Subject: [PATCH 379/395] uppdate readme --- aws_sra_examples/solutions/genai/bedrock_org/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 1cf590938..adb60aae8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -159,7 +159,7 @@ Please read the following notes before deploying the stack to ensure successful This section explains the parameters in the CloudFormation template that require JSON string values. Each parameter's structure and purpose are described in detail to assist in their configuration. -### pBedrockModelEvalBucketRuleParams +### `pBedrockModelEvalBucketRuleParams` - **Purpose**: Configures a rule to validate Bedrock Model Evaluation buckets. NOTE: `--` will be appended to get the existing bucket name(s). Ensure any S3 eval job bucket names to be checked match this naming convention. - **Structure**: ```json From fee6f9a15b7b3060eb69c0b7c3aee8be7f3a377f Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:36:55 -0700 Subject: [PATCH 380/395] update readme section title --- aws_sra_examples/solutions/genai/bedrock_org/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index adb60aae8..ffef2c2b0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -6,7 +6,7 @@ - [Implementation Instructions](#implementation-instructions) - [Security Controls](#security-controls) - [References](#references) -- [JSON Parameters Explanation](#json-parameters-explanation) +- [JSON Parameters](#json-parameters) --- @@ -155,7 +155,7 @@ Please read the following notes before deploying the stack to ensure successful | Central Observability | Configures cross-account/region metric aggregation | [pBedrockCentralObservabilityParams](#pbedrockcentralobservabilityparams) | --- -## JSON Parameters Explanation +## JSON Parameters This section explains the parameters in the CloudFormation template that require JSON string values. Each parameter's structure and purpose are described in detail to assist in their configuration. From ad5629ce1d4b72ca3602fe991e54434ed55b8b05 Mon Sep 17 00:00:00 2001 From: liamschn Date: Tue, 28 Jan 2025 17:37:26 -0700 Subject: [PATCH 381/395] update toc --- aws_sra_examples/solutions/genai/bedrock_org/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index ffef2c2b0..eb26a88e8 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -5,8 +5,8 @@ - [Deployed Resource Details](#deployed-resource-details) - [Implementation Instructions](#implementation-instructions) - [Security Controls](#security-controls) -- [References](#references) - [JSON Parameters](#json-parameters) +- [References](#references) --- From cb2560b4502c0936ce8283125a91c5916a1cede8 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 29 Jan 2025 12:33:47 -0700 Subject: [PATCH 382/395] get_partition_for_region mypy error --- .../genai/bedrock_org/lambda/src/sra_sts.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index f36959023..438152270 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -33,6 +33,21 @@ class SRASTS: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) + def _get_partition_for_region(self, region_name: str) -> str: + """Get AWS partition for a given region. + + Args: + region_name (str): AWS region name + + Returns: + str: AWS partition name (aws, aws-cn, aws-us-gov) + """ + if region_name.startswith('us-gov-'): + return 'aws-us-gov' + elif region_name.startswith('cn-'): + return 'aws-cn' + return 'aws' + def __init__(self, profile: str = "default") -> None: """Initialize class object. @@ -56,14 +71,14 @@ def __init__(self, profile: str = "default") -> None: self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") self.HOME_REGION = self.MANAGEMENT_ACCOUNT_SESSION.region_name self.LOGGER.info(f"STS detected home region: {self.HOME_REGION}") - self.PARTITION = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) + self.PARTITION = self._get_partition_for_region(self.HOME_REGION) except botocore.exceptions.ClientError as error: if error.response["Error"]["Code"] == "ExpiredToken": self.LOGGER.info("Token has expired, please re-run with proper credentials set.") self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") self.HOME_REGION = self.MANAGEMENT_ACCOUNT_SESSION.region_name - self.PARTITION = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) + self.PARTITION = self._get_partition_for_region(self.HOME_REGION) else: self.LOGGER.info(f"Error: {error}") From aa1e14e1ede27919a9108ab816b9a7be46a71938 Mon Sep 17 00:00:00 2001 From: liamschn Date: Wed, 29 Jan 2025 12:52:48 -0700 Subject: [PATCH 383/395] reverted back to orig --- .../genai/bedrock_org/lambda/src/sra_sts.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py index 438152270..f36959023 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_sts.py @@ -33,21 +33,6 @@ class SRASTS: log_level: str = os.environ.get("LOG_LEVEL", "INFO") LOGGER.setLevel(log_level) - def _get_partition_for_region(self, region_name: str) -> str: - """Get AWS partition for a given region. - - Args: - region_name (str): AWS region name - - Returns: - str: AWS partition name (aws, aws-cn, aws-us-gov) - """ - if region_name.startswith('us-gov-'): - return 'aws-us-gov' - elif region_name.startswith('cn-'): - return 'aws-cn' - return 'aws' - def __init__(self, profile: str = "default") -> None: """Initialize class object. @@ -71,14 +56,14 @@ def __init__(self, profile: str = "default") -> None: self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") self.HOME_REGION = self.MANAGEMENT_ACCOUNT_SESSION.region_name self.LOGGER.info(f"STS detected home region: {self.HOME_REGION}") - self.PARTITION = self._get_partition_for_region(self.HOME_REGION) + self.PARTITION = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) except botocore.exceptions.ClientError as error: if error.response["Error"]["Code"] == "ExpiredToken": self.LOGGER.info("Token has expired, please re-run with proper credentials set.") self.MANAGEMENT_ACCOUNT_SESSION = boto3.Session() self.STS_CLIENT = self.MANAGEMENT_ACCOUNT_SESSION.client("sts") self.HOME_REGION = self.MANAGEMENT_ACCOUNT_SESSION.region_name - self.PARTITION = self._get_partition_for_region(self.HOME_REGION) + self.PARTITION = self.MANAGEMENT_ACCOUNT_SESSION.get_partition_for_region(self.HOME_REGION) else: self.LOGGER.info(f"Error: {error}") From 2fb9933937450f5cbd24e561a332bd06a7f7f04c Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 30 Jan 2025 09:09:19 -0700 Subject: [PATCH 384/395] update readme --- .../solutions/genai/bedrock_org/README.md | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index eb26a88e8..86ffa297b 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -71,39 +71,38 @@ Read the [Important Notes](#important-notes) section before deploying the stack. ```bash aws cloudformation create-stack \ - --stack-name BedrockOrg \ - --template-body file://templates/sra-bedrock-org-main.yaml \ + --stack-name sra-bedrock-org-main \ + --template-body file://aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml \ + --region us-east-1 \ --parameters \ - ParameterKey=pSRARepoZipUrl,ParameterValue=https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip \ - ParameterKey=pDryRun,ParameterValue=false \ - ParameterKey=pSRAExecutionRoleName,ParameterValue=sra-execution-role \ + ParameterKey=pSRARepoZipUrl,ParameterValue=https://github.com/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip \ + ParameterKey=pDryRun,ParameterValue=true \ + ParameterKey=pSRAExecutionRoleName,ParameterValue=sra-execution \ ParameterKey=pDeployLambdaLogGroup,ParameterValue=true \ ParameterKey=pLogGroupRetention,ParameterValue=30 \ ParameterKey=pLambdaLogLevel,ParameterValue=INFO \ ParameterKey=pSRASolutionName,ParameterValue=sra-bedrock-org \ ParameterKey=pSRASolutionVersion,ParameterValue=1.0.0 \ ParameterKey=pSRAAlarmEmail,ParameterValue=alerts@examplecorp.com \ - ParameterKey=pSRAStagingS3BucketName,ParameterValue=staging-artifacts-bucket \ - ParameterKey=pBedrockOrgLambdaRoleName,ParameterValue=sra-bedrock-org-lambda-role \ - ParameterKey=pBedrockAccounts,ParameterValue='["123456789012","234567890123"]' \ - ParameterKey=pBedrockRegions,ParameterValue='["us-east-1","us-west-2"]' \ - ParameterKey=pBedrockModelEvalBucketRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"BucketNamePrefix": "evaluation-bucket","CheckRetention": "true", "CheckEncryption": "true", "CheckLogging": "true", "CheckObjectLocking": "true", "CheckVersioning": "true"}}' \ - ParameterKey=pBedrockIAMUserAccessRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ - ParameterKey=pBedrockGuardrailsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"content_filters": "true", "denied_topics": "true", "word_filters": "true", "sensitive_info_filters": "true", "contextual_grounding": "true"}}' \ - ParameterKey=pBedrockVPCEndpointsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_bedrock": "true", "check_bedrock_agent": "true", "check_bedrock_agent_runtime": "true", "check_bedrock_runtime": "true"}}' \ - ParameterKey=pBedrockInvocationLogCWRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_retention": "true", "check_encryption": "true"}}' \ - ParameterKey=pBedrockInvocationLogS3RuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {"check_retention": "true", "check_encryption": "true", "check_access_logging": "true", "check_object_locking": "true", "check_versioning": "true"}}' \ - ParameterKey=pBedrockCWEndpointsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ - ParameterKey=pBedrockS3EndpointsRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ - ParameterKey=pBedrockGuardrailEncryptionRuleParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "input_params": {}}' \ - ParameterKey=pBedrockServiceChangesFilterParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs"}}' \ - ParameterKey=pBedrockBucketChangesFilterParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "filter_params": {"log_group_name": "aws-controltower/CloudTrailLogs", "bucket_names": ["my-bucket-name"]}}' \ - ParameterKey=pBedrockPromptInjectionFilterParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "filter_params": {"log_group_name": "invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' \ - ParameterKey=pBedrockSensitiveInfoFilterParams,ParameterValue='{"deploy": "true", "accounts": ["123456789012"], "regions": ["us-east-1"], "filter_params": {"log_group_name": "invocation-log-group", "input_path": "input.inputBodyJson.messages[0].content"}}' \ - ParameterKey=pBedrockCentralObservabilityParams,ParameterValue='{"deploy": "true", "bedrock_accounts": ["123456789012"], "regions": ["us-east-1"]}' \ + ParameterKey=pSRAStagingS3BucketName,ParameterValue=/sra/staging-s3-bucket-name \ + ParameterKey=pBedrockOrgLambdaRoleName,ParameterValue=sra-bedrock-org-lambda \ + ParameterKey=pBedrockAccounts,ParameterValue='"[\"222222222222\",\"333333333333\"]"' \ + ParameterKey=pBedrockRegions,ParameterValue='"[\"us-east-1\",\"us-west-2\"]"' \ + ParameterKey=pBedrockModelEvalBucketRuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {\"BucketNamePrefix\": \"model-eval-job-bucket\",\"CheckRetention\": \"true\", \"CheckEncryption\": \"true\", \"CheckLogging\": \"true\", \"CheckObjectLocking\": \"true\", \"CheckVersioning\": \"true\"}}"' \ + ParameterKey=pBedrockIAMUserAccessRuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {}}"' \ + ParameterKey=pBedrockGuardrailsRuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {\"content_filters\": \"true\", \"denied_topics\": \"true\", \"word_filters\": \"true\", \"sensitive_info_filters\": \"true\", \"contextual_grounding\": \"true\"}}"' \ + ParameterKey=pBedrockVPCEndpointsRuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {\"check_bedrock\": \"true\", \"check_bedrock_agent\": \"true\", \"check_bedrock_agent_runtime\": \"true\", \"check_bedrock_runtime\": \"true\"}}"' \ + ParameterKey=pBedrockInvocationLogCWRuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {\"check_retention\": \"true\", \"check_encryption\": \"true\"}}"' \ + ParameterKey=pBedrockInvocationLogS3RuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {\"check_retention\": \"true\", \"check_encryption\": \"true\", \"check_access_logging\": \"true\", \"check_object_locking\": \"true\", \"check_versioning\": \"true\"}}"' \ + ParameterKey=pBedrockCWEndpointsRuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {}}"' \ + ParameterKey=pBedrockS3EndpointsRuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {}}"' \ + ParameterKey=pBedrockGuardrailEncryptionRuleParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\",\"us-west-2\"], \"input_params\": {}}"' \ + ParameterKey=pBedrockServiceChangesFilterParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"111111111111\"], \"regions\": [\"us-east-1\"], \"filter_params\": {\"log_group_name\": \"aws-controltower/CloudTrailLogs\"}}"' \ + ParameterKey=pBedrockBucketChangesFilterParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"111111111111\"], \"regions\": [\"us-east-1\"], \"filter_params\": {\"log_group_name\": \"aws-controltower/CloudTrailLogs\", \"bucket_names\": [\"model-invocation-log-bucket-222222222222-us-west-2\",\"model-invocation-log-bucket-222222222222-us-east-1\",\"model-invocation-log-bucket-333333333333-us-west-2\",\"model-invocation-log-bucket-333333333333-us-east-1\"]}}"' \ + ParameterKey=pBedrockPromptInjectionFilterParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\"], \"filter_params\": {\"log_group_name\": \"model-invocation-log-group\", \"input_path\": \"input.inputBodyJson.messages[0].content\"}}"' \ + ParameterKey=pBedrockSensitiveInfoFilterParams,ParameterValue='"{\"deploy\": \"true\", \"accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\"], \"filter_params\": {\"log_group_name\": \"model-invocation-log-group\", \"input_path\": \"input.inputBodyJson.messages[0].content\"}}"' \ + ParameterKey=pBedrockCentralObservabilityParams,ParameterValue='"{\"deploy\": \"true\", \"bedrock_accounts\": [\"222222222222\",\"333333333333\"], \"regions\": [\"us-east-1\"]}"' \ --capabilities CAPABILITY_NAMED_IAM -``` - 2. Monitor the stack creation progress in the AWS CloudFormation Console or via CLI commands. ### Post-Deployment From 79a0c295c77520dc498f2418b11f6ecdbcf642be Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 30 Jan 2025 11:57:03 -0700 Subject: [PATCH 385/395] fixing mypy errors --- .../ami_bakery/ami_bakery_org/lambda/src/codepipeline.py | 4 ++-- .../solutions/config/config_org/lambda/src/config.py | 2 +- .../rules/sra_bedrock_check_cloudwatch_endpoints/app.py | 2 +- .../rules/sra_bedrock_check_guardrail_encryption/app.py | 4 ++-- .../lambda/rules/sra_bedrock_check_guardrails/app.py | 2 +- .../lambda/rules/sra_bedrock_check_iam_user_access/app.py | 2 +- .../rules/sra_bedrock_check_invocation_log_cloudwatch/app.py | 2 +- .../lambda/rules/sra_bedrock_check_invocation_log_s3/app.py | 3 ++- .../lambda/rules/sra_bedrock_check_s3_endpoints/app.py | 2 +- .../lambda/rules/sra_bedrock_check_vpc_endpoints/app.py | 2 +- .../solutions/genai/bedrock_org/lambda/src/sra_lambda.py | 2 +- .../solutions/genai/bedrock_org/lambda/src/sra_s3.py | 5 ++++- .../solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py | 4 ++-- .../security_lake_org/lambda/src/security_lake.py | 2 +- 14 files changed, 21 insertions(+), 17 deletions(-) diff --git a/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py b/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py index a84b6edcd..c4ca779bc 100644 --- a/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py +++ b/aws_sra_examples/solutions/ami_bakery/ami_bakery_org/lambda/src/codepipeline.py @@ -90,7 +90,7 @@ def create_codepipeline( "roleArn": "arn:" + aws_partition + ":iam::" + account_id + ":role/" + codepipeline_role_name, "artifactStore": {"type": "S3", "location": bucket_name}, "stages": [ - { # type: ignore + { "name": pipeline_name + "-CodeCommitSource", "actions": [ { @@ -104,7 +104,7 @@ def create_codepipeline( } ], }, - { # type: ignore + { "name": pipeline_name + "-DeployEC2ImageBuilder", "actions": [ { diff --git a/aws_sra_examples/solutions/config/config_org/lambda/src/config.py b/aws_sra_examples/solutions/config/config_org/lambda/src/config.py index a67a75f63..666882a16 100644 --- a/aws_sra_examples/solutions/config/config_org/lambda/src/config.py +++ b/aws_sra_examples/solutions/config/config_org/lambda/src/config.py @@ -92,7 +92,7 @@ def set_config_in_org( configuration_recorder: ConfigurationRecorderTypeDef = { "name": recorder_name, "roleARN": role_arn, - "recordingGroup": { # type: ignore + "recordingGroup": { "allSupported": all_supported, "includeGlobalResourceTypes": include_global_resource_types, "resourceTypes": resource_types, diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py index a92e6785e..6fe35ceb7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_cloudwatch_endpoints/app.py @@ -104,6 +104,6 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 # Submit compliance evaluations if evaluations: - config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) + config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) # type: ignore LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index f4b881045..791ecc4ce 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -61,7 +61,7 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 except ClientError as e: if e.response['Error']['Code'] == 'AccessDeniedException': - LOGGER.info(f"Access denied. If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key.") + LOGGER.info("Access denied. If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key.") return "NON_COMPLIANT", ( "Access denied. If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key." ) @@ -95,6 +95,6 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 LOGGER.info(f"Compliance evaluation result: {compliance_type}") LOGGER.info(f"Annotation: {annotation}") - config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) + config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) # type: ignore LOGGER.info("Compliance evaluation complete.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 9e071a787..807d23d39 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -130,7 +130,7 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 config = boto3.client("config") try: - response = config.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) + response = config.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) # type: ignore LOGGER.info(f"Evaluation sent successfully: {response}") except Exception as e: LOGGER.error(f"Error sending evaluation: {str(e)}") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py index 3ced52ea9..97c97dd78 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -161,7 +161,7 @@ def lambda_handler(event: dict, context: Any) -> dict: "OrderingTimestamp": invoking_event["notificationCreationTime"], } ], - ResultToken=result_token, + ResultToken=result_token, # type: ignore ) # Return the evaluation result diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py index 63ec355c3..f94cc1fc4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_cloudwatch/app.py @@ -103,6 +103,6 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 LOGGER.info(f"Compliance evaluation result: {compliance_type}") LOGGER.info(f"Annotation: {annotation}") - config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) + config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) # type: ignore LOGGER.info("Compliance evaluation complete.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index b33b2a3d3..157f54a98 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -33,6 +33,7 @@ # Global variables BUCKET_NAME = "" + def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ004, CCR001, C901 """Evaluate if Bedrock Model Invocation Logging is properly configured for S3. @@ -139,6 +140,6 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 LOGGER.info(f"Compliance evaluation result: {compliance_type}") LOGGER.info(f"Annotation: {annotation}") - config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) + config_client.put_evaluations(Evaluations=[evaluation], ResultToken=event["resultToken"]) # type: ignore LOGGER.info("Compliance evaluation complete.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py index 5b7593b28..7eb7df0f1 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_s3_endpoints/app.py @@ -108,6 +108,6 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 # Submit compliance evaluations if evaluations: - config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) + config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) # type: ignore LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py index a1b6b2f9a..234ba9ddb 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_vpc_endpoints/app.py @@ -120,6 +120,6 @@ def lambda_handler(event: dict, context: Any) -> None: # noqa: U100 # Submit compliance evaluations if evaluations: - config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) + config_client.put_evaluations(Evaluations=evaluations, ResultToken=event["resultToken"]) # type: ignore LOGGER.info(f"Compliance evaluation complete. Processed {len(evaluations)} evaluations.") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py index 73fd0dab2..eba755e0f 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_lambda.py @@ -96,7 +96,7 @@ def create_lambda_function( # noqa: CFQ002, CCR001 try: create_response = self.LAMBDA_CLIENT.create_function( FunctionName=function_name, - Runtime=runtime, + Runtime=runtime, # type: ignore Handler=handler, Role=role_arn, Code={"ZipFile": open(code_zip_file, "rb").read()}, # noqa: SIM115 diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index eb4d258cc..6ab02359c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -89,7 +89,10 @@ def create_s3_bucket(self, bucket: str) -> None: """ if self.REGION != "us-east-1": create_bucket = self.S3_CLIENT.create_bucket( - ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, ObjectOwnership="BucketOwnerPreferred" + ACL="private", + Bucket=bucket, + CreateBucketConfiguration={"LocationConstraint": self.REGION}, # type: ignore + ObjectOwnership="BucketOwnerPreferred" ) else: create_bucket = self.S3_CLIENT.create_bucket(ACL="private", Bucket=bucket, ObjectOwnership="BucketOwnerPreferred") diff --git a/aws_sra_examples/solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py b/aws_sra_examples/solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py index 393a5204a..d56ae666b 100644 --- a/aws_sra_examples/solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py +++ b/aws_sra_examples/solutions/patch_mgmt/patch_mgmt_org/lambda/src/app.py @@ -369,7 +369,7 @@ def manage_task_params( """ if task_operation is None and task_reboot_option is None: no_param_response: MaintenanceWindowTaskInvocationParametersTypeDef = { - "RunCommand": { # type: ignore + "RunCommand": { "Parameters": {}, "DocumentVersion": "$DEFAULT", "TimeoutSeconds": 3600, @@ -382,7 +382,7 @@ def manage_task_params( task_operation_final: str = "INVALID_TASK_OPERATION_PROVIDED" if task_operation is None else task_operation task_reboot_option_final: str = "INVALID_TASK_REBOOT_OPTION_PROVIDED" if task_reboot_option is None else task_reboot_option with_params_response: MaintenanceWindowTaskInvocationParametersTypeDef = { - "RunCommand": { # type: ignore + "RunCommand": { "Parameters": { "Operation": [task_operation_final], "RebootOption": [task_reboot_option_final], diff --git a/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/security_lake.py b/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/security_lake.py index 15b0028a3..851e3e043 100644 --- a/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/security_lake.py +++ b/aws_sra_examples/solutions/security_lake/security_lake_org/lambda/src/security_lake.py @@ -913,7 +913,7 @@ def set_lake_formation_permissions(lf_client: LakeFormationClient, account: str, try: resource: Union[ResourceTypeDef] = { "Database": {"CatalogId": account, "Name": db_name + "_subscriber"}, - "Table": {"CatalogId": account, "DatabaseName": db_name + "_subscriber", "Name": "rl_*"}, # type: ignore + "Table": {"CatalogId": account, "DatabaseName": db_name + "_subscriber", "Name": "rl_*"}, } lf_client.grant_permissions( CatalogId=account, From c66b3666557a0c07b36ed8617898fe13a24ed740 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 30 Jan 2025 12:13:16 -0700 Subject: [PATCH 386/395] fix flake8 issues --- .../lambda/rules/sra_bedrock_check_guardrails/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index 807d23d39..a2bb32080 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -98,7 +98,10 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 LOGGER.warning(f"Guardrail {guardrail_name} (ID: {guardrail_id}) not found") except ClientError as client_error: if client_error.response['Error']['Code'] == 'AccessDeniedException': - LOGGER.info(f"Access denied to guardrail {guardrail_name} (ID: {guardrail_id}). If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key.") + LOGGER.info( + f"Access denied to guardrail {guardrail_name} (ID: {guardrail_id}). " + + "If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key." + ) non_compliant_guardrails[guardrail_name] = ["(access_denied; see log for details)"] except Exception as e: LOGGER.error(f"Error checking guardrail {guardrail_name} (ID: {guardrail_id}): {str(e)}") From a82f99a45b29d07e30fda93577629ea10c153449 Mon Sep 17 00:00:00 2001 From: liamschn Date: Thu, 30 Jan 2025 14:59:18 -0700 Subject: [PATCH 387/395] fixing black formatter issues --- .../app.py | 6 ++---- .../rules/sra_bedrock_check_guardrails/app.py | 2 +- .../sra_bedrock_check_invocation_log_s3/app.py | 2 +- .../genai/bedrock_org/lambda/src/sra_dynamodb.py | 2 +- .../genai/bedrock_org/lambda/src/sra_iam.py | 16 ++++++++++------ .../genai/bedrock_org/lambda/src/sra_s3.py | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py index 791ecc4ce..c1113eb03 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrail_encryption/app.py @@ -60,11 +60,9 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 return "COMPLIANT", "All Bedrock guardrails are encrypted with a KMS key" except ClientError as e: - if e.response['Error']['Code'] == 'AccessDeniedException': + if e.response["Error"]["Code"] == "AccessDeniedException": LOGGER.info("Access denied. If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key.") - return "NON_COMPLIANT", ( - "Access denied. If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key." - ) + return "NON_COMPLIANT", ("Access denied. If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key.") LOGGER.error(f"Error evaluating Bedrock guardrails encryption: {str(e)}") return "ERROR", f"Error evaluating compliance: {str(e)}" diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index a2bb32080..abf126ef5 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -97,7 +97,7 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 except bedrock.exceptions.ResourceNotFoundException: LOGGER.warning(f"Guardrail {guardrail_name} (ID: {guardrail_id}) not found") except ClientError as client_error: - if client_error.response['Error']['Code'] == 'AccessDeniedException': + if client_error.response["Error"]["Code"] == "AccessDeniedException": LOGGER.info( f"Access denied to guardrail {guardrail_name} (ID: {guardrail_id}). " + "If guardrail uses KMS encryption, ensure Lambda's IAM role has permissions to the KMS key." diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index 157f54a98..0b6df80a0 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -74,7 +74,7 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 if not any(rule.get("Expiration") for rule in lifecycle.get("Rules", [])): issues.append("retention not set") except botocore.exceptions.ClientError as client_error: - if client_error.response['Error']['Code'] == 'NoSuchLifecycleConfiguration': + if client_error.response["Error"]["Code"] == "NoSuchLifecycleConfiguration": LOGGER.info(f"No lifecycle configuration found for S3 bucket: {bucket_name}") issues.append("lifecycle not set") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py index c772c6042..bf5869d2d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_dynamodb.py @@ -96,7 +96,7 @@ def create_table(self, table_name: str) -> None: TableName=table_name, KeySchema=key_schema, AttributeDefinitions=attribute_definitions, - BillingMode='PAY_PER_REQUEST' # on-demand capacity mode + BillingMode="PAY_PER_REQUEST", # on-demand capacity mode ) self.LOGGER.info(f"{table_name} dynamodb table created successfully.") except Exception as e: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py index 783454ef1..e58d2349d 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_iam.py @@ -105,9 +105,11 @@ def create_role(self, role_name: str, trust_policy: dict, solution_name: str) -> """ self.LOGGER.info("Creating role %s.", role_name) try: - return dict(self.IAM_CLIENT.create_role( - RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", "Value": solution_name}] - )) + return dict( + self.IAM_CLIENT.create_role( + RoleName=role_name, AssumeRolePolicyDocument=json.dumps(trust_policy), Tags=[{"Key": "sra-solution", "Value": solution_name}] + ) + ) except ClientError as error: if error.response["Error"]["Code"] == "EntityAlreadyExists": self.LOGGER.info(f"{role_name} role already exists!") @@ -126,9 +128,11 @@ def create_policy(self, policy_name: str, policy_document: dict, solution_name: """ self.LOGGER.info(f"Creating {policy_name} IAM policy") try: - return dict(self.IAM_CLIENT.create_policy( - PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", "Value": solution_name}] - )) + return dict( + self.IAM_CLIENT.create_policy( + PolicyName=policy_name, PolicyDocument=json.dumps(policy_document), Tags=[{"Key": "sra-solution", "Value": solution_name}] + ) + ) except ClientError as error: if error.response["Error"]["Code"] == "EntityAlreadyExists": self.LOGGER.info(f"{policy_name} policy already exists!") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py index 6ab02359c..1bb11f385 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/src/sra_s3.py @@ -92,7 +92,7 @@ def create_s3_bucket(self, bucket: str) -> None: ACL="private", Bucket=bucket, CreateBucketConfiguration={"LocationConstraint": self.REGION}, # type: ignore - ObjectOwnership="BucketOwnerPreferred" + ObjectOwnership="BucketOwnerPreferred", ) else: create_bucket = self.S3_CLIENT.create_bucket(ACL="private", Bucket=bucket, ObjectOwnership="BucketOwnerPreferred") From 8f7ef7e9d31c8896742e768505dc7e698aa14480 Mon Sep 17 00:00:00 2001 From: liamschn Date: Fri, 31 Jan 2025 14:47:14 -0700 Subject: [PATCH 388/395] update config rule annotation wording --- .../lambda/rules/sra_bedrock_check_eval_job_bucket/app.py | 4 ++-- .../lambda/rules/sra_bedrock_check_guardrails/app.py | 6 +++--- .../lambda/rules/sra_bedrock_check_iam_user_access/app.py | 5 ++++- .../lambda/rules/sra_bedrock_check_invocation_log_s3/app.py | 6 +++--- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py index f23c26697..5abfdf2c4 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_eval_job_bucket/app.py @@ -94,10 +94,10 @@ def evaluate_compliance(event: dict, context: Any) -> tuple[str, str]: # noqa: encryption = s3.get_bucket_encryption(Bucket=bucket_name) if "ServerSideEncryptionConfiguration" not in encryption: compliance_type = "NON_COMPLIANT" - annotation.append("KMS CMK encryption not enabled") + annotation.append("KMS customer-managed key encryption not enabled") except ClientError: compliance_type = "NON_COMPLIANT" - annotation.append("KMS CMK encryption not enabled") + annotation.append("KMS customer-managed key encryption not enabled") # Check logging if check_logging: diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py index abf126ef5..72d0a7266 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_guardrails/app.py @@ -110,13 +110,13 @@ def lambda_handler(event: dict, context: Any) -> dict: # noqa: CCR001, C901, U1 if compliant_guardrails: compliance_type = "COMPLIANT" if len(compliant_guardrails) == 1: - annotation = f"The following Bedrock guardrail contains all required features: {compliant_guardrails[0]}" + annotation = f"The following Bedrock Guardrail contains all required features: {compliant_guardrails[0]}" else: - annotation = f"The following Bedrock guardrails contain all required features: {', '.join(compliant_guardrails)}" + annotation = f"The following Bedrock Guardrails contain all required features: {', '.join(compliant_guardrails)}" LOGGER.info(f"Account is COMPLIANT. {annotation}") else: compliance_type = "NON_COMPLIANT" - annotation = "No Bedrock guardrails contain all required features. " + annotation = "No Bedrock Guardrails exist in this account that meet all required features. " for guardrail, missing in non_compliant_guardrails.items(): # type: ignore annotation += f" [{guardrail} is missing {', '.join(missing)}]" LOGGER.info(f"Account is NON_COMPLIANT. {annotation}") diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py index 97c97dd78..5702b8162 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -93,7 +93,10 @@ def evaluate_compliance(event: dict, context: Any) -> dict: # noqa: CCR001, U10 # Prepare the evaluation result if non_compliant_users: compliance_type = "NON_COMPLIANT" - annotation = "The following IAM users have access to the Amazon Bedrock service: " + ", ".join(non_compliant_users) + annotation = ( + "IAM users should not have direct access to Amazon Bedrock. These users have access and should use roles instead: " + + ", ".join(non_compliant_users) + ) else: compliance_type = "COMPLIANT" annotation = "No IAM users have access to the Amazon Bedrock service." diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py index 0b6df80a0..a88d73e12 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_invocation_log_s3/app.py @@ -63,7 +63,7 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 LOGGER.info(f"Bedrock Model Invocation S3 bucketName: {bucket_name}") BUCKET_NAME = bucket_name if not s3_config or not bucket_name: - return "NON_COMPLIANT", "S3 logging is not enabled for Bedrock Model Invocation Logging" + return "NON_COMPLIANT", "S3 logging destination is not enabled for Bedrock Model Invocation Logging" # Check S3 bucket configurations issues = [] @@ -107,8 +107,8 @@ def evaluate_compliance(rule_parameters: dict) -> tuple[str, str]: # noqa: CFQ0 return "INSUFFICIENT_DATA", f"Error evaluating Object Lock configuration: {str(error)}" if issues: - return "NON_COMPLIANT", f"S3 logging to {BUCKET_NAME} enabled but {', '.join(issues)}" - return "COMPLIANT", f"S3 logging properly configured for Bedrock Model Invocation Logging. Bucket: {bucket_name}" + return "NON_COMPLIANT", f"S3 logging destination to {BUCKET_NAME} enabled but {', '.join(issues)}" + return "COMPLIANT", f"S3 logging destination properly configured for Bedrock Model Invocation Logging. Bucket: {bucket_name}" except botocore.exceptions.ClientError as client_error: LOGGER.error(f"Error evaluating Bedrock Model Invocation Logging configuration: {str(client_error)}") return "INSUFFICIENT_DATA", f"Error evaluating compliance: {str(client_error)}" From 2c0881d699834739f32f1ddd48d186c05de65a27 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 3 Feb 2025 09:40:37 -0700 Subject: [PATCH 389/395] formatting --- .../lambda/rules/sra_bedrock_check_iam_user_access/app.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py index 5702b8162..10361ac68 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py +++ b/aws_sra_examples/solutions/genai/bedrock_org/lambda/rules/sra_bedrock_check_iam_user_access/app.py @@ -93,9 +93,8 @@ def evaluate_compliance(event: dict, context: Any) -> dict: # noqa: CCR001, U10 # Prepare the evaluation result if non_compliant_users: compliance_type = "NON_COMPLIANT" - annotation = ( - "IAM users should not have direct access to Amazon Bedrock. These users have access and should use roles instead: " - + ", ".join(non_compliant_users) + annotation = "IAM users should not have direct access to Amazon Bedrock. These users have access and should use roles instead: " + ", ".join( + non_compliant_users ) else: compliance_type = "COMPLIANT" From bdc7e3cf9d3ff455545ef6344781e1abf93a6c89 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 3 Feb 2025 10:17:07 -0700 Subject: [PATCH 390/395] update description of zip URL param --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index f2ce4e84d..c987acad7 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -6,7 +6,7 @@ Parameters: Type: String Default: 'https://github.com/aws-samples/aws-security-reference-architecture-examples/archive/refs/heads/main.zip' AllowedPattern: ^https://.*\.zip$ - Description: The S3 URL for the SRA solution zip file. Test for URL example - https://github.com/liamschn/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip + Description: The S3 URL for the SRA solution zip file. Defaults to prod URL. Test fork URL example - https://github.com//aws-security-reference-architecture-examples/archive/refs/heads/.zip ConstraintDescription: The S3 URL for the SRA code repository zip file. pDryRun: From eaf927c970a7c03ecacb6860c3021b43eaf255d7 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 3 Feb 2025 11:30:19 -0700 Subject: [PATCH 391/395] updating URL in readme --- aws_sra_examples/solutions/genai/bedrock_org/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/README.md b/aws_sra_examples/solutions/genai/bedrock_org/README.md index 86ffa297b..11900b99e 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/README.md +++ b/aws_sra_examples/solutions/genai/bedrock_org/README.md @@ -117,7 +117,7 @@ Please read the following notes before deploying the stack to ensure successful - This example assumes the CloudFormation template file is saved in the templates directory. Adjust the --template-body path if necessary. - Always validate the JSON parameters for correctness to avoid deployment errors. - Ensure the --capabilities CAPABILITY_NAMED_IAM flag is included to allow CloudFormation to create the necessary IAM resources. -- An example test fork URL for `pSRARepoZipUrl` is - `https://github.com/liamschn/aws-security-reference-architecture-examples/archive/refs/heads/sra-genai.zip` +- An example test fork URL for `pSRARepoZipUrl` is - `https://github.com//aws-security-reference-architecture-examples/archive/refs/heads/.zip` - The eval job bucket config rule will append `--` to the `BucketNamePrefix` parameter provided to get the existing bucket name(s). Ensure any S3 eval job bucket names to be checked match this naming convention. - The Config rule Lambda execution role needs to have access to any KMS keys used to encrypt Bedrock guardrails. Make sure to grant the appropriate KMS key permissions to the Lambda role to ensure proper evaluation of encrypted guardrail configurations. From ebf5582a0e37aefcec4f38b89fc6885dae9983e8 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 3 Feb 2025 11:39:03 -0700 Subject: [PATCH 392/395] update description --- .../genai/bedrock_org/templates/sra-bedrock-org-main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml index c987acad7..1af2e701c 100644 --- a/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml +++ b/aws_sra_examples/solutions/genai/bedrock_org/templates/sra-bedrock-org-main.yaml @@ -1,5 +1,5 @@ AWSTemplateFormatVersion: '2010-09-09' -Description: CloudFormation template to create a Lambda function and its execution role +Description: CloudFormation template to deploy the sra-bedrock-org solution for GenAI deep-dive Bedrock capability one security controls. See https://github.com/aws-samples/aws-security-reference-architecture-examples (sra-1u3sd7f8n) Parameters: pSRARepoZipUrl: From 5e4cdd38998bc81c81f7f95c2a143c48d33454ba Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 3 Feb 2025 11:43:12 -0700 Subject: [PATCH 393/395] add solution to main readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c17707634..3b8e7ee47 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ Please follow the instructions for SRA Terraform deployments in the [SRA Terrafo | [Security Hub](aws_sra_examples/solutions/securityhub/securityhub_org) | Configures Security Hub within a delegated admin account for all accounts and governed regions within the organization. | |

| | [Security Lake](aws_sra_examples/solutions/security_lake/security_lake_org) | Configures Security Lake within a delegated admin account for accounts and governed regions within the organization. | | | [Shield Advanced](aws_sra_examples/solutions/shield_advanced/shield_advanced) | Enables and configures AWS Shield Advanced for some or all the existing and future AWS Organization accounts | | | +| [Bedrock](aws_sra_examples/solutions/genai/bedrock_org) | Enables and configures security controls for Bedrock GenAI deep-dive capability one. | | | ## Utils From 2c3e4596763e48193c328fdcf67ec1cda48083f8 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 3 Feb 2025 11:45:41 -0700 Subject: [PATCH 394/395] sorting readme spreadsheet --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3b8e7ee47..bf9f13a0a 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ Please follow the instructions for SRA Terraform deployments in the [SRA Terrafo | :---------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Account Alternate Contacts](aws_sra_examples/solutions/account/account_alternate_contacts) | Sets the billing, operations, and security alternate contacts for all accounts within the organization. | | | | [AMI Bakery](aws_sra_examples/solutions/ami_bakery/ami_bakery_org) | Creates and configures an AMI image management pipeline. | | | +| [Bedrock](aws_sra_examples/solutions/genai/bedrock_org) | Enables and configures security controls for Bedrock GenAI deep-dive capability one. | | | | [CloudTrail](aws_sra_examples/solutions/cloudtrail/cloudtrail_org) | Organization trail with defaults set to configure data events (e.g. S3 and Lambda) to avoid duplicating the Control Tower configured CloudTrail. Options for configuring management events. | CloudTrail enabled in each account with management events only. | | | [Config Management Account](aws_sra_examples/solutions/config/config_management_account) | Enables AWS Config in the Management account to allow resource compliance monitoring. | Configures AWS Config in all accounts except for the Management account in each governed region. |
  • AWS Control Tower
| | [Config Organization Aggregator](aws_sra_examples/solutions/config/config_aggregator_org) | **Not required for most Control Tower environments.** Deploy an Organization Config Aggregator to a delegated admin other than the Audit account. | Organization Config Aggregator in the Management account and Account Config Aggregator in the Audit account. |
  • AWS Control Tower
  • [Common Register Delegated Administrator](aws_sra_examples/solutions/common/common_register_delegated_administrator)
| @@ -158,7 +159,6 @@ Please follow the instructions for SRA Terraform deployments in the [SRA Terrafo | [Security Hub](aws_sra_examples/solutions/securityhub/securityhub_org) | Configures Security Hub within a delegated admin account for all accounts and governed regions within the organization. | |
  • AWS Config in all Org Accounts
  • [Config Management Account](aws_sra_examples/solutions/config/config_management_account) (_if using AWS Control Tower_)
| | [Security Lake](aws_sra_examples/solutions/security_lake/security_lake_org) | Configures Security Lake within a delegated admin account for accounts and governed regions within the organization. | | | [Shield Advanced](aws_sra_examples/solutions/shield_advanced/shield_advanced) | Enables and configures AWS Shield Advanced for some or all the existing and future AWS Organization accounts | | | -| [Bedrock](aws_sra_examples/solutions/genai/bedrock_org) | Enables and configures security controls for Bedrock GenAI deep-dive capability one. | | | ## Utils From 9acc8ce32ad1afd63fe87606396cd95aa585da59 Mon Sep 17 00:00:00 2001 From: liamschn Date: Mon, 3 Feb 2025 11:55:53 -0700 Subject: [PATCH 395/395] update changelog --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e73d574..ad76f9dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,25 @@ All notable changes to this project will be documented in this file. --- + +## 2025-02-04 + +### Added + +- Added [Bedrock](aws_sra_examples/solutions/genai/bedrock_org) solution to deploy the sra-bedrock-org solution for GenAI deep-dive Bedrock capability one security controls. See https://github.com/aws-samples/aws-security-reference-architecture-examples (sra-1u3sd7f8n) + +## 2025-01-21 + +### Updated + +- Updated [Config Management Account](aws_sra_examples/solutions/config/config_management_account) solution to use service-linked role for AWS Config. + +## 2025-01-08 + +### Updated + +- Updated [Common Prerequisites](aws_sra_examples/solutions/common/common_prerequisites) staging util script to fix lambda layer deploy when using solution_directory. + ## 2024-09-18 ### Added