AWSTemplateFormatVersion: 2010-09-09
Description: Creates solution to validate attribute groups schema based on schema definition.

Parameters:
  SchemaDefinitionAttributeGroupName:
    Description: |
      The name of an existing AppRegistry attribute group that defines the schema.
    Type: String
  AttributeGroupNamePrefixes:
    Description: |
      Optionally, specify comma separated one or more prefix. Only those attribute groups will be validated
      which name starts with the prefix. Example: with AttributeGroupNamePrefix as "prod-, infra-"
      attribute group with name "infra-platform" will be validated.
    Type: String
    Default: ""

Resources:
  EventBridgeRule:
    Type: AWS::Events::Rule
    Properties:
      Description: Rule will send AppRegistry attribute group events to Lambda Function
      EventBusName: default
      EventPattern: {
        "source": [
          "aws.servicecatalog-appregistry"
        ],
        "detail": {
          "eventName": [
            "CreateAttributeGroup",
            "UpdateAttributeGroup"
          ]
        }
      }
      Name: !Sub 'SchemaValidation-${AWS::StackName}'
      State: ENABLED
      Targets:
        - Arn: !GetAtt LambdaFunc.Arn
          Id: AppRegStateMachine
  LambdaFuncLambdaFuncPermission:
    Type: 'AWS::Lambda::Permission'
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !GetAtt LambdaFunc.Arn
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt EventBridgeRule.Arn
  IamPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      Description: AppRegistry schema validation Lambda policy
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action:
              - servicecatalog:GetAttributeGroup
            Resource:
              - '*'
          - Effect: Allow
            Action:
              - 'logs:CreateLogGroup'
            Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*'
          - Effect: Allow
            Action:
              - 'logs:CreateLogStream'
              - 'logs:PutLogEvents'
            Resource:
              - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/SchemaValidation-${AWS::StackName}:*'
          - Effect: Allow
            Action:
              - 'ssm:CreateOpsItem'
            Resource:
              - !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:opsitem/*'
      Roles:
        - !Ref IamRole

  IamRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument: |-
        {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Action": "sts:AssumeRole",
              "Principal": {
                "Service": "lambda.amazonaws.com"
              },
              "Effect": "Allow",
              "Sid": ""
            }
          ]
        }
  LambdaFunc:
    Type: 'AWS::Lambda::Function'
    DependsOn:
      - IamRole
      - IamPolicy
    Properties:
      FunctionName: !Sub 'SchemaValidation-${AWS::StackName}'
      Runtime: "python3.9"
      Handler: "index.lambda_handler"
      Timeout: 30
      Environment:
        Variables:
          SCHEMA_DEFINITION_ATTR_GROUP_NAME: !Ref SchemaDefinitionAttributeGroupName
          ATTR_GROUP_PREFIX: !Ref AttributeGroupNamePrefixes
      Role: !GetAtt IamRole.Arn
      Code:
        ZipFile: |

          import json
          import boto3
          import os
          import urllib
          import uuid


          env_var_name = 'SCHEMA_DEFINITION_ATTR_GROUP_NAME'
          schema_definition_attr_group_name = os.environ.get(env_var_name)
          if not schema_definition_attr_group_name:
              raise KeyError(f'Missing required environment variable. Required vars <{env_var_name}>.')

          attr_group_prefix = os.environ.get('ATTR_GROUP_PREFIX', '')
          attr_group_prefix_tuple = tuple(attr_group_prefix.strip().split(','))

          client = boto3.client('servicecatalog-appregistry')
          ssm = boto3.client('ssm')


          def validate_schema(schema, target, path, collector):
              for k in schema:

                  if k not in target:
                      collector.append(f'{path}.{k}')
                      continue

                  if type(schema[k]) is dict:
                      validate_schema(schema[k], target[k], f"{path}.{k}", collector)


          def get_attr_group_schema_definition(attr_group_name):
              response = client.get_attribute_group(attributeGroup=attr_group_name)

              return json.loads(response['attributes'])


          def get_event_attr_group_name(event):
              return event['detail']['requestParameters']['name']


          def get_attr_group_target(event):
              return json.loads(event['detail']['requestParameters']['attributes'])


          def create_event_log(collector, event, event_attr_group_name):
              # if list isn't empty, target attribute group schema doesn't match schema definition
              if collector:
                  return {
                      'schemaDefinitionCompliant': False,
                      'missingAttributes': collector,
                      'attributeGroup': event['detail']['responseElements']['attributeGroup'],
                      'userIdentity': event['detail']['userIdentity'],
                      'eventName': event['detail']['eventName']
                  }

              return {
                  'schemaDefinitionCompliant': True,
                  'attributeGroupName': event_attr_group_name
              }


          def create_ops_item_if_not_compliant(obj, context):
              if obj['schemaDefinitionCompliant']:
                  return

              ops_item_id = 'N/A'
              logs_version = 'logsV2'
              cw_log_url = 'https://{REGION}.console.aws.amazon.com/cloudwatch/home?region={REGION}#{LOGS_VERSION}:' \
                           'log-groups/log-group/{LOG_GROUP_NAME}/log-events/{LOG_STREAM_NAME}?filterPattern={LOG_ID}'

              cw_log_url_enc = cw_log_url.format(
                  REGION=context['invoked_function_arn'].split(':')[3],
                  LOGS_VERSION=logs_version,
                  LOG_GROUP_NAME=urllib.parse.quote(context['log_group_name'], safe=''),
                  LOG_STREAM_NAME=urllib.parse.quote(context['log_stream_name'], safe=''),
                  LOG_ID=obj['log_id']
              )

              description = f'CloudWatch logs console URL: {cw_log_url_enc}'

              try:
                  arn = obj['attributeGroup']['arn']
                  attr_group_id = arn.split('/')[-1]
                  response = ssm.create_ops_item(
                      Description=description,
                      OperationalData={
                          '/aws/dedup': {
                              'Value': str(json.dumps({"dedupString": attr_group_id})),
                              'Type': 'SearchableString'
                          },
                          '/aws/resources': {
                              'Value': str(json.dumps([{"arn": arn}])),
                              'Type': 'SearchableString'
                          }
                      },
                      Source='Service Catalog AppRegistry',
                      Title=f'Non-compliant attribute group {attr_group_id}'
                  )
                  ops_item_id = response['OpsItemId']
                  print(f'OpsItemId: {ops_item_id}')
              except ssm.exceptions.OpsItemAlreadyExistsException as e:
                  # find id on the existing OpsItem
                  err_list = str(e).split("oi-")
                  if len(err_list) == 2:
                      ops_item_id = f'oi-{err_list[1]}'

              return ops_item_id


          def lambda_handler(event, context):
              event_attr_group_name = get_event_attr_group_name(event)

              # if schema definition itself changed, return
              if schema_definition_attr_group_name == event_attr_group_name:
                  return

              # if attribute group doesn't match any of the prefix patterns, return
              elif not event_attr_group_name.startswith(attr_group_prefix_tuple):
                  return

              schema = get_attr_group_schema_definition(schema_definition_attr_group_name)
              target = get_attr_group_target(event)

              collector = []
              validate_schema(schema, target, '$', collector)

              event_log = create_event_log(collector, event, event_attr_group_name)
              event_log['log_id'] = uuid.uuid4().hex
              ops_item_id = create_ops_item_if_not_compliant(event_log, context.__dict__)
              event_log['ops_item_id'] = ops_item_id
              print(json.dumps(event_log))
