shoc-backend/terraform/live/modules/environment-owned/main.tf
Adam Moussa 24f08d3cb1
feat(terraform): adopt live deployment roles safely (#94)
* feat(terraform): add safe backend environment adoption

Introduce import-guarded environment roots and retire temporary bootstrap and POC provisioning after ownership transfer.

* ci(deploy): pause dev and staging deployments

Prevent application releases from racing Terraform adoption while retaining production deployment and validation.

* ci(deploy): require manual environment dispatch

* fix: update `required_version` from `>=1.7.0` to `>=1.9.0`

The deploy-boundary check interpolates `var.aws_account_id` and `var.environment`. Terraform only allows other variables inside `validation` from 1.9.0+.

CI already runs against `1.9.8` so `versions.tf` setting version as `>=1.7.0` is a breaking finding

* chore(deps): add `terraform` to renovate dependency coverage

* ci(deploy): drop unprovisioned prod dispatch path
2026-08-31 11:51:18 -04:00

524 lines
14 KiB
HCL

data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
data "aws_elastic_beanstalk_hosted_zone" "current" {}
locals {
application_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:application/${var.eb_application_name}"
environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}"
environment_stack_name = "awseb-${var.eb_environment_id}-stack"
eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}"
use_legacy_s3_policy = !var.adoption_complete && var.legacy_dev_s3_policy
app_config_secret_pattern = "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:${var.app_config_secret_name}-*"
}
data "aws_iam_policy_document" "runtime_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "runtime" {
name = var.runtime_role_name
path = "/"
description = var.metadata_before_adoption.runtime_role_description
assume_role_policy = data.aws_iam_policy_document.runtime_assume.json
max_session_duration = 3600
permissions_boundary = var.permissions_boundary_arn
tags = var.adoption_complete ? merge(var.metadata_before_adoption.runtime_role_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.runtime_role_tags
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_role_policy_attachment" "web_tier" {
role = aws_iam_role.runtime.name
policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "runtime_app_config" {
statement {
sid = var.app_config_policy_sid
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [local.app_config_secret_pattern]
}
}
resource "aws_iam_role_policy" "runtime_app_config" {
name = var.runtime_app_config_policy_name
role = aws_iam_role.runtime.id
policy = data.aws_iam_policy_document.runtime_app_config.json
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "runtime_webhook" {
count = var.work_order_webhook_enabled ? 1 : 0
statement {
sid = var.webhook_read_policy_sid
effect = "Allow"
actions = [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
]
resources = [var.webhook_secret_arn]
}
statement {
sid = var.webhook_decrypt_policy_sid
effect = "Allow"
actions = ["kms:Decrypt"]
resources = ["arn:aws:kms:us-east-1:011934824531:key/d10fd1f0-a61a-4405-8568-85e9fd11ba18"]
condition {
test = "StringEquals"
variable = "kms:ViaService"
values = ["secretsmanager.us-east-1.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy" "runtime_webhook" {
count = var.work_order_webhook_enabled ? 1 : 0
name = var.runtime_webhook_policy_name
role = aws_iam_role.runtime.id
policy = data.aws_iam_policy_document.runtime_webhook[0].json
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "runtime_dynamo" {
count = var.dynamo_reader_role_arn == null ? 0 : 1
statement {
sid = var.dynamo_policy_sid
effect = "Allow"
actions = ["sts:AssumeRole"]
resources = [var.dynamo_reader_role_arn]
}
}
resource "aws_iam_role_policy" "runtime_dynamo" {
count = var.dynamo_reader_role_arn == null ? 0 : 1
name = var.runtime_dynamo_policy_name
role = aws_iam_role.runtime.id
policy = data.aws_iam_policy_document.runtime_dynamo[0].json
lifecycle {
prevent_destroy = true
}
}
resource "aws_iam_instance_profile" "runtime" {
name = var.runtime_role_name
path = "/"
role = aws_iam_role.runtime.name
tags = var.adoption_complete ? merge(var.metadata_before_adoption.instance_profile_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.instance_profile_tags
lifecycle {
prevent_destroy = true
}
}
resource "aws_secretsmanager_secret" "app_config" {
name = var.app_config_secret_name
description = var.metadata_before_adoption.app_config_description
tags = var.adoption_complete ? merge(var.metadata_before_adoption.app_config_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.app_config_tags
lifecycle {
prevent_destroy = true
ignore_changes = [
force_overwrite_replica_secret,
recovery_window_in_days,
]
}
}
data "aws_iam_policy_document" "deploy_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [data.aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repo}:environment:${var.github_environment}"]
}
}
}
resource "aws_iam_role" "github_deploy" {
name = var.github_deploy_role_name
path = "/"
description = var.metadata_before_adoption.deploy_role_description
assume_role_policy = data.aws_iam_policy_document.deploy_assume.json
max_session_duration = 3600
permissions_boundary = var.github_deploy_permissions_boundary_arn
tags = var.adoption_complete ? merge(var.metadata_before_adoption.deploy_role_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.deploy_role_tags
lifecycle {
prevent_destroy = true
}
}
data "aws_iam_policy_document" "deploy" {
statement {
sid = var.environment == "tf-poc" ? "DescribeDeploymentResources" : null
effect = "Allow"
actions = [
"autoscaling:Describe*",
"ec2:Describe*",
"elasticbeanstalk:DescribeApplicationVersions",
"elasticbeanstalk:DescribeEnvironments",
"elasticbeanstalk:DescribeEvents",
"elasticloadbalancing:Describe*",
]
resources = ["*"]
}
statement {
sid = var.environment == "tf-poc" ? "CreateApplicationVersion" : null
effect = "Allow"
actions = ["elasticbeanstalk:CreateApplicationVersion"]
resources = [
local.application_arn,
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:applicationversion/${var.eb_application_name}/*",
]
}
statement {
sid = var.environment == "tf-poc" ? "UpdatePocEnvironment" : null
effect = "Allow"
actions = ["elasticbeanstalk:UpdateEnvironment"]
resources = [local.environment_arn]
}
dynamic "statement" {
for_each = var.environment != "tf-poc" ? [1] : []
content {
effect = "Allow"
actions = [
"cloudformation:CancelUpdateStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResource",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplate",
"cloudformation:ListStackResources",
"cloudformation:UpdateStack",
]
resources = [
"arn:aws:cloudformation:${var.aws_region}:${var.aws_account_id}:stack/${local.environment_stack_name}/*",
]
}
}
dynamic "statement" {
for_each = var.environment != "tf-poc" ? [1] : []
content {
effect = "Allow"
actions = [
"autoscaling:PutNotificationConfiguration",
"autoscaling:ResumeProcesses",
"autoscaling:SuspendProcesses",
]
resources = [
"arn:aws:autoscaling:${var.aws_region}:${var.aws_account_id}:autoScalingGroup:*:autoScalingGroupName/${local.environment_stack_name}-*",
]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [1] : []
content {
effect = "Allow"
actions = ["s3:Delete*", "s3:Get*", "s3:Put*"]
resources = ["arn:aws:s3:::elasticbeanstalk-*/*"]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [1] : []
content {
effect = "Allow"
actions = [
"s3:GetBucket*",
"s3:ListBucket",
"s3:PutBucketOwnershipControls",
"s3:PutBucketPolicy",
"s3:PutBucketPublicAccessBlock",
]
resources = ["arn:aws:s3:::elasticbeanstalk-*"]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
sid = var.environment == "tf-poc" ? "UploadApplicationVersion" : null
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::${local.eb_bucket_name}/${var.eb_application_name}/*"]
}
}
dynamic "statement" {
for_each = local.use_legacy_s3_policy ? [] : [1]
content {
sid = var.environment == "tf-poc" ? "UseBeanstalkBucket" : null
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
resources = ["arn:aws:s3:::${local.eb_bucket_name}"]
}
}
dynamic "statement" {
for_each = var.environment == "tf-poc" ? [1] : []
content {
sid = "DenyLiveEnvironments"
effect = "Deny"
actions = ["elasticbeanstalk:*"]
resources = [
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/shoc-backend-dev",
"arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/shoc-backend-staging",
]
}
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = var.github_deploy_policy_name
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.deploy.json
lifecycle {
prevent_destroy = true
}
}
resource "aws_elastic_beanstalk_environment" "this" {
name = var.eb_environment_name
application = var.eb_application_name
platform_arn = var.platform_arn
tier = "WebServer"
cname_prefix = var.eb_environment_name
setting {
namespace = "aws:elasticbeanstalk:environment"
name = "EnvironmentType"
value = "LoadBalanced"
}
setting {
namespace = "aws:elasticbeanstalk:environment"
name = "LoadBalancerType"
value = "application"
}
setting {
namespace = "aws:elasticbeanstalk:environment"
name = "ServiceRole"
value = var.eb_service_role_name
}
setting {
namespace = "aws:ec2:vpc"
name = "VPCId"
value = var.vpc_id
}
setting {
namespace = "aws:ec2:vpc"
name = "Subnets"
value = join(",", sort(var.instance_subnet_ids))
}
setting {
namespace = "aws:ec2:vpc"
name = "ELBSubnets"
value = join(",", sort(var.load_balancer_subnet_ids))
}
setting {
namespace = "aws:ec2:vpc"
name = "ELBScheme"
value = "public"
}
setting {
namespace = "aws:ec2:vpc"
name = "AssociatePublicIpAddress"
value = "true"
}
setting {
namespace = "aws:autoscaling:launchconfiguration"
name = "IamInstanceProfile"
value = aws_iam_instance_profile.runtime.name
}
setting {
namespace = "aws:autoscaling:launchconfiguration"
name = "InstanceType"
value = "t3.small"
}
dynamic "setting" {
for_each = var.instance_security_group_id == null ? [] : [var.instance_security_group_id]
content {
namespace = "aws:autoscaling:launchconfiguration"
name = "SecurityGroups"
value = setting.value
}
}
setting {
namespace = "aws:autoscaling:asg"
name = "MinSize"
value = "1"
}
setting {
namespace = "aws:autoscaling:asg"
name = "MaxSize"
value = "1"
}
setting {
namespace = "aws:elbv2:listener:443"
name = "Protocol"
value = "HTTPS"
}
setting {
namespace = "aws:elbv2:listener:443"
name = "SSLCertificateArns"
value = var.shared_certificate_arn
}
setting {
namespace = "aws:elasticbeanstalk:environment:process:default"
name = "HealthCheckPath"
value = "/"
}
setting {
namespace = "aws:elasticbeanstalk:environment:process:default"
name = "MatcherHTTPCode"
value = "200-499"
}
dynamic "setting" {
for_each = var.app_config_json_keys
content {
namespace = "aws:elasticbeanstalk:application:environmentsecrets"
name = setting.value
value = "${aws_secretsmanager_secret.app_config.arn}:${setting.value}"
}
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "ASPNETCORE_ENVIRONMENT"
value = "Production"
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "ASPNETCORE_URLS"
value = "http://0.0.0.0:5000"
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__Enabled"
value = var.work_order_webhook_enabled ? "true" : "false"
}
setting {
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__Region"
value = var.aws_region
}
dynamic "setting" {
for_each = var.webhook_secret_arn == null ? [] : [var.webhook_secret_arn]
content {
namespace = "aws:elasticbeanstalk:application:environment"
name = "WorkOrderWebhook__SecretId"
value = setting.value
}
}
tags = var.metadata_before_adoption.environment_tags
lifecycle {
prevent_destroy = true
ignore_changes = [
wait_for_ready_timeout,
]
}
}
resource "aws_route53_record" "api_alias" {
count = var.api_record_type == "A" ? 1 : 0
zone_id = var.hosted_zone_id
name = var.api_domain
type = "A"
alias {
name = aws_elastic_beanstalk_environment.this.cname
zone_id = data.aws_elastic_beanstalk_hosted_zone.current.id
evaluate_target_health = true
}
lifecycle {
prevent_destroy = true
}
}
resource "aws_route53_record" "api_cname" {
count = var.api_record_type == "CNAME" ? 1 : 0
zone_id = var.hosted_zone_id
name = var.api_domain
type = "CNAME"
ttl = 60
records = [aws_elastic_beanstalk_environment.this.endpoint_url]
lifecycle {
prevent_destroy = true
}
}