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>
379 lines
11 KiB
Terraform
379 lines
11 KiB
Terraform
terraform {
|
|
required_version = ">= 1.9"
|
|
required_providers {
|
|
aws = {
|
|
source = "hashicorp/aws"
|
|
version = "~> 6.0"
|
|
}
|
|
}
|
|
}
|
|
|
|
data "aws_caller_identity" "current" {}
|
|
data "aws_partition" "current" {}
|
|
|
|
locals {
|
|
account_id = data.aws_caller_identity.current.account_id
|
|
partition = data.aws_partition.current.partition
|
|
prefix = var.module_name_prefix
|
|
}
|
|
|
|
###
|
|
# Role + trust
|
|
|
|
resource "aws_iam_role" "this" {
|
|
name = var.role_name
|
|
path = var.role_path
|
|
assume_role_policy = data.aws_iam_policy_document.trust.json
|
|
description = "Deploy + manage the nextcloud-mcp-server Terraform module."
|
|
}
|
|
|
|
data "aws_iam_policy_document" "trust" {
|
|
statement {
|
|
sid = "AllowTrustedPrincipals"
|
|
actions = ["sts:AssumeRole"]
|
|
principals {
|
|
type = "AWS"
|
|
identifiers = var.trusted_principal_arns
|
|
}
|
|
}
|
|
}
|
|
|
|
###
|
|
# Deployer policy
|
|
#
|
|
# Scoping strategy: actions that have no usable resource-ARN form at create
|
|
# time (RegisterTaskDefinition, CreateLoadBalancer, CreateFileSystem, all of
|
|
# CloudFront, etc.) are granted on `*` — IAM gives no other option there.
|
|
# Where ARN scoping IS available and worth the surface reduction (IAM, logs,
|
|
# secrets, route53), the policy is scoped tight to the module's name prefix.
|
|
|
|
data "aws_iam_policy_document" "deployer" {
|
|
|
|
# --- Compute / runtime: ECS, ALB, EFS ---
|
|
#
|
|
# All three services are gated by SG/SCP/account boundaries already, and
|
|
# most of their create-time APIs reject resource-ARN scoping. Granting
|
|
# service-wide is the standard pattern for a deployer role.
|
|
statement {
|
|
sid = "EcsService"
|
|
actions = ["ecs:*"]
|
|
resources = ["*"]
|
|
}
|
|
|
|
statement {
|
|
sid = "ElbV2Service"
|
|
actions = ["elasticloadbalancing:*"]
|
|
resources = ["*"]
|
|
}
|
|
|
|
statement {
|
|
sid = "EfsService"
|
|
actions = ["elasticfilesystem:*"]
|
|
resources = ["*"]
|
|
}
|
|
|
|
# --- Service Discovery / Cloud Map ---
|
|
#
|
|
# 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 = "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 = ["*"]
|
|
}
|
|
|
|
# --- Logs ---
|
|
#
|
|
# Module creates a single log group: `/ecs/${var.name}`. Scope CW Logs
|
|
# writes/reads to that prefix; describe APIs need `*` because they don't
|
|
# accept resource-ARN scoping.
|
|
statement {
|
|
sid = "LogsManageGroup"
|
|
actions = ["logs:*"]
|
|
resources = [
|
|
"arn:${local.partition}:logs:*:${local.account_id}:log-group:/ecs/${local.prefix}*",
|
|
"arn:${local.partition}:logs:*:${local.account_id}:log-group:/ecs/${local.prefix}*:*",
|
|
"arn:${local.partition}:logs:*:${local.account_id}:log-group:/ecs/${local.prefix}*:log-stream:*",
|
|
]
|
|
}
|
|
|
|
statement {
|
|
sid = "LogsDescribe"
|
|
actions = [
|
|
"logs:DescribeLogGroups",
|
|
"logs:DescribeLogStreams",
|
|
]
|
|
resources = ["*"]
|
|
}
|
|
|
|
# --- IAM ---
|
|
#
|
|
# The module creates two roles under path `/ecs/`: `${name}-execution` and
|
|
# `${name}-task`, plus inline policies on each. Scope to that path+prefix
|
|
# so the deployer can't pivot to creating arbitrary roles.
|
|
statement {
|
|
sid = "IamManageModuleRoles"
|
|
actions = [
|
|
"iam:CreateRole",
|
|
"iam:GetRole",
|
|
"iam:DeleteRole",
|
|
"iam:UpdateRole",
|
|
"iam:UpdateAssumeRolePolicy",
|
|
"iam:PutRolePolicy",
|
|
"iam:GetRolePolicy",
|
|
"iam:DeleteRolePolicy",
|
|
"iam:ListRolePolicies",
|
|
"iam:ListAttachedRolePolicies",
|
|
"iam:TagRole",
|
|
"iam:UntagRole",
|
|
"iam:ListRoleTags",
|
|
]
|
|
resources = [
|
|
"arn:${local.partition}:iam::${local.account_id}:role/ecs/${local.prefix}-*",
|
|
]
|
|
}
|
|
|
|
# ECS RunTask + service updates need iam:PassRole on the task/exec roles.
|
|
statement {
|
|
sid = "IamPassModuleRoles"
|
|
actions = ["iam:PassRole"]
|
|
resources = [
|
|
"arn:${local.partition}:iam::${local.account_id}:role/ecs/${local.prefix}-*",
|
|
]
|
|
condition {
|
|
test = "StringEquals"
|
|
variable = "iam:PassedToService"
|
|
values = ["ecs-tasks.amazonaws.com"]
|
|
}
|
|
}
|
|
|
|
# The execution role attaches the AWS-managed AmazonECSTaskExecutionRolePolicy.
|
|
# Restrict the Attach/Detach actions to that single managed policy ARN so
|
|
# this grant can't be used to attach AdministratorAccess or similar.
|
|
statement {
|
|
sid = "IamAttachManagedTaskExecPolicy"
|
|
actions = [
|
|
"iam:AttachRolePolicy",
|
|
"iam:DetachRolePolicy",
|
|
]
|
|
resources = [
|
|
"arn:${local.partition}:iam::${local.account_id}:role/ecs/${local.prefix}-*",
|
|
]
|
|
condition {
|
|
test = "ArnEquals"
|
|
variable = "iam:PolicyARN"
|
|
values = [
|
|
"arn:${local.partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
|
|
]
|
|
}
|
|
}
|
|
|
|
# --- Secrets Manager ---
|
|
#
|
|
# The module reads a caller-supplied secret ARN at deploy time
|
|
# (execution-role policy in iam.tf:26). Optionally allow create/update
|
|
# for callers who manage the secret in their own TF.
|
|
statement {
|
|
sid = "SecretsRead"
|
|
actions = [
|
|
"secretsmanager:GetSecretValue",
|
|
"secretsmanager:DescribeSecret",
|
|
"secretsmanager:ListSecrets",
|
|
]
|
|
resources = [
|
|
"arn:${local.partition}:secretsmanager:*:${local.account_id}:secret:${var.secret_name_prefix}*",
|
|
]
|
|
}
|
|
|
|
dynamic "statement" {
|
|
for_each = var.allow_secret_create ? [1] : []
|
|
content {
|
|
sid = "SecretsManage"
|
|
actions = [
|
|
"secretsmanager:CreateSecret",
|
|
"secretsmanager:UpdateSecret",
|
|
"secretsmanager:DeleteSecret",
|
|
"secretsmanager:PutSecretValue",
|
|
"secretsmanager:TagResource",
|
|
"secretsmanager:UntagResource",
|
|
"secretsmanager:GetResourcePolicy",
|
|
"secretsmanager:PutResourcePolicy",
|
|
]
|
|
resources = [
|
|
"arn:${local.partition}:secretsmanager:*:${local.account_id}:secret:${var.secret_name_prefix}*",
|
|
]
|
|
}
|
|
}
|
|
|
|
# --- EC2: Security Groups + describe-only networking ---
|
|
#
|
|
# SG mutations have no usable resource-ARN scoping at create time
|
|
# (CreateSecurityGroup returns the ID), so SG actions are granted on `*`.
|
|
# The describe set is needed for the module's data sources and SG-rule
|
|
# references (managed prefix list lookup for the CloudFront SG lock).
|
|
statement {
|
|
sid = "Ec2NetworkDescribe"
|
|
actions = [
|
|
"ec2:DescribeVpcs",
|
|
"ec2:DescribeSubnets",
|
|
"ec2:DescribeAvailabilityZones",
|
|
"ec2:DescribeSecurityGroups",
|
|
"ec2:DescribeSecurityGroupRules",
|
|
"ec2:DescribeNetworkInterfaces",
|
|
"ec2:DescribeRouteTables",
|
|
"ec2:DescribePrefixLists",
|
|
"ec2:DescribeManagedPrefixLists",
|
|
"ec2:GetManagedPrefixListEntries",
|
|
"ec2:DescribeTags",
|
|
]
|
|
resources = ["*"]
|
|
}
|
|
|
|
statement {
|
|
sid = "Ec2SecurityGroupManage"
|
|
actions = [
|
|
"ec2:CreateSecurityGroup",
|
|
"ec2:DeleteSecurityGroup",
|
|
"ec2:ModifySecurityGroupRules",
|
|
"ec2:AuthorizeSecurityGroupIngress",
|
|
"ec2:AuthorizeSecurityGroupEgress",
|
|
"ec2:RevokeSecurityGroupIngress",
|
|
"ec2:RevokeSecurityGroupEgress",
|
|
"ec2:UpdateSecurityGroupRuleDescriptionsIngress",
|
|
"ec2:UpdateSecurityGroupRuleDescriptionsEgress",
|
|
"ec2:CreateTags",
|
|
"ec2:DeleteTags",
|
|
]
|
|
resources = ["*"]
|
|
}
|
|
|
|
# --- Route53 ---
|
|
#
|
|
# 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 `*`.
|
|
statement {
|
|
sid = "Route53GetChange"
|
|
actions = ["route53:GetChange"]
|
|
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) ---
|
|
statement {
|
|
sid = "KmsDescribeAwsManaged"
|
|
actions = [
|
|
"kms:DescribeKey",
|
|
"kms:ListAliases",
|
|
]
|
|
resources = ["*"]
|
|
}
|
|
|
|
# --- Bedrock (model discovery) ---
|
|
#
|
|
# The task role grants `bedrock:InvokeModel` at runtime. The deployer
|
|
# itself doesn't invoke; it only needs to validate the model exists when
|
|
# rendering the task-role policy document. Read-only.
|
|
statement {
|
|
sid = "BedrockDescribe"
|
|
actions = [
|
|
"bedrock:GetFoundationModel",
|
|
"bedrock:ListFoundationModels",
|
|
]
|
|
resources = ["*"]
|
|
}
|
|
|
|
# --- STS ---
|
|
statement {
|
|
sid = "StsCallerIdentity"
|
|
actions = ["sts:GetCallerIdentity"]
|
|
resources = ["*"]
|
|
}
|
|
}
|
|
|
|
resource "aws_iam_policy" "deployer" {
|
|
name = "${var.role_name}-policy"
|
|
path = var.role_path
|
|
description = "Least-privilege policy for the nextcloud-mcp-server deployer role."
|
|
policy = data.aws_iam_policy_document.deployer.json
|
|
}
|
|
|
|
resource "aws_iam_role_policy_attachment" "deployer" {
|
|
role = aws_iam_role.this.name
|
|
policy_arn = aws_iam_policy.deployer.arn
|
|
}
|