diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab23309 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Sombra module for self hosted Transcend.io + +This module is a work in progress diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..18a1b18 --- /dev/null +++ b/main.tf @@ -0,0 +1,282 @@ +################# +# Load Balancer # +################# + +module load_balancer { + source = "terraform-aws-modules/alb/aws" + version = "~> 4.0" + + # General Settings + load_balancer_name = "${var.deploy_env}-sombra-${var.project_id}-alb" + enable_deletion_protection = false + + # Log settings + log_bucket_name = var.log_bucket_name + log_location_prefix = "${var.deploy_env}-alb-sombra-${var.project_id}" + + # VPC Settings + subnets = var.public_subnet_ids + vpc_id = var.vpc_id + security_groups = [aws_security_group.alb.id] + + # Listeners + https_listeners = [ + # Internal Listener + { + certificate_arn = var.certificate_arn + port = var.internal_port + ssl_policy = "ELBSecurityPolicy-2016-08" + target_group_index = 0 + }, + # External Listener + { + certificate_arn = var.certificate_arn + port = var.external_port + ssl_policy = "ELBSecurityPolicy-FS-2018-06" + target_group_index = 1 + }, + ] + https_listeners_count = 2 + + # Target groups + target_groups = [ + # Internal group + { + name = "${var.deploy_env}-${var.project_id}-internal" + health_check_path = "/health" + backend_protocol = "HTTPS" + target_type = "ip" + backend_port = var.internal_port + health_check_port = var.internal_port + }, + # External group + { + name = "${var.deploy_env}-${var.project_id}-external" + health_check_path = "/health" + backend_protocol = "HTTPS" + target_type = "ip" + backend_port = var.external_port + health_check_port = var.external_port + }, + ] + target_groups_count = 2 +} + +resource "aws_security_group" "alb" { + name = "${var.deploy_env}-${var.project_id}-sombra-alb-security-group" + description = "Security group for sombra alb" + vpc_id = var.vpc_id + + # Allow external port + ingress { + protocol = "tcp" + from_port = var.external_port + to_port = var.external_port + cidr_blocks = ["0.0.0.0/0"] + } + + # Allow internal port from the calling companies IP range + ingress { + protocol = "tcp" + from_port = var.internal_port + to_port = var.internal_port + cidr_blocks = [var.incoming_cidr_range] + } + + egress { + protocol = "tcp" + from_port = var.internal_port + to_port = var.internal_port + cidr_blocks = var.private_subnets_cidr_blocks + } + + egress { + protocol = "tcp" + from_port = var.external_port + to_port = var.external_port + cidr_blocks = var.private_subnets_cidr_blocks + } + + timeouts { + create = "45m" + delete = "45m" + } +} + +############ +# ECS Task # +############ + +module container_definition { + source = "./modules/fargate_container_definition" + + name = "${var.deploy_env}-${var.project_id}-container" + image = var.ecr_image + containerPorts = [var.internal_port, var.external_port] + ssm_prefix = var.project_id + + use_cloudwatch_logs = var.use_cloudwatch_logs + log_configuration = var.log_configuration + log_secrets = var.log_secrets + + environment = { + # General Settings + EXTERNAL_PORT_HTTPS = var.external_port + INTERNAL_PORT_HTTPS = var.internal_port + USE_TLS_AUTH = false + + # JWT + JWT_AUTHENTICATION_PUBLIC_KEY = var.jwt_authentication_public_key + + # AWS KMS + AWS_KMS_KEY_ARN = var.use_local_kms ? "" : aws_kms_key.key.0.arn + KMS_PROVIDER = var.use_local_kms ? "local" : "AWS" + AWS_REGION = var.aws_region + + # Override internal key + INTERNAL_KEY_HASH = var.internal_key_hash + + # Cycle + HMAC_NONCE_KEY_CYCLE = var.hmac_nonce_key_cycle + KEY_ENCRYPTION_BASE_CYCLE = var.key_encryption_base_cycle + + NODE_ENV = "production" + TRANSCEND_URL = var.transcend_backend_url + TRANSCEND_CN = var.transcend_certificate_common_name + LOG_LEVEL = var.log_level + ENCRYPTED_SAAS_HTTP_METHODS = join(",", var.encrypted_saas_http_methods) + + # Global Settings + ORGANIZATION_URI = var.subdomain + DATA_SUBJECT_AUTHENTICATION_METHODS = join(",", var.data_subject_auth_methods) + EMPLOYEE_AUTHENTICATION_METHODS = join(",", var.employee_auth_methods) + + # Employee Single Sign On + SAML_ENTRYPOINT = var.saml_config.entrypoint + SAML_ISSUER = var.saml_config.issuer + SAML_CERT = var.saml_config.cert + SAML_AUDIENCE = var.saml_config.audience + SAML_ACCEPT_CLOCK_SKEWED_MS = var.saml_config.accepted_clock_skew_ms + + # Data Subject OAuth + OAUTH_SCOPES = join(",", var.oauth_config.scopes) + OAUTH_CLIENT_ID = var.oauth_config.client_id + OAUTH_GET_TOKEN_URL = var.oauth_config.get_token_url + OAUTH_GET_CORE_ID_URL = var.oauth_config.get_core_id_url + OAUTH_GET_CORE_ID_PATH = var.oauth_config.get_core_id_path + OAUTH_GET_PROFILE_URL = var.oauth_config.get_profile_url + OAUTH_GET_TOKEN_BODY_REDIRECT_URI = var.oauth_config.get_token_body_redirect_uri + OAUTH_GET_PROFILE_PATH = var.oauth_config.get_profile_path + OAUTH_GET_EMAIL_PATH = var.oauth_config.get_email_path + OAUTH_PROFILE_PICTURE_PATH = var.oauth_config.profile_picture_path + OAUTH_EMAIL_IS_VERIFIED_PATH = var.oauth_config.email_is_verified_path + OAUTH_EMAIL_IS_VERIFIED = var.oauth_config.email_is_verified + } + + secret_environment = { + for key, val in { + OAUTH_CLIENT_SECRET = var.oauth_config.secret_id + JWT_ECDSA_KEY = var.jwt_ecdsa_key + SOMBRA_TLS_KEY = var.tls_config.key + SOMBRA_TLS_KEY_PASSPHRASE = var.tls_config.passphrase + SOMBRA_TLS_CERT = var.tls_config.cert + } : + key => val + if length(val) > 0 + } + + deploy_env = var.deploy_env + aws_region = var.aws_region + tags = var.tags +} + +############### +# ECS Service # +############### + +module service { + source = "./modules/fargate_service" + + name = "${var.deploy_env}-${var.project_id}-sombra-service" + desired_count = var.desired_count + cpu = var.cpu + memory = var.memory + cluster_id = var.cluster_id + vpc_id = var.vpc_id + subnet_ids = var.private_subnet_ids + alb_security_group_ids = [aws_security_group.alb.id] + container_definitions = format( + "[%s]", + join(",", setunion( + [module.container_definition.json_map], + var.extra_container_definitions + )) + ) + + additional_task_policy_arns = concat([ + module.container_definition.secrets_policy_arn, + aws_iam_policy.kms_policy.arn, + ], var.extra_task_policy_arns) + additional_task_policy_arns_count = 2 + length(var.extra_task_policy_arns) + + load_balancers = [ + # Internal target group manager + { + target_group_arn = module.load_balancer.target_group_arns[0] + container_name = module.container_definition.container_name + container_port = var.internal_port + }, + # External target group manager + { + target_group_arn = module.load_balancer.target_group_arns[1] + container_name = module.container_definition.container_name + container_port = var.external_port + } + ] + + deploy_env = var.deploy_env + aws_region = var.aws_region + tags = var.tags +} + +############## +# KMS Policy # +############## + +resource "aws_kms_key" "key" { + count = var.use_local_kms ? 0 : 1 + description = "Encryption key for ${var.deploy_env} ${var.project_id} Sombra" + tags = var.tags +} + +data "aws_iam_policy_document" "kms_policy_doc" { + statement { + sid = "AllowReadingKms" + effect = "Allow" + # TODO: Make the actions tighter. + actions = ["kms:*"] + resources = var.use_local_kms ? ["*"] : [aws_kms_key.key.0.arn] + } +} + +resource "aws_iam_policy" "kms_policy" { + name = "${var.deploy_env}-${var.project_id}-sombra-kms-policy" + description = "Allows Sombra instances to get the KMS key" + policy = data.aws_iam_policy_document.kms_policy_doc.json +} + +####### +# DNS # +####### + +resource "aws_route53_record" "alb_alias" { + zone_id = var.zone_id + name = "${var.subdomain}.${var.root_domain}" + type = "A" + + alias { + name = module.load_balancer.dns_name + zone_id = module.load_balancer.load_balancer_zone_id + evaluate_target_health = false + } +} diff --git a/modules/fargate_container_definition/main.tf b/modules/fargate_container_definition/main.tf new file mode 100644 index 0000000..ff855dd --- /dev/null +++ b/modules/fargate_container_definition/main.tf @@ -0,0 +1,110 @@ +resource "aws_ssm_parameter" "params" { + for_each = var.secret_environment + + description = "Param for the ${each.key} env var in the container: ${var.name}" + + name = "${var.deploy_env}-${var.ssm_prefix}-${each.key}" + value = each.value + + type = "SecureString" + tier = length(each.value) > 4096 ? "Advanced" : "Standard" + + tags = var.tags +} + +resource "aws_ssm_parameter" "secret_log_options" { + for_each = var.log_secrets + + description = "Log option named ${each.key} in the container: ${var.name}" + + name = "${var.deploy_env}-logOptions-${var.ssm_prefix}-${each.key}" + value = each.value + + type = "SecureString" + tier = length(each.value) > 4096 ? "Advanced" : "Standard" + + tags = var.tags +} + +data "aws_iam_policy_document" "secret_access_policy_doc" { + statement { + effect = "Allow" + actions = [ + "ssm:GetParameters", + "secretsmanager:GetSecretValue", + ] + resources = [ + for name, outputs in merge( + aws_ssm_parameter.params, + aws_ssm_parameter.secret_log_options, + ) : + outputs.arn + ] + } +} + +resource "aws_iam_policy" "secret_access_policy" { + name_prefix = "${var.deploy_env}-${var.name}-secret-access-policy" + description = "Gives access to read ssm env vars" + policy = data.aws_iam_policy_document.secret_access_policy_doc.json +} + +module "definition" { + source = "cloudposse/ecs-container-definition/aws" + version = "v0.21.0" + + container_name = var.name + container_image = var.image + + container_cpu = var.cpu + container_memory = var.memory + + port_mappings = [ + for port in var.containerPorts : + { + containerPort = port + hostPort = port + protocol = "tcp" + } + ] + + log_configuration = var.use_cloudwatch_logs ? { + logDriver = "awslogs" + options = { + "awslogs-region" = var.aws_region + "awslogs-group" = aws_cloudwatch_log_group.log_group[0].name + "awslogs-stream-prefix" = "ecs--${var.name}" + } + secretOptions = [] + } : merge(var.log_configuration, { + secretOptions = [ + for name, outputs in aws_ssm_parameter.secret_log_options : + { + name = name + valueFrom = outputs.arn + } + ] + }) + + environment = [ + for name, value in var.environment : + { + name = name + value = value + } + ] + + secrets = [ + for name, outputs in aws_ssm_parameter.params : + { + name = name + valueFrom = outputs.arn + } + ] +} + +resource "aws_cloudwatch_log_group" "log_group" { + count = var.use_cloudwatch_logs ? 1 : 0 + name = "${var.name}-log-group" + tags = var.tags +} diff --git a/modules/fargate_container_definition/outputs.tf b/modules/fargate_container_definition/outputs.tf new file mode 100644 index 0000000..db64646 --- /dev/null +++ b/modules/fargate_container_definition/outputs.tf @@ -0,0 +1,19 @@ +output json { + value = module.definition.json +} + +output json_map { + value = module.definition.json_map +} + +output secrets_policy_arn { + value = aws_iam_policy.secret_access_policy.arn +} + +output container_name { + value = var.name +} + +output container_ports { + value = var.containerPorts +} diff --git a/modules/fargate_container_definition/variables.tf b/modules/fargate_container_definition/variables.tf new file mode 100644 index 0000000..42d958a --- /dev/null +++ b/modules/fargate_container_definition/variables.tf @@ -0,0 +1,130 @@ +variable name { + type = string + description = < 0 ? 60 : 0 + + dynamic "load_balancer" { + for_each = var.load_balancers + content { + target_group_arn = load_balancer.value.target_group_arn + container_name = load_balancer.value.container_name + container_port = load_balancer.value.container_port + } + } + + # Allow external changes to service count without Terraform plan difference + lifecycle { + ignore_changes = [desired_count] + } + + propagate_tags = "SERVICE" + tags = var.tags +} + +resource "aws_ecs_task_definition" "task" { + family = "${var.name}-task" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = var.cpu + memory = var.memory + execution_role_arn = aws_iam_role.execution_role.arn + task_role_arn = aws_iam_role.execution_role.arn + container_definitions = var.container_definitions + tags = var.tags +} + +resource "aws_security_group" "service_security_group" { + name = "${var.name}-ecs-security-group" + description = "Allows inbound access to an ECS service only through its alb" + vpc_id = var.vpc_id + + dynamic "ingress" { + for_each = var.load_balancers + content { + from_port = ingress.value.container_port + to_port = ingress.value.container_port + security_groups = var.alb_security_group_ids + protocol = "tcp" + } + } + + # Allow all outgoing access + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + timeouts { + create = "45m" + delete = "45m" + } + + tags = var.tags +} diff --git a/modules/fargate_service/variables.tf b/modules/fargate_service/variables.tf new file mode 100644 index 0000000..1ded52b --- /dev/null +++ b/modules/fargate_service/variables.tf @@ -0,0 +1,90 @@ +variable name { + description = "The name of the service. Used as a prefix for other resource names" +} + +variable cluster_id { + description = <