diff --git a/stacks/control_broker_stack.py b/stacks/control_broker_stack.py index 91d4ba0c..ad87579e 100644 --- a/stacks/control_broker_stack.py +++ b/stacks/control_broker_stack.py @@ -70,6 +70,7 @@ def __init__( self.output_handler_event_driven() + self.input_handler_cfn_hook() self.input_handler_cloudformation() self.input_handler_config_event() self.input_handler_cross_cloud_custom_auth() @@ -755,28 +756,106 @@ def input_handler_config_event(self): ) ) - def input_handler_cfn_hooks(self): + def input_handler_cfn_hook(self): - self.lambda_invoked_by_apigw_cfn_hooks = aws_lambda.Function( + self.bucket_cfn_hook_converted_inputs = aws_s3.Bucket( self, - "InvokedByApigwCfnHooks", + "CFNHookConvertedInput", + removal_policy=RemovalPolicy.DESTROY, + auto_delete_objects=True, + block_public_access=aws_s3.BlockPublicAccess( + block_public_acls=True, + ignore_public_acls=True, + block_public_policy=True, + restrict_public_buckets=True, + ), + ) + + self.bucket_cfn_hook_raw_inputs = aws_s3.Bucket( + self, + "CFNHookRawInput", + removal_policy=RemovalPolicy.DESTROY, + auto_delete_objects=True, + block_public_access=aws_s3.BlockPublicAccess( + block_public_acls=True, + ignore_public_acls=True, + block_public_policy=True, + restrict_public_buckets=True, + ), + ) + + self.lambda_invoked_by_apigw_cfn_hook = aws_lambda.Function( + self, + "InvokedByApigwCFNHook", runtime=aws_lambda.Runtime.PYTHON_3_9, handler="lambda_function.lambda_handler", timeout=Duration.seconds(60), memory_size=1024, code=aws_lambda.Code.from_asset( - "./supplementary_files/lambdas/invoked_by_apigw_cfn_hooks" + "./supplementary_files/lambdas/invoked_by_apigw_cfn_hook" ), + environment={ + "RawPaCResultsBucket": self.bucket_raw_pac_results.bucket_name, + "OutputHandlers": json.dumps( + { + "OPA": { + "Bucket": self.bucket_output_handler.bucket_name + } + } + ), + "CFNHookConvertedInputsBucket":self.bucket_cfn_hook_converted_inputs.bucket_name + }, layers=[ self.layers['requests'], self.layers['aws_requests_auth'] ] ) - self.api.add_api_handler( - "CfnHooks", self.lambda_invoked_by_apigw_cfn_hooks, "/CfnHooks" + self.lambda_invoked_by_apigw_cfn_hook.role.add_to_policy( + aws_iam.PolicyStatement( + actions=[ + "cloudformation:ValidateTemplate", + "cloudformation:DescribeType", + ], + resources=["*"], + ) + ) + + self.lambda_invoked_by_apigw_cfn_hook.role.add_to_policy( + aws_iam.PolicyStatement( + actions=[ + "cloudcontrol:GetResource", + ], + resources=["*"], + ) ) + self.lambda_invoked_by_apigw_cfn_hook.role.add_to_policy( + aws_iam.PolicyStatement( + actions=[ + "s3:GetObject", # required to generate presigned url for get_object ClientMethod + ], + resources=[ + self.bucket_raw_pac_results.bucket_arn, + self.bucket_raw_pac_results.arn_for_objects("*"), + self.bucket_output_handler.bucket_arn, + self.bucket_output_handler.arn_for_objects("*"), + ], + ) + ) + + self.lambda_invoked_by_apigw_cfn_hook.role.add_to_policy( + aws_iam.PolicyStatement( + actions=[ + "s3:PutObject", + ], + resources=[ + self.bucket_cfn_hook_converted_inputs.bucket_arn, + self.bucket_cfn_hook_converted_inputs.arn_for_objects("*"), + ], + ) + ) + def input_handler_cross_cloud_custom_auth(self): self.bucket_cross_cloud_inputs = aws_s3.Bucket( @@ -1047,6 +1126,8 @@ def eval_engine(self): self.bucket_evaluation_context.arn_for_objects("*"), self.bucket_config_events_converted_inputs.bucket_arn, self.bucket_config_events_converted_inputs.arn_for_objects("*"), + self.bucket_cfn_hook_converted_inputs.bucket_arn, + self.bucket_cfn_hook_converted_inputs.arn_for_objects("*"), self.bucket_cloudformation_raw_inputs.bucket_arn, self.bucket_cloudformation_raw_inputs.arn_for_objects("*"), self.bucket_cross_cloud_inputs.bucket_arn, @@ -1073,6 +1154,10 @@ def eval_engine(self): def add_apis(self): + handler_url_cfn_hook = self.api.add_api_handler( + "CFNHook", self.lambda_invoked_by_apigw_cfn_hook, "/CFNHook" + ) + handler_url_config_event = self.api.add_api_handler( "ConfigEvent", self.lambda_invoked_by_apigw_config_event, "/ConfigEvent" ) diff --git a/supplementary_files/lambdas/invoked_by_apigw_cfn_hook/lambda_function.py b/supplementary_files/lambdas/invoked_by_apigw_cfn_hook/lambda_function.py new file mode 100644 index 00000000..c3100153 --- /dev/null +++ b/supplementary_files/lambdas/invoked_by_apigw_cfn_hook/lambda_function.py @@ -0,0 +1,433 @@ +import json +import re +import os +import uuid + +import boto3 +from botocore.exceptions import ClientError + +import requests +from aws_requests_auth.boto_utils import BotoAWSRequestsAuth + +session = boto3.session.Session() +region = session.region_name +account_id = boto3.client('sts').get_caller_identity().get('Account') + +s3 = boto3.client('s3') +cfn = boto3.client('cloudformation') +cloudcontrol = boto3.client('cloudcontrol') + +def get_object(*,bucket,key): + + try: + r = s3.get_object( + Bucket = bucket, + Key = key + ) + except ClientError as e: + print(f'ClientError:\nbucket:\n{bucket}\nkey:\n{key}\n{e}') + raise + else: + print(f'no ClientError get_object:\nbucket:\n{bucket}\nkey:\n{key}') + body = r['Body'] + content = json.loads(body.read().decode('utf-8')) + return content + +def put_object(*,bucket,key,object_:dict): + try: + r = s3.put_object( + Bucket = bucket, + Key = key, + Body = json.dumps(object_) + ) + except ClientError as e: + print(f'ClientError:\nbucket:\n{bucket}\nkey:\n{key}\n{e}') + raise + else: + print(f'no ClientError put_object:\nbucket:\n{bucket}\nkey:\n{key}') + return True + +class RequestParser(): + + def __init__(self,*, + event + ): + self.event = event + + self.request_json_body = json.loads(event['body']) + self.headers = event['headers'] + self.consumer_request_context = self.request_json_body['Context'] + + self.main() + + def requestor_is_authorized(self): + # TODO + self._requestor_is_authorized = True + return True + + def input_grants_required_read_access(self): + # TODO + self._input_grants_required_read_access = True + return True + + def get_validated_input_type(self): + # TODO + + # go to that provided object + + # validate it matches type expected by this handler + self.validated_input_type = "CloudFormation" + return self.validated_input_type + + def fail_fast(self): + + fail_fast=None + + request = { + "Request":{ + "Requestor": { + "IsAuthorized": self.requestor_is_authorized(), + }, + "Input": { + "GrantsRequiredReadAccess": self.input_grants_required_read_access() + }, + "InputType":{ + "Validated":bool(self.get_validated_input_type()) + }, + "Context":{ + "IsApproved":bool(self.approved_context) + } + } + } + + def any_false_leaf(d): + if isinstance(d, dict): + return any(any_false_leaf(v) for v in d.values()) + return not d + + if any_false_leaf(request): + fail_fast = request + + print(f'fail_fast:{fail_fast}') + return fail_fast + + def get_consumer_metadata(self): + + def integrate_with_my_identity_provider(event): + + headers = event['headers'] + + authorization_header = headers['authorization'] + + # make external call per enterprise implementation + # demo values below + + return { + "Org":"OrgA", + "BusinessUnit":"BU1", + "BillingCode":"bu-01", + "Name":"Jane Ray" + } + + self.consumer_metadata = { + "SSOAttributes": integrate_with_my_identity_provider(self.event) + } + + return self.consumer_metadata + + def get_approved_context(self): + + def integrate_with_my_entitlement_system(consumer_metadata,consumer_request_context): + + # make external call per enterprise implementation + + # if consumer is authorized to call the CB with the context it provided, then return the unmodified context + + # if not, return failure + + # demo check below + + return consumer_metadata['SSOAttributes']['Org'] == "OrgA" + + if integrate_with_my_entitlement_system(self.consumer_metadata,self.consumer_request_context): + + self.approved_context = self.consumer_request_context + + return self.approved_context + + else: + + return False + + + def main(self): + + self.get_consumer_metadata() + + self.get_approved_context() + +class CFNHookToCloudFormationConverter(): + + def __init__( + self, + cfn_hook_input_to_be_evaluated:dict + ): + + self.cfn_hook = cfn_hook_input_to_be_evaluated + + def _fix_boolean(self, a_dict): + for k, v in a_dict.items(): + if not isinstance(v, dict): + if v and 'false' in v: + a_dict[k] = False + if v and 'true' in v: + a_dict[k] = True + else: + self._fix_boolean(v) + return a_dict + + def parse_cfn_hook(self): + + print(f'cfn_hook:\n{self.cfn_hook}') + + self.resource_type = self.cfn_hook['targetType'] + print(f'resource_type:\n{self.resource_type}') + + self.resource_id = self.cfn_hook['targetLogicalId'] + print(f'resource_id:\n{self.resource_id}') + + self.resource_properties = self._fix_boolean(self.cfn_hook['targetModel']['resourceProperties']) + print(f'resource_properties:\n{self.resource_properties}') + + def get_converted_cloudformation(self): + self.cfn = { + "Resources" : { + "CFNHookResource" : { + "Type" : self.resource_type, + "Properties" : self.resource_properties + } + } + } + print(f'cfn:\n{self.cfn}') + + return self.cfn + + def get_converted_s3_path(self): + + self.parse_cfn_hook() + self.get_converted_cloudformation() + + return self.cfn + +def convert_cfn_hook_to_cfn(*,cfn_hook_input_to_be_evaluated): + + c = CFNHookToCloudFormationConverter(cfn_hook_input_to_be_evaluated) + + modified_input_to_be_evaluated = c.get_converted_s3_path() + + return modified_input_to_be_evaluated + +def sign_request(*, + full_invoke_url:str, + region:str, + input:dict, +): + + def get_host(full_invoke_url): + m = re.search('https://(.*)/.*',full_invoke_url) + return m.group(1) + + host = get_host(full_invoke_url) + + auth = BotoAWSRequestsAuth( + aws_host= host, + aws_region=region, + aws_service='execute-api' + ) + + print(f'begin request\nfull_invoke_url\n{full_invoke_url}\njson input\n{input}') + + r = requests.post( + full_invoke_url, + auth = auth, + json = input + ) + + print(f'signed request headers:\n{dict(r.request.headers)}') + + content = json.loads(r.content) + + r = { + 'StatusCode':r.status_code, + 'Content': content + } + + print(f'formatted response:\n{r}') + + return True + +def generate_uuid(): + return str(uuid.uuid4()) + +def generate_s3_uuid_uri(*,bucket): + + uuid = generate_uuid() + + s3_uri = f's3://{bucket}/{uuid}' + + return s3_uri + +def generate_presigned_url(bucket,key,client_method="get_object",ttl=3600): + try: + url = s3.generate_presigned_url( + ClientMethod=client_method, + Params={ + 'Bucket':bucket, + 'Key':key + }, + ExpiresIn=ttl + ) + except ClientError: + raise + else: + print(f"Presigned URL:\n{url}") + return url + +def format_response_expected_by_consumer(response_expected_by_consumer): + + from collections.abc import MutableMapping + from contextlib import suppress + + def delete_keys_from_dict(dictionary, keys): + for key in keys: + with suppress(KeyError): + del dictionary[key] + for value in dictionary.values(): + if isinstance(value, MutableMapping): + delete_keys_from_dict(value, keys) + + delete_keys_from_dict(response_expected_by_consumer,['Bucket','Key']) + + return response_expected_by_consumer + +def lambda_handler(event,context): + + print(f'event:\n{event}\ncontext:\n{context}') + + # instantiate + + r = RequestParser(event=event) + + # parse event + + request_json_body = json.loads(event['body']) + + print(f'request_json_body:\n{request_json_body}') + + headers = event['headers'] + + print(f'headers:\n{headers}') + + authorization_header = headers['authorization'] + + print(f'authorization_header:\n{authorization_header}') + + # fail fast + + fail_fast = r.fail_fast() + + if fail_fast: + return fail_fast + + # set response + + evaluation_key = f'cb-{generate_uuid()}' + + response_expected_by_consumer = { + "ControlBrokerEvaluation": { + "Raw": { + "PresignedUrl": generate_presigned_url( + bucket = os.environ['RawPaCResultsBucket'], + key = evaluation_key + ), + "Bucket": os.environ['RawPaCResultsBucket'], + "Key": evaluation_key + }, + "OutputHandlers":{ + "OPA": { + "PresignedUrl": generate_presigned_url( + bucket = json.loads(os.environ['OutputHandlers'])['OPA']['Bucket'], + key = evaluation_key + ), + "Bucket": json.loads(os.environ['OutputHandlers'])['OPA']['Bucket'], + "Key": evaluation_key + } + } + } + } + + original_input_to_be_evaluated = request_json_body['Input'] + + print(f'original_input_to_be_evaluated:\n{original_input_to_be_evaluated}') + + converted_input_to_be_evaluated_object = convert_cfn_hook_to_cfn( + cfn_hook_input_to_be_evaluated = original_input_to_be_evaluated + ) + + print(f'converted_input_to_be_evaluated_object:\n{converted_input_to_be_evaluated_object}') + + # set input + + input_to_be_evaluated = { + 'Bucket' : os.environ['CFNHookConvertedInputsBucket'], + 'Key' : evaluation_key, + } + + put_object( + bucket = input_to_be_evaluated['Bucket'], + key = input_to_be_evaluated['Key'], + object_ = converted_input_to_be_evaluated_object + ) + + eval_engine_input = { + "InputToBeEvaluated": input_to_be_evaluated, + "ConsumerMetadata": r.consumer_metadata, + "Context": r.approved_context, + "InputType": r.validated_input_type, + "ResponseExpectedByConsumer": response_expected_by_consumer + } + + print(f'eval_engine_input:\n{eval_engine_input}') + + # sign request + + sign_request( + full_invoke_url = headers['x-eval-engine-invoke-url'], + region = region, + input = eval_engine_input + ) + + # set response + + control_broker_request_status = { + "Request":{ + "Content": request_json_body, + "Requestor": { + "IsAuthorized": r._requestor_is_authorized + }, + "Input": { + "GrantsRequiredReadAccess": r._input_grants_required_read_access + }, + "InputType":{ + "Validated":bool(r.validated_input_type) + }, + "Context":{ + "IsApproved":bool(r.approved_context) + } + }, + "Response": format_response_expected_by_consumer(response_expected_by_consumer) + } + + print(f'control_broker_request_status:\n{control_broker_request_status}') + + return control_broker_request_status \ No newline at end of file