carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# region-host: one region of a CARLOS deployment — a single Graviton box
2# running `carlos agent`, behind an Elastic IP. The box is disposable by
3# construction: all durable state is in the buckets, so there is no backup
4# story for hosts at all, only for buckets.
5#
6# Residency is enforced by construction: the host role reaches the
7# deployment bucket plus ITS OWN region's data bucket and nothing else. A
8# host in another region gets a different module instance whose role does
9# not include this region's bucket — a wrong-region wake fails loudly at
10# the IAM layer.
11
12data "aws_region" "current" {}
13
14data "aws_ami" "debian_arm64" {
15 most_recent = true
16 owners = [var.ami_owner]
17 filter {
18 name = "name"
19 values = [var.ami_name_pattern]
20 }
21 filter {
22 name = "architecture"
23 values = ["arm64"]
24 }
25}
26
27# Does this region have a default VPC at all? Asked with aws_vpcs (a LIST)
28# rather than aws_vpc (a lookup), on purpose: the lookup ERRORS when nothing
29# matches, and an error inside a data source cannot be turned into a useful
30# message. The list comes back empty instead, so the precondition below can
31# say what to do about it. Not evaluated at all when vpc_id is set.
32data "aws_vpcs" "default" {
33 count = var.vpc_id == null ? 1 : 0
34 filter {
35 name = "is-default"
36 values = ["true"]
37 }
38}
39
40# The VPC's own CIDR, for output vpc_cidr — CARLOS_RELAY_DENY_CIDRS needs it
41# so the relay never proxies traffic that is already inside the VPC.
42data "aws_vpc" "host" {
43 id = var.vpc_id != null ? var.vpc_id : one(data.aws_vpcs.default[0].ids)
44}
45
46locals {
47 # Inline, like the SG's other ingress: a standalone
48 # aws_vpc_security_group_ingress_rule would fight the in-line set and be
49 # stripped on every apply (see extra_ingress in variables.tf).
50 relay_ingress = var.relay ? [
51 {
52 description = "relay stun/turn udp"
53 from_port = 3478
54 to_port = 3478
55 protocol = "udp"
56 cidr_blocks = ["0.0.0.0/0"]
57 },
58 {
59 description = "relay stun/turn tcp"
60 from_port = 3478
61 to_port = 3478
62 protocol = "tcp"
63 cidr_blocks = ["0.0.0.0/0"]
64 },
65 {
66 description = "relay allocations"
67 from_port = 49152
68 to_port = 65535
69 protocol = "udp"
70 cidr_blocks = ["0.0.0.0/0"]
71 },
72 ] : []
73}
74
75resource "aws_security_group" "host" {
76 name = var.name
77 description = var.sg_description
78 # Null = the region's default VPC, which is what this module did before it
79 # took the input at all.
80 vpc_id = var.vpc_id
81
82 lifecycle {
83 # Both preconditions exist to move a failure from the middle of an apply
84 # to the start of a plan. The buckets and the IAM role are created before
85 # the instance, so a mid-apply failure here leaves a half-built region
86 # that the next run has to reconcile.
87 precondition {
88 condition = var.vpc_id != null || length(try(data.aws_vpcs.default[0].ids, [])) > 0
89 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.)"
90 }
91 precondition {
92 condition = (var.vpc_id == null) == (var.subnet_id == null)
93 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."
94 }
95 }
96
97 ingress {
98 from_port = 80
99 to_port = 80
100 protocol = "tcp"
101 cidr_blocks = ["0.0.0.0/0"]
102 }
103 ingress {
104 from_port = 443
105 to_port = 443
106 protocol = "tcp"
107 cidr_blocks = ["0.0.0.0/0"]
108 }
109 dynamic "ingress" {
110 for_each = var.extra_ingress
111 content {
112 description = ingress.value.description
113 from_port = ingress.value.from_port
114 to_port = ingress.value.to_port
115 protocol = ingress.value.protocol
116 cidr_blocks = ingress.value.cidr_blocks
117 }
118 }
119 dynamic "ingress" {
120 for_each = local.relay_ingress
121 content {
122 description = ingress.value.description
123 from_port = ingress.value.from_port
124 to_port = ingress.value.to_port
125 protocol = ingress.value.protocol
126 cidr_blocks = ingress.value.cidr_blocks
127 }
128 }
129 # No SSH ingress: admin access is SSM Session Manager only, and the
130 # carlos binary arrives from the deployment bucket, not over scp.
131 egress {
132 from_port = 0
133 to_port = 0
134 protocol = "-1"
135 cidr_blocks = ["0.0.0.0/0"]
136 }
137}
138
139resource "aws_iam_role" "host" {
140 name = var.iam_name
141 assume_role_policy = jsonencode({
142 Version = "2012-10-17"
143 Statement = [{
144 Effect = "Allow"
145 Principal = { Service = "ec2.amazonaws.com" }
146 Action = "sts:AssumeRole"
147 }]
148 })
149}
150
151# Scoped to exactly the deployment bucket, this region's data bucket, and
152# the deployment's SSM parameter prefix.
153resource "aws_iam_role_policy" "host_buckets" {
154 name = "${var.iam_name}-buckets"
155 role = aws_iam_role.host.id
156 policy = jsonencode({
157 Version = "2012-10-17"
158 # The two sibling-staging statement groups (instance-move spec, issue
159 # #331) are appended only when sibling_staging_arns is non-empty:
160 # push-only into moves/* — a box can stage a residence move TOWARD a
161 # sibling region but can never read that region's data or touch its
162 # live instances/* replica lineage.
163 Statement = concat([
164 {
165 Effect = "Allow"
166 Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
167 Resource = [
168 "${var.deployment_bucket_arn}/*",
169 "${var.data_bucket_arn}/*",
170 ]
171 },
172 {
173 Effect = "Allow"
174 Action = ["s3:ListBucket", "s3:GetBucketLocation"]
175 Resource = [var.deployment_bucket_arn, var.data_bucket_arn]
176 },
177 # Host lifecycle claim, Plan 2 (spec C3, §6.2, §6.5): exact-version
178 # list, read and delete on this box's OWN data bucket. Sibling-region
179 # moves/* grants are unchanged: the destination region retires its
180 # own staging (spec N13).
181 {
182 Sid = "PullDoorObjectVersions"
183 Effect = "Allow"
184 Action = ["s3:GetObjectVersion"]
185 Resource = ["${var.data_bucket_arn}/*"]
186 },
187 {
188 Effect = "Allow"
189 Action = ["s3:DeleteObjectVersion"]
190 Resource = ["${var.data_bucket_arn}/*"]
191 },
192 {
193 Sid = "PullDoorVersionList"
194 Effect = "Allow"
195 Action = ["s3:ListBucketVersions"]
196 Resource = [var.data_bucket_arn]
197 },
198 {
199 Effect = "Allow"
200 Action = ["ssm:GetParameter"]
201 Resource = var.ssm_parameter_arn
202 },
203 # Infrastructure inventory (2026-08-19-fleet-architecture-diagram-design.md
204 # §8): what the console's hourly infra pass needs to draw
205 # /fleet/architecture. Read-only in the strictest sense — every action
206 # here is a List/Describe/Get of a resource's SHAPE. No write action,
207 # no s3:GetObject, and deliberately no iam:GetPolicyVersion: the page
208 # shows that a role exists and how many policies are attached to it,
209 # never what those policies say.
210 {
211 Effect = "Allow"
212 Action = [
213 "ec2:DescribeInstances",
214 "ec2:DescribeVolumes",
215 "ec2:DescribeAddresses",
216 "ec2:DescribeSecurityGroups",
217 "ec2:DescribeVpcs",
218 "ec2:DescribeSubnets",
219 "ec2:DescribeRegions",
220 "tag:GetResources",
221 "s3:ListAllMyBuckets",
222 "s3:GetBucketLocation",
223 "s3:GetBucketVersioning",
224 "s3:GetEncryptionConfiguration",
225 "s3:GetBucketPublicAccessBlock",
226 "route53:ListHostedZones",
227 "route53:ListResourceRecordSets",
228 "iam:ListRoles",
229 "iam:ListUsers",
230 "iam:ListAttachedRolePolicies",
231 "cloudwatch:DescribeAlarms",
232 "acm:ListCertificates",
233 "sts:GetCallerIdentity",
234 ]
235 Resource = "*"
236 },
237 ],
238 [for arn in var.sibling_staging_arns : {
239 Effect = "Allow"
240 Action = ["s3:PutObject", "s3:DeleteObject"]
241 Resource = "${arn}/moves/*"
242 }],
243 [for arn in var.sibling_staging_arns : {
244 Effect = "Allow"
245 Action = ["s3:ListBucket"]
246 Resource = arn
247 Condition = { StringLike = { "s3:prefix" = "moves/*" } }
248 }]
249 )
250 })
251}
252
253# Wildcard-cert DNS-01 (edge wildcard.go, CARLOS_DNS_PROVIDER=route53):
254# write challenge TXTs into the named zones only. ListHostedZonesByName has
255# no resource-level scoping in IAM, hence the split statement.
256resource "aws_iam_role_policy" "host_route53" {
257 count = length(var.route53_zone_ids) > 0 ? 1 : 0
258 name = "${var.iam_name}-route53"
259 role = aws_iam_role.host.id
260 policy = jsonencode({
261 Version = "2012-10-17"
262 Statement = [
263 {
264 Effect = "Allow"
265 Action = ["route53:ChangeResourceRecordSets"]
266 Resource = [for z in var.route53_zone_ids : "arn:aws:route53:::hostedzone/${z}"]
267 # The box may touch ONLY _acme-challenge TXT records, and only via the
268 # two operations the edge issues (UPSERT in present, DELETE in cleanup).
269 # Without these conditions a zone grant is the whole zone — apex, MX,
270 # NS — and this policy is the only bound on the box's DNS authority.
271 Condition = {
272 "ForAllValues:StringEquals" = {
273 "route53:ChangeResourceRecordSetsRecordTypes" = ["TXT"]
274 "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"]
275 }
276 "ForAllValues:StringLike" = {
277 "route53:ChangeResourceRecordSetsNormalizedRecordNames" = ["_acme-challenge.*"]
278 }
279 }
280 },
281 {
282 Effect = "Allow"
283 Action = ["route53:GetChange"]
284 Resource = "arn:aws:route53:::change/*"
285 },
286 {
287 Effect = "Allow"
288 Action = ["route53:ListHostedZonesByName"]
289 Resource = "*"
290 }
291 ]
292 })
293}
294
295# DNS steering (internal/steering/route53.go, run as a housekeeper duty on
296# whichever box holds the lease): latency A records per armed edge, each bound
297# to a Route53 health check on that edge's EIP. Every action here is one the
298# converger's route53API interface actually calls — nothing is granted "for
299# later", and nothing it calls is missing (a missing action is not a partial
300# feature: the whole pass aborts AccessDenied and no record ever converges).
301resource "aws_iam_role_policy" "host_steering" {
302 count = length(var.steering_zone_ids) > 0 ? 1 : 0
303 name = "${var.iam_name}-steering"
304 role = aws_iam_role.host.id
305 policy = jsonencode({
306 Version = "2012-10-17"
307 Statement = [
308 {
309 # The write. Two bounds, and it is worth being exact about what each
310 # one does and does not buy:
311 #
312 # - TYPE and ACTION are bounded always: the converger only ever
313 # UPSERTs or DELETEs A records (desiredRecord is a latency A set;
314 # converge builds no other type or action), so the zone's NS, SOA,
315 # MX, TXT and CNAME records are out of this role's reach by IAM.
316 # - NAME is bounded only if steering_record_names is set. WITHOUT it
317 # this grant covers every A record in the zone — the APEX A
318 # included, which on both our zones is a live site — and the only
319 # thing standing in the way is the converger's own code
320 # (isManaged, conflictedNames). That is a code bound, not an IAM
321 # one. Set the variable to "*.<apps-domain>": it covers the
322 # steering hostname and every steered platform host, and steered
323 # CUSTOM domains need no grant here at all — they reach steering
324 # through a CNAME in the customer's own zone.
325 Effect = "Allow"
326 Action = ["route53:ChangeResourceRecordSets"]
327 Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"]
328 Condition = merge(
329 {
330 "ForAllValues:StringEquals" = {
331 "route53:ChangeResourceRecordSetsRecordTypes" = ["A"]
332 "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"]
333 }
334 },
335 length(var.steering_record_names) > 0 ? {
336 "ForAllValues:StringLike" = {
337 "route53:ChangeResourceRecordSetsNormalizedRecordNames" = var.steering_record_names
338 }
339 } : {},
340 )
341 },
342 {
343 # The read, split out because ListResourceRecordSets supports none of
344 # the condition keys above — folded into one statement they would be
345 # decoration on this action, which is worse than saying so here.
346 Effect = "Allow"
347 Action = ["route53:ListResourceRecordSets"]
348 Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"]
349 },
350 {
351 # Health checks are NOT zone-scoped and Route53 supports NO
352 # tag-based condition keys on them (the service authorization
353 # reference lists no condition keys for the healthcheck resource
354 # type, and CreateHealthCheck takes no tags at all — the converger
355 # tags in a second call, ChangeTagsForResource). Said plainly rather
356 # than dressed up: within this account, this role may create, tag,
357 # read the tags of, and delete ANY Route53 health check. The
358 # carlos-steering=<zone-id> tag is the converger's OWN ownership
359 # rule (ownedHealthChecks / gcHealthChecks refuse to delete anything
360 # else); it is a code bound, not an IAM one, and it cannot be made
361 # one here.
362 #
363 # ListHealthChecks and CreateHealthCheck have no resource ARN to name
364 # at all, hence "*" for the pair; the per-check operations are held to
365 # the healthcheck ARN space.
366 Effect = "Allow"
367 Action = ["route53:ListHealthChecks", "route53:CreateHealthCheck"]
368 Resource = "*"
369 },
370 {
371 Effect = "Allow"
372 Action = [
373 "route53:DeleteHealthCheck",
374 "route53:ChangeTagsForResource",
375 # PLURAL — the batched form (ListTagsForResources) is what the
376 # converger calls, ten checks at a time. The singular
377 # ListTagsForResource grants it nothing.
378 "route53:ListTagsForResources",
379 ]
380 Resource = "arn:aws:route53:::healthcheck/*"
381 },
382 ]
383 })
384}
385
386# Cost Explorer read for the economics dashboard's daily bill pull
387# (internal/console/cepull.go). Off by default: only the box that runs the
388# console needs it, and ce:GetCostAndUsage has no resource-level scoping —
389# it reads the whole account's cost lines, so the grant is deliberate.
390resource "aws_iam_role_policy" "host_costexplorer" {
391 count = var.enable_cost_explorer ? 1 : 0
392 name = "${var.iam_name}-costexplorer"
393 role = aws_iam_role.host.id
394 policy = jsonencode({
395 Version = "2012-10-17"
396 Statement = [
397 {
398 Effect = "Allow"
399 Action = ["ce:GetCostAndUsage"]
400 Resource = "*"
401 },
402 ]
403 })
404}
405
406# The agent's memwatch duty (internal/host/memwatch.go) publishes
407# MemAvailablePercent and MemoryPressureSome60 once a minute. PutMetricData
408# has no resource ARN; the namespace condition is the whole bound — this
409# role can write Carlos/Host and nothing else.
410resource "aws_iam_role_policy" "host_metrics" {
411 name = "${var.iam_name}-metrics"
412 role = aws_iam_role.host.id
413 policy = jsonencode({
414 Version = "2012-10-17"
415 Statement = [
416 {
417 Effect = "Allow"
418 Action = ["cloudwatch:PutMetricData"]
419 Resource = "*"
420 Condition = {
421 StringEquals = { "cloudwatch:namespace" = "Carlos/Host" }
422 }
423 },
424 ]
425 })
426}
427
428# Session Manager. This is the ONLY admin path to these boxes — there is no
429# SSH ingress at all — so the managed policy is attached rather than
430# hand-rolled: getting its action list wrong locks everyone out of a fleet
431# whose recovery path is the thing that just broke.
432resource "aws_iam_role_policy_attachment" "host_ssm" {
433 role = aws_iam_role.host.name
434 policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
435}
436
437# ...and the hole that comes with it, closed.
438#
439# AmazonSSMManagedInstanceCore grants `ssm:GetParameter*` on `Resource: "*"`.
440# So the Allow above — one parameter ARN pattern, the variable named
441# ssm_parameter_arn, whose whole description is about scoping — bounds
442# nothing at all on its own: every host can read every SSM parameter in the
443# account. On one deployment that included /carlos/release-key, the root
444# release-signing key, readable by an internet-facing edge box. Verified with
445# simulate-principal-policy; found standing up a fleet, not in review
446# (discussion #7).
447#
448# An explicit Deny is the fix that does not risk the admin path: IAM's
449# explicit deny wins over any Allow, including a managed policy's, and this
450# one names ONLY the four parameter-read actions. Session Manager's channel
451# actions, inventory, association and patch actions are untouched, so an
452# operator's `aws ssm start-session` behaves exactly as it did.
453#
454# NotResource, not Resource: the deny covers everything the Allow does not
455# name, which is the shape that stays correct when ssm_parameter_arn changes.
456resource "aws_iam_role_policy" "host_ssm_parameter_scope" {
457 count = var.scope_ssm_parameter_reads ? 1 : 0
458 name = "${var.iam_name}-ssm-parameter-scope"
459 role = aws_iam_role.host.id
460 policy = jsonencode({
461 Version = "2012-10-17"
462 Statement = [
463 {
464 Effect = "Deny"
465 Action = [
466 "ssm:GetParameter",
467 "ssm:GetParameters",
468 "ssm:GetParameterHistory",
469 "ssm:GetParametersByPath",
470 ]
471 NotResource = [var.ssm_parameter_arn]
472 },
473 ]
474 })
475}
476
477resource "aws_iam_instance_profile" "host" {
478 name = var.iam_name
479 role = aws_iam_role.host.name
480}
481
482resource "aws_instance" "host" {
483 ami = data.aws_ami.debian_arm64.id
484 instance_type = var.instance_type
485 # Null = the default VPC's default subnet, EC2's own behaviour and this
486 # module's original one. The security group's precondition guarantees this
487 # is only null when a default VPC exists to hold it.
488 subnet_id = var.subnet_id
489 vpc_security_group_ids = [aws_security_group.host.id]
490 iam_instance_profile = aws_iam_instance_profile.host.name
491 user_data = var.user_data
492 user_data_replace_on_change = false
493 # IMDSv2 only, one hop. With the block absent EC2 leaves IMDSv1 on, and a
494 # plain GET from any request-forgery bug in a tenant app reads this
495 # role's credentials; v2 needs a PUT with a header first, which those
496 # bugs rarely give an attacker. Hop limit 1 keeps the token from leaving
497 # the box. Changing these on a running instance is in place, no restart.
498 # The flagship boxes were switched by hand on 2026-10-23; this keeps a
499 # replaced box from coming back on v1. The scanner host has done the same
500 # since it was written.
501 metadata_options {
502 http_endpoint = "enabled"
503 http_tokens = "required"
504 http_put_response_hop_limit = 1
505 }
506 root_block_device {
507 volume_size = var.root_volume_size
508 volume_type = "gp3"
509 encrypted = true
510 # A terminated box must leave no disk behind: termination is the proof
511 # that its instances may run elsewhere (region HA spec 1 §5.9).
512 delete_on_termination = true
513 }
514 tags = { Name = var.name }
515 # The AMI data source floats (most_recent): without this, a newer
516 # Debian image silently turns any consumer's `tofu apply` into
517 # "replace the region host" — seen live 2026-08-03, when a routine
518 # plan in tito's deployment wanted to destroy the production box.
519 # AMI updates are explicit-replace territory:
520 # `tofu apply -replace=module.<host>.aws_instance.host` when intended.
521 lifecycle {
522 ignore_changes = [ami]
523 }
524}
525
526resource "aws_eip" "host" {
527 domain = "vpc"
528 instance = aws_instance.host.id
529 tags = { Name = var.name }
530}
531
532# HA ladder rung 0 (2026-08-12 spec): system-status failure recovers the
533# instance onto healthy hardware, keeping instance id, EIP and volumes.
534resource "aws_cloudwatch_metric_alarm" "host_auto_recover" {
535 count = var.enable_auto_recovery ? 1 : 0
536 alarm_name = "${var.name}-auto-recover"
537 alarm_description = "EC2 system-status-check failure — auto-recovers ${var.name} onto healthy hardware."
538 namespace = "AWS/EC2"
539 metric_name = "StatusCheckFailed_System"
540 statistic = "Maximum"
541 period = 60
542 evaluation_periods = 2
543 threshold = 1
544 comparison_operator = "GreaterThanOrEqualToThreshold"
545 alarm_actions = ["arn:aws:automate:${data.aws_region.current.region}:ec2:recover"]
546 dimensions = {
547 InstanceId = aws_instance.host.id
548 }
549 tags = { Name = "${var.name}-auto-recover" }
550}
551
552# Memory-pressure spec §2.4: the early warning the status checks can't give.
553# On 2026-08-28 the flagship thrashed for 15 minutes with EC2 status ok/ok;
554# MemAvailable would have crossed 15% minutes earlier. Missing data is
555# BREACHING: a box that stops reporting is a box in trouble (or an agent
556# rolled without CARLOS_HOST_METRICS — either way, look). Which is also
557# why the caller should set alerts_email only once the box is publishing
558# (spec §4): wiring it first buys days of ALARM mail about nothing.
559#
560# The topic lives here, in the host's region, because an alarm can only
561# notify a same-region topic — one account-wide topic passed to four
562# regional hosts would apply and then never deliver for three of them.
563resource "aws_sns_topic" "alerts" {
564 count = var.alerts_email != "" ? 1 : 0
565 name = "${var.name}-alerts"
566}
567
568resource "aws_sns_topic_subscription" "alerts_email" {
569 count = var.alerts_email != "" ? 1 : 0
570 topic_arn = aws_sns_topic.alerts[0].arn
571 protocol = "email"
572 endpoint = var.alerts_email
573}
574
575resource "aws_cloudwatch_metric_alarm" "host_memory_low" {
576 count = var.alerts_email != "" ? 1 : 0
577 alarm_name = "${var.name}-memory-low"
578 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."
579 namespace = "Carlos/Host"
580 metric_name = "MemAvailablePercent"
581 statistic = "Minimum"
582 period = 60
583 evaluation_periods = 3
584 datapoints_to_alarm = 3
585 threshold = 15
586 comparison_operator = "LessThanThreshold"
587 treat_missing_data = "breaching"
588 alarm_actions = [aws_sns_topic.alerts[0].arn]
589 ok_actions = [aws_sns_topic.alerts[0].arn]
590 dimensions = {
591 InstanceId = aws_instance.host.id
592 }
593 tags = { Name = "${var.name}-memory-low" }
594}
595
596# ---- object storage provisioning (issue #368) ------------------------------
597#
598# Everything below exists only when store_bucket_prefix is set. A deployment
599# that has not switched object storage on gets none of it, which is why the
600# console's own storeBackend() treats an unset CARLOS_STORE_BACKEND as a legal
601# deployment rather than a misconfiguration.
602#
603# The shape is two halves that only make sense together: a grant wide enough
604# to provision, and a ceiling narrow enough that the grant cannot be turned
605# into anything else.
606
607data "aws_caller_identity" "current" {}
608
609# The ceiling. Every store user is created under this boundary (objstore's
610# EnsureUser stamps it, and the role policy below REFUSES CreateUser without
611# it), so a store credential can never do more than S3 on this deployment's
612# own store buckets — no matter what inline policy the console writes.
613#
614# This is the answer to the escalation the grant would otherwise open: a role
615# that may CreateUser and PutUserPolicy can mint a principal wider than
616# itself, and scoping those calls to an IAM path does not help, because a path
617# says which user is made and nothing about what it may do.
618resource "aws_iam_policy" "store_user_boundary" {
619 count = var.store_bucket_prefix == "" ? 0 : 1
620 name = "${var.iam_name}-store-boundary"
621 description = "Permissions ceiling for CARLOS object-store users. Intersected with each user's inline policy, which narrows it to that user's one bucket."
622 policy = jsonencode({
623 Version = "2012-10-17"
624 Statement = [
625 {
626 Effect = "Allow"
627 Action = ["s3:ListBucket", "s3:GetBucketLocation"]
628 Resource = "arn:aws:s3:::${var.store_bucket_prefix}*"
629 },
630 {
631 Effect = "Allow"
632 Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
633 Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*"
634 },
635 ]
636 })
637}
638
639resource "aws_iam_role_policy" "host_store" {
640 count = var.store_bucket_prefix == "" ? 0 : 1
641 name = "${var.iam_name}-store"
642 role = aws_iam_role.host.id
643 policy = jsonencode({
644 Version = "2012-10-17"
645 Statement = [
646 # Bucket lifecycle, prefix-scoped. EmptyBucket's paginated delete needs
647 # ListBucket on the bucket and DeleteObject under it; teardown needs
648 # DeleteBucket. No GetObject: the console provisions stores, it does not
649 # read tenant data, and the economics sweep sizes buckets with ListBucket.
650 # (Scanning is the exception, and it is a separate policy behind
651 # store_scanning, below.)
652 {
653 Effect = "Allow"
654 Action = ["s3:CreateBucket", "s3:DeleteBucket", "s3:ListBucket", "s3:GetBucketLocation"]
655 Resource = "arn:aws:s3:::${var.store_bucket_prefix}*"
656 },
657 {
658 Effect = "Allow"
659 Action = ["s3:DeleteObject"]
660 Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*"
661 },
662 # A deployment whose store prefix also matches its OWN buckets — the
663 # obvious "carlos-" does exactly that against carlos-<deployment>-<acct>
664 # — would otherwise hand the box DeleteBucket on the control plane it
665 # runs from. An explicit Deny beats every Allow in IAM, so this holds
666 # whatever prefix an operator chooses, including a careless one.
667 {
668 Effect = "Deny"
669 Action = ["s3:DeleteBucket", "s3:DeleteObject", "s3:CreateBucket"]
670 Resource = [
671 var.deployment_bucket_arn,
672 "${var.deployment_bucket_arn}/*",
673 var.data_bucket_arn,
674 "${var.data_bucket_arn}/*",
675 ]
676 },
677 # The two calls that could escalate, each refused unless the user being
678 # made or modified carries the boundary above. Conditioning PutUserPolicy
679 # as well as CreateUser matters: without it the console could attach a
680 # wide inline policy to some pre-existing unbounded user under the path.
681 {
682 Effect = "Allow"
683 Action = ["iam:CreateUser", "iam:PutUserPolicy"]
684 Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user${var.store_user_path}*"
685 Condition = {
686 StringEquals = { "iam:PermissionsBoundary" = aws_iam_policy.store_user_boundary[0].arn }
687 }
688 },
689 # The rest of the user lifecycle. None of these can widen a principal,
690 # so none is boundary-conditioned — but all stay pinned to the store
691 # user path, so the box can never touch a role or a person's user.
692 # Deliberately absent: iam:PutUserPermissionsBoundary and
693 # iam:DeleteUserPermissionsBoundary, which would let the console remove
694 # the ceiling, and iam:AttachUserPolicy, which would let it bypass the
695 # inline document entirely.
696 {
697 Effect = "Allow"
698 Action = [
699 "iam:GetUser",
700 "iam:DeleteUser",
701 "iam:DeleteUserPolicy",
702 "iam:CreateAccessKey",
703 "iam:DeleteAccessKey",
704 "iam:ListAccessKeys",
705 ]
706 Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user${var.store_user_path}*"
707 },
708 ]
709 })
710}
711
712# The console's own email (invite fallback, console-invite-delivery spec §4).
713# One identity, one From address. The configuration set and tenant are the
714# ops account's ("carlos-bab"), which SES checks on a tenant-attributed send.
715resource "aws_iam_role_policy" "host_console_mail" {
716 count = var.console_mail_from == "" ? 0 : 1
717 name = "${var.iam_name}-console-mail"
718 role = aws_iam_role.host.id
719 policy = jsonencode({
720 Version = "2012-10-17"
721 Statement = [{
722 Effect = "Allow"
723 Action = ["ses:SendEmail"]
724 Resource = [
725 "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:identity/${split("@", var.console_mail_from)[1]}",
726 "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:configuration-set/carlos-bab",
727 "arn:aws:ses:${var.console_mail_region}:${data.aws_caller_identity.current.account_id}:tenant/carlos-bab/*",
728 ]
729 Condition = {
730 StringEquals = { "ses:FromAddress" = var.console_mail_from }
731 }
732 }]
733 })
734 lifecycle {
735 precondition {
736 condition = var.console_mail_region != ""
737 error_message = "console_mail_region must be set when console_mail_from is (it must equal the console's CARLOS_MAIL_REGION)."
738 }
739 }
740}
741
742# ---- object storage scanning (opt-in) --------------------------------------
743#
744# The one grant that lets this role read tenant object bytes, so it is its own
745# policy behind its own switch: a deployment that has not turned scanning on
746# plans no change here, and the sentence in host_store above ("it does not
747# read tenant data") stays true for it.
748#
749# Exactly the calls the scanner makes. HeadObject is authorised by GetObject;
750# ListObjectsV2 is already in host_store. Store buckets use SSE-S3, so there is
751# no KMS statement. PutObjectTagging is how a verdict is written; the store
752# user boundary deliberately lacks it, so an app cannot write one itself.
753resource "aws_iam_role_policy" "host_store_scan" {
754 count = var.store_scanning ? 1 : 0
755 name = "${var.iam_name}-store-scan"
756 role = aws_iam_role.host.id
757 policy = jsonencode({
758 Version = "2012-10-17"
759 Statement = [
760 {
761 Effect = "Allow"
762 Action = ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging"]
763 Resource = "arn:aws:s3:::${var.store_bucket_prefix}*/*"
764 },
765 ]
766 })
767 lifecycle {
768 precondition {
769 condition = var.store_bucket_prefix != ""
770 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."
771 }
772 }
773}
774
775# --- Termination evidence (region HA spec 1 §5.9) ---------------------------
776# EC2 tells EventBridge when an instance in this account and region reaches
777# "terminated"; the rule forwards that to a queue the region's boxes read.
778# Delivery is best-effort on AWS's side: the queue keeps what arrived, and a
779# box that finds nothing here still asks EC2 directly.
780
781resource "aws_sqs_queue" "terminations" {
782 name = "${var.iam_name}-terminations"
783 message_retention_seconds = 1209600 # 14 days, the maximum
784 sqs_managed_sse_enabled = true
785}
786
787resource "aws_cloudwatch_event_rule" "terminations" {
788 name = "${var.iam_name}-terminations"
789 description = "EC2 instances reaching terminated, for region HA failover."
790 event_pattern = jsonencode({
791 source = ["aws.ec2"]
792 "detail-type" = ["EC2 Instance State-change Notification"]
793 detail = { state = ["terminated"] }
794 })
795}
796
797resource "aws_cloudwatch_event_target" "terminations" {
798 rule = aws_cloudwatch_event_rule.terminations.name
799 arn = aws_sqs_queue.terminations.arn
800}
801
802# Only this rule may put a message on the queue. A message is evidence, so
803# who can write one matters as much as who can read one.
804#
805# The Allow alone is not enough: a principal in this account with its own
806# sqs:SendMessage grant (an admin role, a wildcard policy) could still post
807# a forged event, because an identity policy and a queue policy add up.
808# The Deny closes that. It matches every send whose source is not this
809# rule, including one with no source at all, which is what a direct
810# SendMessage from a role looks like.
811resource "aws_sqs_queue_policy" "terminations" {
812 queue_url = aws_sqs_queue.terminations.id
813 policy = jsonencode({
814 Version = "2012-10-17"
815 Statement = [
816 {
817 Sid = "TheTerminationRuleMaySend"
818 Effect = "Allow"
819 Principal = { Service = "events.amazonaws.com" }
820 Action = "sqs:SendMessage"
821 Resource = aws_sqs_queue.terminations.arn
822 Condition = { ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.terminations.arn } }
823 },
824 {
825 Sid = "NobodyElseMaySend"
826 Effect = "Deny"
827 Principal = "*"
828 Action = "sqs:SendMessage"
829 Resource = aws_sqs_queue.terminations.arn
830 Condition = { ArnNotEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.terminations.arn } }
831 },
832 ]
833 })
834}
835
836resource "aws_iam_role_policy" "host_terminations" {
837 name = "${var.iam_name}-terminations"
838 role = aws_iam_role.host.id
839 policy = jsonencode({
840 Version = "2012-10-17"
841 Statement = [{
842 Effect = "Allow"
843 Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
844 Resource = aws_sqs_queue.terminations.arn
845 }]
846 })
847}
848