source/idea/infrastructure/install/stacks/ad_sync_stack.py (587 lines of code) (raw):

# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 from typing import Any, Dict, Optional, Union import aws_cdk as cdk import constructs from aws_cdk import Duration from aws_cdk import aws_dynamodb as dynamodb from aws_cdk import aws_ec2 as ec2 from aws_cdk import aws_ecs as ecs from aws_cdk import aws_events as events from aws_cdk import aws_events_targets as events_targets from aws_cdk import aws_iam as iam from aws_cdk import aws_lambda as _lambda from aws_cdk import aws_logs as logs from aws_cdk.aws_events import Schedule from res.constants import ( # type: ignore AD_SYNC_LOCK_TABLE, AD_SYNC_STATUS_SUBMISSION_TIME_KEY, AD_SYNC_STATUS_TABLE, AD_SYNC_STATUS_TASK_ID_KEY, AD_SYNC_STATUS_TTL_KEY, ENVIRONMENT_NAME_KEY, LOCK_DB_HASH_KEY, LOCK_DB_RANGE_KEY, MODULE_NAME_DIRECTORY_SERVICE, ) from res.resources import cluster_settings # type: ignore from idea.batteries_included.parameters.parameters import BIParameters from idea.infrastructure.install.constants import ( RES_COMMON_LAMBDA_RUNTIME, RES_ECR_REPO_NAME_SUFFIX, ) from idea.infrastructure.install.constructs.base import ResBaseConstruct from idea.infrastructure.install.ddb_tables.base import RESDDBTableBase from idea.infrastructure.install.ddb_tables.list import RESDDBTable from idea.infrastructure.install.handlers import scheduled_ad_sync_handler from idea.infrastructure.install.parameters.common import CommonKey from idea.infrastructure.install.parameters.parameters import RESParameters from idea.infrastructure.install.utils import InfraUtils from idea.infrastructure.resources.lambda_functions.custom_resource.ad_sync_resources_populator_lambda import ( ad_sync_resources_populator_handler, ) from idea.infrastructure.resources.lambda_functions.custom_resource.ad_sync_task_terminator_lambda import ( handler, ) class ADSyncStack(ResBaseConstruct): """ Setup infrastructure for the AD Sync process """ def __init__( self, scope: constructs.Construct, lambda_layer: _lambda.LayerVersion, parameters: Union[RESParameters, BIParameters] = RESParameters(), registry_name: Optional[str] = None, ): self.parameters = parameters self.cluster_name = parameters.get_str(CommonKey.CLUSTER_NAME) self.registry_name = registry_name if registry_name else "" self.lambda_layer = lambda_layer super().__init__( self.cluster_name, cdk.Aws.REGION, "ad-sync", scope, parameters, ) self.nested_stack = cdk.NestedStack( scope, "ad-sync", description="Nested Stack for supporting AD Sync", ) vpc = ec2.Vpc.from_vpc_attributes( self.nested_stack, "ExistingVpc", availability_zones=[""], vpc_id=self.parameters.get_str(CommonKey.VPC_ID), private_subnet_ids=[ cdk.Fn.select( 0, self.parameters.get( CommonKey.INFRASTRUCTURE_HOST_SUBNETS ).value_as_list, ), cdk.Fn.select( 1, self.parameters.get( CommonKey.INFRASTRUCTURE_HOST_SUBNETS ).value_as_list, ), ], ) self.ecs_cluster = ecs.Cluster( self.nested_stack, "ADSyncCluster", vpc=vpc, cluster_name=f"{self.cluster_name}-ad-sync-cluster", ) self.build_ad_sync_security_group(vpc) self.build_ad_sync_lock_table() self.build_ad_sync_status_table() self.build_scheduled_event_ad_sync_infra() self.build_ad_sync_task_definition() self.terminate_ad_sync_ecs_task() self.build_ad_sync_resources_populator() self.nested_stack.node.add_dependency(self.lambda_layer) self.apply_permission_boundary(self.nested_stack) def build_ad_sync_security_group(self, vpc: ec2.IVpc) -> None: """ Create the AD Sync Security Group. """ self.ad_sync_security_group = ec2.SecurityGroup( self.nested_stack, "ADSyncSecurityGroup", security_group_name=f"{self.cluster_name}-ad-sync-security-group", vpc=vpc, description="Security group for AD Sync task", ) self.ad_sync_security_group.add_egress_rule( ec2.Peer.ipv4("0.0.0.0/0"), ec2.Port.all_traffic(), description="Allow all outbound traffic by default", ) self.add_common_tags(self.ad_sync_security_group) def build_ad_sync_lock_table(self) -> None: """ Create the DynamoDB table used to lock AD Sync operations. """ ad_sync_lock_table: RESDDBTable = RESDDBTable( id=AD_SYNC_LOCK_TABLE, module_id=MODULE_NAME_DIRECTORY_SERVICE, table_props=dynamodb.TableProps( partition_key=dynamodb.Attribute( name=LOCK_DB_HASH_KEY, type=dynamodb.AttributeType.STRING ), sort_key=dynamodb.Attribute( name=LOCK_DB_RANGE_KEY, type=dynamodb.AttributeType.STRING ), ), ) self.ad_sync_lock_table = RESDDBTableBase( self.nested_stack, ad_sync_lock_table.id, self.cluster_name, ad_sync_lock_table, ).ddb_table def build_ad_sync_status_table(self) -> None: """ Create the DynamoDB table for tracking AD Sync ECS task status. """ ad_sync_status_table: RESDDBTable = RESDDBTable( id=AD_SYNC_STATUS_TABLE, module_id=MODULE_NAME_DIRECTORY_SERVICE, table_props=dynamodb.TableProps( partition_key=dynamodb.Attribute( name=AD_SYNC_STATUS_TASK_ID_KEY, type=dynamodb.AttributeType.STRING ), sort_key=dynamodb.Attribute( name=AD_SYNC_STATUS_SUBMISSION_TIME_KEY, type=dynamodb.AttributeType.NUMBER, ), time_to_live_attribute=AD_SYNC_STATUS_TTL_KEY, ), ) self.ad_sync_status_table = RESDDBTableBase( self.nested_stack, ad_sync_status_table.id, self.cluster_name, ad_sync_status_table, ).ddb_table def build_scheduled_event_ad_sync_infra(self) -> None: lambda_name = f"{self.cluster_name}-scheduled-ad-sync" scheduled_ad_sync_lambda_role = iam.Role( self.nested_stack, id="scheduled-ad-sync-role", role_name=f"{lambda_name}-role", assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), description=f"{lambda_name}-role", ) scheduled_ad_sync_lambda_role.attach_inline_policy( iam.Policy( self.nested_stack, id="scheduled-ad-sync-policy", policy_name=f"{lambda_name}-policy", statements=[ iam.PolicyStatement( actions=["logs:CreateLogGroup"], sid="CloudWatchLogsPermissions", resources=["*"], ), iam.PolicyStatement( actions=[ "logs:CreateLogStream", "logs:PutLogEvents", "logs:DeleteLogStream", ], sid="CloudWatchLogStreamPermissions", resources=["*"], ), iam.PolicyStatement( actions=[ "dynamodb:GetItem", "dynamodb:Scan", ], sid="ClusterSettingsTablePermissions", resources=[ f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.cluster-settings", ], ), iam.PolicyStatement( actions=[ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", ], sid="ADSyncLockTablePermissions", resources=[ f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.ad-sync.distributed-lock", ], ), iam.PolicyStatement( actions=[ "dynamodb:Query", "dynamodb:Scan", "dynamodb:UpdateItem", "dynamodb:PutItem", ], sid="ADSyncStatusTablePermissions", resources=[ f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.ad-sync.status", ], ), iam.PolicyStatement( actions=[ "ecs:RunTask", "ecs:StopTask", "ecs:ListTasks", ], resources=["*"], conditions={ "ArnEquals": {"ecs:cluster": self.ecs_cluster.cluster_arn} }, ), iam.PolicyStatement( actions=["iam:PassRole"], resources=[ f"arn:{cdk.Aws.PARTITION}:iam::{cdk.Aws.ACCOUNT_ID}:role/{self.cluster_name}-ad-sync-task-role", ], ), iam.PolicyStatement( actions=["ec2:DescribeSecurityGroups"], resources=["*"], ), ], ) ) self.add_common_tags(scheduled_ad_sync_lambda_role) scheduled_ad_sync_lambda = _lambda.Function( self.nested_stack, id="scheduled-ad-sync", function_name=lambda_name, description=f"Lambda to send scheduled event to trigger ad sync", environment={ "environment_name": self.cluster_name, }, timeout=Duration.seconds(180), role=scheduled_ad_sync_lambda_role, runtime=RES_COMMON_LAMBDA_RUNTIME, **InfraUtils.get_handler_and_code_for_function( scheduled_ad_sync_handler.handler ), layers=[self.lambda_layer], ) self.add_common_tags(scheduled_ad_sync_lambda) # CloudFormation that doesn't support Tags for Event Bridge rule currently: # Check https://github.com/aws/aws-cdk/issues/4907 schedule_trigger_rule = events.Rule( self.nested_stack, id="ad-sync-schedule-rule", enabled=True, rule_name=f"{self.cluster_name}-ad-sync-schedule-rule", description="Event Rule to Trigger schedule AD sync EVERY hour", schedule=Schedule.cron(minute="0", hour="0/1"), # every 1 hour ) schedule_trigger_rule.add_target( events_targets.LambdaFunction( scheduled_ad_sync_lambda, ) ) def build_ad_sync_task_definition(self) -> None: ad_sync_task_role = self.build_ad_sync_task_role() self.task_definition = ecs.TaskDefinition( self.nested_stack, id="ad-sync-task-definition", compatibility=ecs.Compatibility.FARGATE, task_role=ad_sync_task_role, execution_role=ad_sync_task_role, memory_mib="1024", cpu="512", family=f"{self.cluster_name}-ad-sync-task-definition", ) commands = " && ".join( [ "source venv/bin/activate", "exec res-ad-sync", ] ) self.task_definition.add_container( "ad-sync-task-container", image=ecs.ContainerImage.from_registry(self.registry_name), command=["/bin/sh", "-exc", commands], environment={ "environment_name": self.cluster_name, "AWS_DEFAULT_REGION": cdk.Aws.REGION, }, logging=ecs.LogDriver.aws_logs( stream_prefix="ecs", log_group=logs.LogGroup( self.nested_stack, "ad-sync-task-log-group", log_group_name=f"{self.cluster_name}/ad-sync", removal_policy=cdk.RemovalPolicy.DESTROY, ), ), ) self.add_common_tags(self.task_definition) def build_ad_sync_task_role(self) -> iam.Role: ad_sync_task_role = iam.Role( self.nested_stack, id="ad-sync-task-role", role_name=f"{self.cluster_name}-ad-sync-task-role", assumed_by=iam.ServicePrincipal("ecs-tasks.amazonaws.com"), description=f"ad-sync-task-role", ) ad_sync_task_role.attach_inline_policy( iam.Policy( self.nested_stack, id="ad-sync-task-policy", policy_name=f"{self.cluster_name}-ad-sync-task-policy", statements=[ iam.PolicyStatement( actions=["logs:CreateLogGroup"], sid="CloudWatchLogsPermissions", resources=["*"], ), iam.PolicyStatement( actions=[ "logs:CreateLogStream", "logs:PutLogEvents", "logs:DeleteLogStream", ], sid="CloudWatchLogStreamPermissions", resources=["*"], ), iam.PolicyStatement( actions=["secretsmanager:GetSecretValue"], sid="SecretsManagerPermissions", resources=["*"], ), iam.PolicyStatement( actions=[ "dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan", "dynamodb:UpdateItem", "dynamodb:PutItem", "dynamodb:DeleteItem", ], sid="DynamoDBPermissions", resources=[ f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.cluster-settings", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.accounts.users", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.accounts.users/index/*", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.accounts.groups", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.accounts.group-members", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.projects", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.projects/index/*", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.authz.role-assignments", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.authz.role-assignments/index/*", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.{AD_SYNC_STATUS_TABLE}", f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.{AD_SYNC_STATUS_TABLE}/index/*", ], ), iam.PolicyStatement( effect=iam.Effect.ALLOW, resources=[ f"arn:{cdk.Aws.PARTITION}:ecr:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:repository/{self.cluster_name}{RES_ECR_REPO_NAME_SUFFIX}" ], actions=[ "ecr:BatchGetImage", "ecr:DescribeRepositories", "ecr:GetDownloadUrlForLayer", "ecr:GetLifecyclePolicy", "ecr:GetRepositoryPolicy", "ecr:ListTagsForResource", ], sid="ECRPermissions", ), iam.PolicyStatement( effect=iam.Effect.ALLOW, resources=["*"], actions=[ "ecr:GetAuthorizationToken", ], sid="ECRAuthorizationPermissions", ), ], ) ) self.add_common_tags(ad_sync_task_role) return ad_sync_task_role # Create a custom lambda to terminate AD sync ECS task before deleting AD sync ECS cluster def terminate_ad_sync_ecs_task(self) -> None: lambda_name = f"{self.cluster_name}-terminate-ad-sync-ecs-task" terminate_ad_sync_ecs_task_role = iam.Role( self.nested_stack, id="terminate-ad-sync-ecs-task-role", role_name=f"{lambda_name}-role", assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), description=f"{lambda_name}-role", ) terminate_ad_sync_ecs_task_policy = iam.Policy( self.nested_stack, id="terminate-ad-sync-ecs-task-policy", policy_name=f"{lambda_name}-policy", statements=[ iam.PolicyStatement( actions=["logs:CreateLogGroup"], sid="CloudWatchLogsPermissions", resources=["*"], ), iam.PolicyStatement( actions=[ "logs:CreateLogStream", "logs:PutLogEvents", "logs:DeleteLogStream", ], sid="CloudWatchLogStreamPermissions", resources=["*"], ), iam.PolicyStatement( actions=[ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem", ], sid="ADSyncLockTablePermissions", resources=[ f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.ad-sync.distributed-lock", ], ), iam.PolicyStatement( actions=[ "dynamodb:Query", "dynamodb:Scan", "dynamodb:UpdateItem", "dynamodb:PutItem", ], sid="ADSyncStatusTablePermissions", resources=[ f"arn:{cdk.Aws.PARTITION}:dynamodb:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:table/{self.cluster_name}.ad-sync.status", ], ), iam.PolicyStatement( actions=[ "ecs:StopTask", "ecs:ListTasks", "ecs:DescribeTasks", ], resources=["*"], conditions={ "ArnEquals": {"ecs:cluster": self.ecs_cluster.cluster_arn} }, ), iam.PolicyStatement( actions=["iam:PassRole"], resources=[ f"arn:{cdk.Aws.PARTITION}:iam::{cdk.Aws.ACCOUNT_ID}:role/{self.cluster_name}-ad-sync-task-role", ], ), ], ) terminate_ad_sync_ecs_task_role.attach_inline_policy( terminate_ad_sync_ecs_task_policy ) self.add_common_tags(terminate_ad_sync_ecs_task_role) terminate_ad_sync_ecs_task_lambda = _lambda.Function( self.nested_stack, id="terminate-ad-sync-ecs-task", function_name=lambda_name, description=f"Custom lambda to terminate AD sync ECS task before deleting AD sync ECS cluster", environment={ "environment_name": self.cluster_name, }, timeout=Duration.seconds(300), role=terminate_ad_sync_ecs_task_role, runtime=RES_COMMON_LAMBDA_RUNTIME, **InfraUtils.get_handler_and_code_for_function(handler.handler), layers=[self.lambda_layer], ) self.add_common_tags(terminate_ad_sync_ecs_task_lambda) terminate_ad_sync_ecs_task_custom_resource = cdk.CustomResource( self.nested_stack, id="terminate-ad-sync-ecs-task-custom-resource", service_token=terminate_ad_sync_ecs_task_lambda.function_arn, removal_policy=cdk.RemovalPolicy.DESTROY, resource_type="Custom::TerminateADSyncECSTask", ) terminate_ad_sync_ecs_task_custom_resource.node.add_dependency( terminate_ad_sync_ecs_task_policy ) terminate_ad_sync_ecs_task_custom_resource.node.add_dependency(self.ecs_cluster) terminate_ad_sync_ecs_task_custom_resource.node.add_dependency( self.ad_sync_lock_table ) # Create a custom lambda to store AD sync resources in DDB def build_ad_sync_resources_populator(self) -> None: lambda_name = f"{self.cluster_name}-ad-sync-resources-populator" ad_sync_resources_populator_role = iam.Role( self.nested_stack, id="ad-sync-resources-populator-role", role_name=f"{lambda_name}-role", assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"), description=f"{lambda_name}-role", ) ad_sync_resources_populator_policy = iam.Policy( self.nested_stack, id="ad-sync-resources-populator-policy", policy_name=f"{lambda_name}-policy", statements=[ iam.PolicyStatement( actions=["logs:CreateLogGroup"], sid="CloudWatchLogsPermissions", resources=["*"], ), iam.PolicyStatement( actions=[ "logs:CreateLogStream", "logs:PutLogEvents", "logs:DeleteLogStream", ], sid="CloudWatchLogStreamPermissions", resources=["*"], ), iam.PolicyStatement( actions=[ "dynamodb:PutItem", ], sid="ClusterSettingsTablePermissions", resources=[ InfraUtils.get_ddb_table_arn( self.cluster_name, cluster_settings.CLUSTER_SETTINGS_TABLE_NAME, ), ], ), ], ) ad_sync_resources_populator_role.attach_inline_policy( ad_sync_resources_populator_policy ) self.add_common_tags(ad_sync_resources_populator_role) ad_sync_resources_populator_lambda = _lambda.Function( self.nested_stack, id="ad-sync-resources-populator", function_name=lambda_name, description=f"Custom lambda to store AD sync resources in DDB", environment={ **self.build_ad_sync_resources_populator_environment_varaibles(), ENVIRONMENT_NAME_KEY: self.cluster_name, }, timeout=Duration.seconds(300), role=ad_sync_resources_populator_role, runtime=RES_COMMON_LAMBDA_RUNTIME, **InfraUtils.get_handler_and_code_for_function( ad_sync_resources_populator_handler.handler ), layers=[self.lambda_layer], ) self.add_common_tags(ad_sync_resources_populator_lambda) ad_sync_resources_populator_custom_resource = cdk.CustomResource( self.nested_stack, id="ad-sync-resources-populator-custom-resource", service_token=ad_sync_resources_populator_lambda.function_arn, removal_policy=cdk.RemovalPolicy.DESTROY, resource_type="Custom::ADSyncResourcesPopulator", ) ad_sync_resources_populator_custom_resource.node.add_dependency( self.ad_sync_security_group ) ad_sync_resources_populator_custom_resource.node.add_dependency( self.ecs_cluster ) ad_sync_resources_populator_custom_resource.node.add_dependency( self.task_definition ) ad_sync_resources_populator_custom_resource.node.add_dependency( ad_sync_resources_populator_policy ) def build_ad_sync_resources_populator_environment_varaibles(self) -> Dict[str, Any]: return { "ad_sync_security_group_id": self.ad_sync_security_group.security_group_id, "ad_sync_task_cluster": self.ecs_cluster.cluster_name, "ad_sync_task_definition": self.task_definition.family, }