fix(infra): address PR review feedback on tf modules

Deployer role:
- Add servicediscovery actions; module always creates Cloud Map namespace
  and service so the policy must grant CreatePrivateDnsNamespace etc.
- Make Route53 + ACM permissions unconditional. The server module always
  issues an ACM cert and writes Route53 records (no CloudFront default-cert
  path exists), so gating these on route53_zone_ids was broken. Split
  Route53 into hosted-zone management (always) plus record-set mutation
  (scoped to caller-supplied zones, falls back to *).
- Remove unused cloudfront:* statement; no CloudFront resources in module.
- Replace acm:* wildcard with explicit cert-management action set.

Server module:
- qdrant_image_tag is now nullable with default null and validated against
  use_external_qdrant, so external-qdrant callers can omit it instead of
  passing a sentinel "unused" value.
- task_role_arn and efs_id outputs marked sensitive; qdrant_dns_name returns
  null when use_external_qdrant = true.
- ALB SG now has matching IPv6 egress rule (was v4-only).
- nextcloud_url validates the https:// scheme.
- random_pet.subdomain keeper includes zone_name so a zone migration that
  preserves zone_id still triggers regeneration.
- Pin required_version >= 1.9 on both modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-01 23:24:09 +02:00
co-authored by Claude Opus 4.7
parent ccf4b91bf9
commit e4c552cd19
10 changed files with 151 additions and 62 deletions
@@ -15,15 +15,26 @@ IAM role and least-privilege policy scoped to deploy the
client will use — so any "works for me, breaks for them" gap surfaces in
testing rather than at the client.
## Modes
## What the policy grants
The role's permissions are mode-aware via inputs:
Always granted (the server module always creates these resources):
| Mode | Inputs | What gets granted |
|---|---|---|
| **CloudFront default cert** (recommended) | (defaults) | ECS, ALB, EFS, CloudFront, scoped IAM/logs/secrets, EC2 SG + describe |
| **Custom domain** | `route53_zone_ids = [...]` | + Route53 (scoped to listed zones) and ACM |
| **Secret managed in same TF** | `allow_secret_create = true` | + Secrets Manager create/update/delete (scoped to `secret_name_prefix`) |
- ECS, ALB, EFS, Cloud Map (servicediscovery), Bedrock describe
- ACM cert management (scoped action set, resources `*` since cert ARNs
aren't known at policy-write time)
- Route53 hosted-zone reads + private-zone CRUD (Cloud Map needs the
latter); record-set mutation is scoped to caller-supplied zones via
`route53_zone_ids`
- IAM (scoped to `role/ecs/${module_name_prefix}-*`), CloudWatch Logs
(scoped to `/ecs/${module_name_prefix}*`), Secrets Manager read (scoped
to `${secret_name_prefix}*`), EC2 SG + describe
Conditional via inputs:
| Input | Effect |
|---|---|
| `route53_zone_ids = [...]` | scopes `route53:ChangeResourceRecordSets` to the listed zones (otherwise falls back to `*`) |
| `allow_secret_create = true` | adds Secrets Manager create/update/delete (scoped to `secret_name_prefix`) |
## Cross-account assume from your account
@@ -45,13 +56,14 @@ provider "aws" {
| Name | Version |
| ---- | ------- |
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.9 |
| <a name="requirement_aws"></a> [aws](#requirement\_aws) | ~> 6.0 |
## Providers
| Name | Version |
| ---- | ------- |
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.43.0 |
| <a name="provider_aws"></a> [aws](#provider\_aws) | ~> 6.0 |
## Modules
@@ -77,7 +89,7 @@ No modules.
| <a name="input_module_name_prefix"></a> [module\_name\_prefix](#input\_module\_name\_prefix) | The `var.name` value passed to the nextcloud-mcp-server module. Used to<br/>scope IAM/logs/secrets ARNs. Defaults match the module default; change<br/>only if the module is instantiated with a non-default name. | `string` | `"nextcloud-mcp-server"` | no |
| <a name="input_role_name"></a> [role\_name](#input\_role\_name) | Name of the deployer IAM role. | `string` | `"nextcloud-mcp-deployer"` | no |
| <a name="input_role_path"></a> [role\_path](#input\_role\_path) | IAM path for the deployer role and its policy. | `string` | `"/clients/"` | no |
| <a name="input_route53_zone_ids"></a> [route53\_zone\_ids](#input\_route53\_zone\_ids) | Route53 hosted zone IDs the deployer is allowed to mutate. Only needed<br/>in the module's custom-domain mode. Leave empty (the default) for the<br/>CloudFront-default-cert path, which requires no DNS or ACM permissions. | `list(string)` | `[]` | no |
| <a name="input_route53_zone_ids"></a> [route53\_zone\_ids](#input\_route53\_zone\_ids) | Route53 public hosted zone IDs the deployer is allowed to mutate. The<br/>server module always creates Route53 records (ALB alias + ACM DNS-01<br/>validation), so this should be set to the zone(s) the module's<br/>`zone_id` input points at. Leaving it empty falls back to `*` as a<br/>convenience but is not recommended in production — scope it. | `list(string)` | `[]` | no |
| <a name="input_secret_name_prefix"></a> [secret\_name\_prefix](#input\_secret\_name\_prefix) | Secrets Manager name prefix the deployer can read (and optionally<br/>create, see `allow_secret_create`). The module accepts a secret ARN as<br/>input; this prefix scopes the deployer's access to secrets matching<br/>that name pattern. | `string` | `"nextcloud-mcp"` | no |
| <a name="input_trusted_principal_arns"></a> [trusted\_principal\_arns](#input\_trusted\_principal\_arns) | Principal ARNs allowed to assume this role. For testing in your own<br/>account: the user/role you want to assume from. For client deployments:<br/>typically a single root-account ARN of the deploying party (e.g.<br/>"arn:aws:iam::<your-account-id>:root"), with MFA or external-id<br/>conditions added at the trust-policy level if required. | `list(string)` | n/a | yes |
@@ -1,4 +1,5 @@
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
@@ -71,13 +72,29 @@ data "aws_iam_policy_document" "deployer" {
resources = ["*"]
}
# --- Edge: CloudFront ---
# --- Service Discovery / Cloud Map ---
#
# CloudFront has no resource-ARN scoping for distribution create. Cache /
# origin-request policies the module ships are also account-wide objects.
# Module always creates `aws_service_discovery_private_dns_namespace` (in
# service_discovery.tf) and `aws_service_discovery_service` (in qdrant.tf).
# Cloud Map APIs don't accept resource-ARN scoping at create time; the
# blast radius is bounded by the SG/account boundary already.
statement {
sid = "CloudFrontService"
actions = ["cloudfront:*"]
sid = "ServiceDiscoveryService"
actions = [
"servicediscovery:CreatePrivateDnsNamespace",
"servicediscovery:DeleteNamespace",
"servicediscovery:GetNamespace",
"servicediscovery:ListNamespaces",
"servicediscovery:GetOperation",
"servicediscovery:CreateService",
"servicediscovery:DeleteService",
"servicediscovery:GetService",
"servicediscovery:UpdateService",
"servicediscovery:ListServices",
"servicediscovery:TagResource",
"servicediscovery:UntagResource",
"servicediscovery:ListTagsForResource",
]
resources = ["*"]
}
@@ -246,44 +263,74 @@ data "aws_iam_policy_document" "deployer" {
resources = ["*"]
}
# --- Route53 + ACM (custom-domain mode only) ---
# --- Route53 ---
#
# Default mode (CloudFront with `*.cloudfront.net` cert) needs neither.
# Opt in by passing `route53_zone_ids` for the zones the deployer is
# allowed to mutate.
dynamic "statement" {
for_each = length(var.route53_zone_ids) > 0 ? [1] : []
content {
sid = "Route53RecordsForZones"
actions = [
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets",
"route53:GetHostedZone",
]
resources = [
for zone_id in var.route53_zone_ids :
"arn:${local.partition}:route53:::hostedzone/${zone_id}"
]
}
# The server module always creates ACM cert + Route53 records (alias to ALB
# and DNS-01 validation), so these statements are unconditional. Cloud Map's
# CreatePrivateDnsNamespace also creates a Route53 private hosted zone
# under the caller's identity, which needs the hosted-zone management
# actions below.
# Hosted-zone CRUD (private zones for Cloud Map, public-zone reads for the
# caller-supplied zone). ChangeResourceRecordSets is the destructive action
# and is split into a separate statement scoped to caller-supplied zones.
statement {
sid = "Route53HostedZoneManage"
actions = [
"route53:CreateHostedZone",
"route53:GetHostedZone",
"route53:DeleteHostedZone",
"route53:ListHostedZones",
"route53:ListHostedZonesByVPC",
"route53:AssociateVPCWithHostedZone",
"route53:DisassociateVPCFromHostedZone",
"route53:ChangeTagsForResource",
"route53:ListTagsForResource",
"route53:ListResourceRecordSets",
]
resources = ["*"]
}
# ChangeResourceRecordSets is scoped to caller-supplied public zones when
# `route53_zone_ids` is set. Falls back to `*` only as a convenience for
# callers who haven't enumerated their zones — strongly recommend setting
# the variable.
statement {
sid = "Route53RecordsForZones"
actions = ["route53:ChangeResourceRecordSets"]
resources = (
length(var.route53_zone_ids) > 0
? [for zone_id in var.route53_zone_ids : "arn:${local.partition}:route53:::hostedzone/${zone_id}"]
: ["*"]
)
}
# GetChange takes a change-id, not a zone ARN — must be `*`.
dynamic "statement" {
for_each = length(var.route53_zone_ids) > 0 ? [1] : []
content {
sid = "Route53GetChange"
actions = ["route53:GetChange"]
resources = ["*"]
}
statement {
sid = "Route53GetChange"
actions = ["route53:GetChange"]
resources = ["*"]
}
dynamic "statement" {
for_each = length(var.route53_zone_ids) > 0 ? [1] : []
content {
sid = "AcmService"
actions = ["acm:*"]
resources = ["*"]
}
# --- ACM ---
#
# The server module always issues an ACM cert. ACM cert ARNs are only
# known after RequestCertificate, so the destructive actions can't be
# scoped further at policy-write time. Restrict the action set instead of
# granting `acm:*`.
statement {
sid = "AcmManageCertificates"
actions = [
"acm:RequestCertificate",
"acm:DescribeCertificate",
"acm:DeleteCertificate",
"acm:ListCertificates",
"acm:ListTagsForCertificate",
"acm:AddTagsToCertificate",
"acm:RemoveTagsFromCertificate",
"acm:UpdateCertificateOptions",
]
resources = ["*"]
}
# --- KMS describe (AWS-managed keys for default EFS / Secrets encryption) ---
@@ -60,9 +60,11 @@ variable "allow_secret_create" {
variable "route53_zone_ids" {
description = <<-EOT
Route53 hosted zone IDs the deployer is allowed to mutate. Only needed
in the module's custom-domain mode. Leave empty (the default) for the
CloudFront-default-cert path, which requires no DNS or ACM permissions.
Route53 public hosted zone IDs the deployer is allowed to mutate. The
server module always creates Route53 records (ALB alias + ACM DNS-01
validation), so this should be set to the zone(s) the module's
`zone_id` input points at. Leaving it empty falls back to `*` as a
convenience but is not recommended in production — scope it.
EOT
type = list(string)
default = []