# region-host: one region of a CARLOS deployment — a single Graviton box # running `carlos agent`, behind an Elastic IP. The box is disposable by # construction: all durable state is in the buckets, so there is no backup # story for hosts at all, only for buckets. # # Residency is enforced by construction: the host role reaches the # deployment bucket plus ITS OWN region's data bucket and nothing else. A # host in another region gets a different module instance whose role does # not include this region's bucket — a wrong-region wake fails loudly at # the IAM layer. data "aws_region" "current" {} data "aws_ami" "debian_arm64" { most_recent = true owners = [var.ami_owner] filter { name = "name" values = [var.ami_name_pattern] } filter { name = "architecture" values = ["arm64"] } } # Does this region have a default VPC at all? Asked with aws_vpcs (a LIST) # rather than aws_vpc (a lookup), on purpose: the lookup ERRORS when nothing # matches, and an error inside a data source cannot be turned into a useful # message. The list comes back empty instead, so the precondition below can # say what to do about it. Not evaluated at all when vpc_id is set. data "aws_vpcs" "default" { count = var.vpc_id == null ? 1 : 0 filter { name = "is-default" values = ["true"] } } # The VPC's own CIDR, for output vpc_cidr — CARLOS_RELAY_DENY_CIDRS needs it # so the relay never proxies traffic that is already inside the VPC. data "aws_vpc" "host" { id = var.vpc_id != null ? var.vpc_id : one(data.aws_vpcs.default[0].ids) } locals { # Inline, like the SG's other ingress: a standalone # aws_vpc_security_group_ingress_rule would fight the in-line set and be # stripped on every apply (see extra_ingress in variables.tf). relay_ingress = var.relay ? [ { description = "relay stun/turn udp" from_port = 3478 to_port = 3478 protocol = "udp" cidr_blocks = ["0.0.0.0/0"] }, { description = "relay stun/turn tcp" from_port = 3478 to_port = 3478 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }, { description = "relay allocations" from_port = 49152 to_port = 65535 protocol = "udp" cidr_blocks = ["0.0.0.0/0"] }, ] : [] } resource "aws_security_group" "host" { name = var.name description = var.sg_description # Null = the region's default VPC, which is what this module did before it # took the input at all. vpc_id = var.vpc_id lifecycle { # Both preconditions exist to move a failure from the middle of an apply # to the start of a plan. The buckets and the IAM role are created before # the instance, so a mid-apply failure here leaves a half-built region # that the next run has to reconcile. precondition { condition = var.vpc_id != null || length(try(data.aws_vpcs.default[0].ids, [])) > 0 error_message = "This region has no default VPC, so the host has nowhere to go. Pass vpc_id and subnet_id naming the VPC and subnet this box should live in. (A default VPC is created per region per account and deleting them is ordinary hygiene; without this check the apply fails half-way through with `VPCIdNotSpecified: No default VPC for this user`, after the buckets and IAM already exist.)" } precondition { condition = (var.vpc_id == null) == (var.subnet_id == null) error_message = "vpc_id and subnet_id go together: name both, or neither. A security group in your VPC and an instance in the default one is an apply that fails at the EC2 layer with a much worse message than this one." } } ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } dynamic "ingress" { for_each = var.extra_ingress content { description = ingress.value.description from_port = ingress.value.from_port to_port = ingress.value.to_port protocol = ingress.value.protocol cidr_blocks = ingress.value.cidr_blocks } } dynamic "ingress" { for_each = local.relay_ingress content { description = ingress.value.description from_port = ingress.value.from_port to_port = ingress.value.to_port protocol = ingress.value.protocol cidr_blocks = ingress.value.cidr_blocks } } # No SSH ingress: admin access is SSM Session Manager only, and the # carlos binary arrives from the deployment bucket, not over scp. egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_iam_role" "host" { name = var.iam_name assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } Action = "sts:AssumeRole" }] }) } # Scoped to exactly the deployment bucket, this region's data bucket, and # the deployment's SSM parameter prefix. resource "aws_iam_role_policy" "host_buckets" { name = "${var.iam_name}-buckets" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" # The two sibling-staging statement groups (instance-move spec, issue # #331) are appended only when sibling_staging_arns is non-empty: # push-only into moves/* — a box can stage a residence move TOWARD a # sibling region but can never read that region's data or touch its # live instances/* replica lineage. Statement = concat([ { Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] Resource = [ "${var.deployment_bucket_arn}/*", "${var.data_bucket_arn}/*", ] }, { Effect = "Allow" Action = ["s3:ListBucket", "s3:GetBucketLocation"] Resource = [var.deployment_bucket_arn, var.data_bucket_arn] }, # Host lifecycle claim, Plan 2 (spec C3, §6.2, §6.5): exact-version # list, read and delete on this box's OWN data bucket. Sibling-region # moves/* grants are unchanged: the destination region retires its # own staging (spec N13). { Sid = "PullDoorObjectVersions" Effect = "Allow" Action = ["s3:GetObjectVersion"] Resource = ["${var.data_bucket_arn}/*"] }, { Effect = "Allow" Action = ["s3:DeleteObjectVersion"] Resource = ["${var.data_bucket_arn}/*"] }, { Sid = "PullDoorVersionList" Effect = "Allow" Action = ["s3:ListBucketVersions"] Resource = [var.data_bucket_arn] }, { Effect = "Allow" Action = ["ssm:GetParameter"] Resource = var.ssm_parameter_arn }, # Infrastructure inventory (2026-08-19-fleet-architecture-diagram-design.md # §8): what the console's hourly infra pass needs to draw # /fleet/architecture. Read-only in the strictest sense — every action # here is a List/Describe/Get of a resource's SHAPE. No write action, # no s3:GetObject, and deliberately no iam:GetPolicyVersion: the page # shows that a role exists and how many policies are attached to it, # never what those policies say. { Effect = "Allow" Action = [ "ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeAddresses", "ec2:DescribeSecurityGroups", "ec2:DescribeVpcs", "ec2:DescribeSubnets", "ec2:DescribeRegions", "tag:GetResources", "s3:ListAllMyBuckets", "s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetEncryptionConfiguration", "s3:GetBucketPublicAccessBlock", "route53:ListHostedZones", "route53:ListResourceRecordSets", "iam:ListRoles", "iam:ListUsers", "iam:ListAttachedRolePolicies", "cloudwatch:DescribeAlarms", "acm:ListCertificates", "sts:GetCallerIdentity", ] Resource = "*" }, ], [for arn in var.sibling_staging_arns : { Effect = "Allow" Action = ["s3:PutObject", "s3:DeleteObject"] Resource = "${arn}/moves/*" }], [for arn in var.sibling_staging_arns : { Effect = "Allow" Action = ["s3:ListBucket"] Resource = arn Condition = { StringLike = { "s3:prefix" = "moves/*" } } }] ) }) } # Wildcard-cert DNS-01 (edge wildcard.go, CARLOS_DNS_PROVIDER=route53): # write challenge TXTs into the named zones only. ListHostedZonesByName has # no resource-level scoping in IAM, hence the split statement. resource "aws_iam_role_policy" "host_route53" { count = length(var.route53_zone_ids) > 0 ? 1 : 0 name = "${var.iam_name}-route53" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["route53:ChangeResourceRecordSets"] Resource = [for z in var.route53_zone_ids : "arn:aws:route53:::hostedzone/${z}"] # The box may touch ONLY _acme-challenge TXT records, and only via the # two operations the edge issues (UPSERT in present, DELETE in cleanup). # Without these conditions a zone grant is the whole zone — apex, MX, # NS — and this policy is the only bound on the box's DNS authority. Condition = { "ForAllValues:StringEquals" = { "route53:ChangeResourceRecordSetsRecordTypes" = ["TXT"] "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"] } "ForAllValues:StringLike" = { "route53:ChangeResourceRecordSetsNormalizedRecordNames" = ["_acme-challenge.*"] } } }, { Effect = "Allow" Action = ["route53:GetChange"] Resource = "arn:aws:route53:::change/*" }, { Effect = "Allow" Action = ["route53:ListHostedZonesByName"] Resource = "*" } ] }) } # DNS steering (internal/steering/route53.go, run as a housekeeper duty on # whichever box holds the lease): latency A records per armed edge, each bound # to a Route53 health check on that edge's EIP. Every action here is one the # converger's route53API interface actually calls — nothing is granted "for # later", and nothing it calls is missing (a missing action is not a partial # feature: the whole pass aborts AccessDenied and no record ever converges). resource "aws_iam_role_policy" "host_steering" { count = length(var.steering_zone_ids) > 0 ? 1 : 0 name = "${var.iam_name}-steering" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { # The write. Two bounds, and it is worth being exact about what each # one does and does not buy: # # - TYPE and ACTION are bounded always: the converger only ever # UPSERTs or DELETEs A records (desiredRecord is a latency A set; # converge builds no other type or action), so the zone's NS, SOA, # MX, TXT and CNAME records are out of this role's reach by IAM. # - NAME is bounded only if steering_record_names is set. WITHOUT it # this grant covers every A record in the zone — the APEX A # included, which on both our zones is a live site — and the only # thing standing in the way is the converger's own code # (isManaged, conflictedNames). That is a code bound, not an IAM # one. Set the variable to "*.": it covers the # steering hostname and every steered platform host, and steered # CUSTOM domains need no grant here at all — they reach steering # through a CNAME in the customer's own zone. Effect = "Allow" Action = ["route53:ChangeResourceRecordSets"] Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"] Condition = merge( { "ForAllValues:StringEquals" = { "route53:ChangeResourceRecordSetsRecordTypes" = ["A"] "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"] } }, length(var.steering_record_names) > 0 ? { "ForAllValues:StringLike" = { "route53:ChangeResourceRecordSetsNormalizedRecordNames" = var.steering_record_names } } : {}, ) }, { # The read, split out because ListResourceRecordSets supports none of # the condition keys above — folded into one statement they would be # decoration on this action, which is worse than saying so here. Effect = "Allow" Action = ["route53:ListResourceRecordSets"] Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"] }, { # Health checks are NOT zone-scoped and Route53 supports NO # tag-based condition keys on them (the service authorization # reference lists no condition keys for the healthcheck resource # type, and CreateHealthCheck takes no tags at all — the converger # tags in a second call, ChangeTagsForResource). Said plainly rather # than dressed up: within this account, this role may create, tag, # read the tags of, and delete ANY Route53 health check. The # carlos-steering= tag is the converger's OWN ownership # rule (ownedHealthChecks / gcHealthChecks refuse to delete anything # else); it is a code bound, not an IAM one, and it cannot be made # one here. # # ListHealthChecks and CreateHealthCheck have no resource ARN to name # at all, hence "*" for the pair; the per-check operations are held to # the healthcheck ARN space. Effect = "Allow" Action = ["route53:ListHealthChecks", "route53:CreateHealthCheck"] Resource = "*" }, { Effect = "Allow" Action = [ "route53:DeleteHealthCheck", "route53:ChangeTagsForResource", # PLURAL — the batched form (ListTagsForResources) is what the # converger calls, ten checks at a time. The singular # ListTagsForResource grants it nothing. "route53:ListTagsForResources", ] Resource = "arn:aws:route53:::healthcheck/*" }, ] }) } # Cost Explorer read for the economics dashboard's daily bill pull # (internal/console/cepull.go). Off by default: only the box that runs the # console needs it, and ce:GetCostAndUsage has no resource-level scoping — # it reads the whole account's cost lines, so the grant is deliberate. resource "aws_iam_role_policy" "host_costexplorer" { count = var.enable_cost_explorer ? 1 : 0 name = "${var.iam_name}-costexplorer" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["ce:GetCostAndUsage"] Resource = "*" }, ] }) } # The agent's memwatch duty (internal/host/memwatch.go) publishes # MemAvailablePercent and MemoryPressureSome60 once a minute. PutMetricData # has no resource ARN; the namespace condition is the whole bound — this # role can write Carlos/Host and nothing else. resource "aws_iam_role_policy" "host_metrics" { name = "${var.iam_name}-metrics" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["cloudwatch:PutMetricData"] Resource = "*" Condition = { StringEquals = { "cloudwatch:namespace" = "Carlos/Host" } } }, ] }) } # Session Manager. This is the ONLY admin path to these boxes — there is no # SSH ingress at all — so the managed policy is attached rather than # hand-rolled: getting its action list wrong locks everyone out of a fleet # whose recovery path is the thing that just broke. resource "aws_iam_role_policy_attachment" "host_ssm" { role = aws_iam_role.host.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } # ...and the hole that comes with it, closed. # # AmazonSSMManagedInstanceCore grants `ssm:GetParameter*` on `Resource: "*"`. # So the Allow above — one parameter ARN pattern, the variable named # ssm_parameter_arn, whose whole description is about scoping — bounds # nothing at all on its own: every host can read every SSM parameter in the # account. On one deployment that included /carlos/release-key, the root # release-signing key, readable by an internet-facing edge box. Verified with # simulate-principal-policy; found standing up a fleet, not in review # (discussion #7). # # An explicit Deny is the fix that does not risk the admin path: IAM's # explicit deny wins over any Allow, including a managed policy's, and this # one names ONLY the four parameter-read actions. Session Manager's channel # actions, inventory, association and patch actions are untouched, so an # operator's `aws ssm start-session` behaves exactly as it did. # # NotResource, not Resource: the deny covers everything the Allow does not # name, which is the shape that stays correct when ssm_parameter_arn changes. resource "aws_iam_role_policy" "host_ssm_parameter_scope" { count = var.scope_ssm_parameter_reads ? 1 : 0 name = "${var.iam_name}-ssm-parameter-scope" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Deny" Action = [ "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParameterHistory", "ssm:GetParametersByPath", ] NotResource = [var.ssm_parameter_arn] }, ] }) } resource "aws_iam_instance_profile" "host" { name = var.iam_name role = aws_iam_role.host.name } resource "aws_instance" "host" { ami = data.aws_ami.debian_arm64.id instance_type = var.instance_type # Null = the default VPC's default subnet, EC2's own behaviour and this # module's original one. The security group's precondition guarantees this # is only null when a default VPC exists to hold it. subnet_id = var.subnet_id vpc_security_group_ids = [aws_security_group.host.id] iam_instance_profile = aws_iam_instance_profile.host.name user_data = var.user_data user_data_replace_on_change = false # IMDSv2 only, one hop. With the block absent EC2 leaves IMDSv1 on, and a # plain GET from any request-forgery bug in a tenant app reads this # role's credentials; v2 needs a PUT with a header first, which those # bugs rarely give an attacker. Hop limit 1 keeps the token from leaving # the box. Changing these on a running instance is in place, no restart. # The flagship boxes were switched by hand on 2026-10-23; this keeps a # replaced box from coming back on v1. The scanner host has done the same # since it was written. metadata_options { http_endpoint = "enabled" http_tokens = "required" http_put_response_hop_limit = 1 } root_block_device { volume_size = var.root_volume_size volume_type = "gp3" encrypted = true # A terminated box must leave no disk behind: termination is the proof # that its instances may run elsewhere (region HA spec 1 §5.9). delete_on_termination = true } tags = { Name = var.name } # The AMI data source floats (most_recent): without this, a newer # Debian image silently turns any consumer's `tofu apply` into # "replace the region host" — seen live 2026-08-03, when a routine # plan in tito's deployment wanted to destroy the production box. # AMI updates are explicit-replace territory: # `tofu apply -replace=module..aws_instance.host` when intended. lifecycle { ignore_changes = [ami] } } resource "aws_eip" "host" { domain = "vpc" instance = aws_instance.host.id tags = { Name = var.name } } # HA ladder rung 0 (2026-08-12 spec): system-status failure recovers the # instance onto healthy hardware, keeping instance id, EIP and volumes. resource "aws_cloudwatch_metric_alarm" "host_auto_recover" { count = var.enable_auto_recovery ? 1 : 0 alarm_name = "${var.name}-auto-recover" alarm_description = "EC2 system-status-check failure — auto-recovers ${var.name} onto healthy hardware." namespace = "AWS/EC2" metric_name = "StatusCheckFailed_System" statistic = "Maximum" period = 60 evaluation_periods = 2 threshold = 1 comparison_operator = "GreaterThanOrEqualToThreshold" alarm_actions = ["arn:aws:automate:${data.aws_region.current.region}:ec2:recover"] dimensions = { InstanceId = aws_instance.host.id } tags = { Name = "${var.name}-auto-recover" } } # Memory-pressure spec §2.4: the early warning the status checks can't give. # On 2026-08-28 the flagship thrashed for 15 minutes with EC2 status ok/ok; # MemAvailable would have crossed 15% minutes earlier. Missing data is # BREACHING: a box that stops reporting is a box in trouble (or an agent # rolled without CARLOS_HOST_METRICS — either way, look). Which is also # why the caller should set alerts_email only once the box is publishing # (spec §4): wiring it first buys days of ALARM mail about nothing. # # The topic lives here, in the host's region, because an alarm can only # notify a same-region topic — one account-wide topic passed to four # regional hosts would apply and then never deliver for three of them. resource "aws_sns_topic" "alerts" { count = var.alerts_email != "" ? 1 : 0 name = "${var.name}-alerts" } resource "aws_sns_topic_subscription" "alerts_email" { count = var.alerts_email != "" ? 1 : 0 topic_arn = aws_sns_topic.alerts[0].arn protocol = "email" endpoint = var.alerts_email } resource "aws_cloudwatch_metric_alarm" "host_memory_low" { count = var.alerts_email != "" ? 1 : 0 alarm_name = "${var.name}-memory-low" alarm_description = "MemAvailable below 15% for 3 minutes on ${var.name} — a tenant is eating the box; see the memory-pressure spec §4 for what to look at." namespace = "Carlos/Host" metric_name = "MemAvailablePercent" statistic = "Minimum" period = 60 evaluation_periods = 3 datapoints_to_alarm = 3 threshold = 15 comparison_operator = "LessThanThreshold" treat_missing_data = "breaching" alarm_actions = [aws_sns_topic.alerts[0].arn] ok_actions = [aws_sns_topic.alerts[0].arn] dimensions = { InstanceId = aws_instance.host.id } tags = { Name = "${var.name}-memory-low" } } # ---- object storage provisioning (issue #368) ------------------------------ # # Everything below exists only when store_bucket_prefix is set. A deployment # that has not switched object storage on gets none of it, which is why the # console's own storeBackend() treats an unset CARLOS_STORE_BACKEND as a legal # deployment rather than a misconfiguration. # # The shape is two halves that only make sense together: a grant wide enough # to provision, and a ceiling narrow enough that the grant cannot be turned # into anything else. data "aws_caller_identity" "current" {} # The ceiling. Every store user is created under this boundary (objstore's # EnsureUser stamps it, and the role policy below REFUSES CreateUser without # it), so a store credential can never do more than S3 on this deployment's # own store buckets — no matter what inline policy the console writes. # # This is the answer to the escalation the grant would otherwise open: a role # that may CreateUser and PutUserPolicy can mint a principal wider than # itself, and scoping those calls to an IAM path does not help, because a path # says which user is made and nothing about what it may do. resource "aws_iam_policy" "store_user_boundary" { count = var.store_bucket_prefix == "" ? 0 : 1 name = "${var.iam_name}-store-boundary" description = "Permissions ceiling for CARLOS object-store users. Intersected with each user's inline policy, which narrows it to that user's one bucket." policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["s3:ListBucket", "s3:GetBucketLocation"] Resource = "arn:aws:s3:::${var.store_bucket_prefix}*" }, { Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*" }, ] }) } resource "aws_iam_role_policy" "host_store" { count = var.store_bucket_prefix == "" ? 0 : 1 name = "${var.iam_name}-store" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [ # Bucket lifecycle, prefix-scoped. EmptyBucket's paginated delete needs # ListBucket on the bucket and DeleteObject under it; teardown needs # DeleteBucket. No GetObject: the console provisions stores, it does not # read tenant data, and the economics sweep sizes buckets with ListBucket. # (Scanning is the exception, and it is a separate policy behind # store_scanning, below.) { Effect = "Allow" Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListBucket", "s3:GetBucketLocation"] Resource = "arn:aws:s3:::${var.store_bucket_prefix}*" }, { Effect = "Allow" Action = ["s3:DeleteObject"] Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*" }, # A deployment whose store prefix also matches its OWN buckets — the # obvious "carlos-" does exactly that against carlos-- # — would otherwise hand the box DeleteBucket on the control plane it # runs from. An explicit Deny beats every Allow in IAM, so this holds # whatever prefix an operator chooses, including a careless one. { Effect = "Deny" Action = ["s3:DeleteBucket", "s3:DeleteObject", "s3:CreateBucket"] Resource = [ var.deployment_bucket_arn, "${var.deployment_bucket_arn}/*", var.data_bucket_arn, "${var.data_bucket_arn}/*", ] }, # The two calls that could escalate, each refused unless the user being # made or modified carries the boundary above. Conditioning PutUserPolicy # as well as CreateUser matters: without it the console could attach a # wide inline policy to some pre-existing unbounded user under the path. { Effect = "Allow" Action = ["iam:CreateUser", "iam:PutUserPolicy"] Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user${var.store_user_path}*" Condition = { StringEquals = { "iam:PermissionsBoundary" = aws_iam_policy.store_user_boundary[0].arn } } }, # The rest of the user lifecycle. None of these can widen a principal, # so none is boundary-conditioned — but all stay pinned to the store # user path, so the box can never touch a role or a person's user. # Deliberately absent: iam:PutUserPermissionsBoundary and # iam:DeleteUserPermissionsBoundary, which would let the console remove # the ceiling, and iam:AttachUserPolicy, which would let it bypass the # inline document entirely. { Effect = "Allow" Action = [ "iam:GetUser", "iam:DeleteUser", "iam:DeleteUserPolicy", "iam:CreateAccessKey", "iam:DeleteAccessKey", "iam:ListAccessKeys", ] Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user${var.store_user_path}*" }, ] }) } # The console's own email (invite fallback, console-invite-delivery spec §4). # One identity, one From address. The configuration set and tenant are the # ops account's ("carlos-bab"), which SES checks on a tenant-attributed send. resource "aws_iam_role_policy" "host_console_mail" { count = var.console_mail_from == "" ? 0 : 1 name = "${var.iam_name}-console-mail" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["ses:SendEmail"] Resource = [ "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:identity/${split("@", var.console_mail_from)[1]}", "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:configuration-set/carlos-bab", "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:tenant/carlos-bab/*", ] Condition = { StringEquals = { "ses:FromAddress" = var.console_mail_from } } }] }) lifecycle { precondition { condition = var.console_mail_region != "" error_message = "console_mail_region must be set when console_mail_from is (it must equal the console's CARLOS_MAIL_REGION)." } } } # ---- object storage scanning (opt-in) -------------------------------------- # # The one grant that lets this role read tenant object bytes, so it is its own # policy behind its own switch: a deployment that has not turned scanning on # plans no change here, and the sentence in host_store above ("it does not # read tenant data") stays true for it. # # Exactly the calls the scanner makes. HeadObject is authorised by GetObject; # ListObjectsV2 is already in host_store. Store buckets use SSE-S3, so there is # no KMS statement. PutObjectTagging is how a verdict is written; the store # user boundary deliberately lacks it, so an app cannot write one itself. resource "aws_iam_role_policy" "host_store_scan" { count = var.store_scanning ? 1 : 0 name = "${var.iam_name}-store-scan" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"] Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*" }, ] }) lifecycle { precondition { condition = var.store_bucket_prefix != "" error_message = "store_scanning needs store_bucket_prefix: with no prefix there are no object stores on this deployment to scan, and the grant would name every bucket in the account." } } } # --- Termination evidence (region HA spec 1 §5.9) --------------------------- # EC2 tells EventBridge when an instance in this account and region reaches # "terminated"; the rule forwards that to a queue the region's boxes read. # Delivery is best-effort on AWS's side: the queue keeps what arrived, and a # box that finds nothing here still asks EC2 directly. resource "aws_sqs_queue" "terminations" { name = "${var.iam_name}-terminations" message_retention_seconds = 1209600 # 14 days, the maximum sqs_managed_sse_enabled = true } resource "aws_cloudwatch_event_rule" "terminations" { name = "${var.iam_name}-terminations" description = "EC2 instances reaching terminated, for region HA failover." event_pattern = jsonencode({ source = ["aws.ec2"] "detail-type" = ["EC2 Instance State-change Notification"] detail = { state = ["terminated"] } }) } resource "aws_cloudwatch_event_target" "terminations" { rule = aws_cloudwatch_event_rule.terminations.name arn = aws_sqs_queue.terminations.arn } # Only this rule may put a message on the queue. A message is evidence, so # who can write one matters as much as who can read one. # # The Allow alone is not enough: a principal in this account with its own # sqs:SendMessage grant (an admin role, a wildcard policy) could still post # a forged event, because an identity policy and a queue policy add up. # The Deny closes that. It matches every send whose source is not this # rule, including one with no source at all, which is what a direct # SendMessage from a role looks like. resource "aws_sqs_queue_policy" "terminations" { queue_url = aws_sqs_queue.terminations.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "TheTerminationRuleMaySend" Effect = "Allow" Principal = { Service = "events.amazonaws.com" } Action = "sqs:SendMessage" Resource = aws_sqs_queue.terminations.arn Condition = { ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.terminations.arn } } }, { Sid = "NobodyElseMaySend" Effect = "Deny" Principal = "*" Action = "sqs:SendMessage" Resource = aws_sqs_queue.terminations.arn Condition = { ArnNotEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.terminations.arn } } }, ] }) } resource "aws_iam_role_policy" "host_terminations" { name = "${var.iam_name}-terminations" role = aws_iam_role.host.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"] Resource = aws_sqs_queue.terminations.arn }] }) }