feat(infra): distribute terraform modules under infra/terraform
POC: lift the homelab-grown nextcloud-mcp-server and nextcloud-mcp-deployer-role Terraform modules into this repo so external operators can consume them via a `git::` source. Includes a top-level README documenting the two-phase deploy flow (bootstrap deployer role with a copy-pasteable IAM policy, then assume the role to deploy the MCP server) and supports both in-VPC Qdrant and external/managed Qdrant modes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0c2d3e1086
commit
ccf4b91bf9
@@ -0,0 +1,91 @@
|
||||
# nextcloud-mcp-deployer-role
|
||||
|
||||
IAM role and least-privilege policy scoped to deploy the
|
||||
[`nextcloud-mcp-server`](../nextcloud-mcp-server) Terraform module.
|
||||
|
||||
## Use cases
|
||||
|
||||
- **Client account**: a client creates this role in their AWS account with
|
||||
`trusted_principal_arns = ["arn:aws:iam::<your-account-id>:root"]` so you
|
||||
can assume it cross-account and deploy/maintain the MCP server on their
|
||||
behalf.
|
||||
- **Your own testing**: instantiated in your account with your IAM user /
|
||||
admin role as the trusted principal, lets you run `terraform apply` for
|
||||
the nextcloud-mcp-server module under the same permission boundary the
|
||||
client will use — so any "works for me, breaks for them" gap surfaces in
|
||||
testing rather than at the client.
|
||||
|
||||
## Modes
|
||||
|
||||
The role's permissions are mode-aware via inputs:
|
||||
|
||||
| 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`) |
|
||||
|
||||
## Cross-account assume from your account
|
||||
|
||||
Once the client has applied this module in their account and given you the
|
||||
output `role_arn`, configure the AWS provider in your client-deployment TF
|
||||
project:
|
||||
|
||||
```hcl
|
||||
provider "aws" {
|
||||
assume_role {
|
||||
role_arn = "arn:aws:iam::<client-account-id>:role/clients/nextcloud-mcp-deployer"
|
||||
# external_id = "..." # optional, recommended for cross-account
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<!-- BEGIN_TF_DOCS -->
|
||||
## Requirements
|
||||
|
||||
| Name | Version |
|
||||
| ---- | ------- |
|
||||
| <a name="requirement_aws"></a> [aws](#requirement\_aws) | ~> 6.0 |
|
||||
|
||||
## Providers
|
||||
|
||||
| Name | Version |
|
||||
| ---- | ------- |
|
||||
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.43.0 |
|
||||
|
||||
## Modules
|
||||
|
||||
No modules.
|
||||
|
||||
## Resources
|
||||
|
||||
| Name | Type |
|
||||
| ---- | ---- |
|
||||
| [aws_iam_policy.deployer](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
|
||||
| [aws_iam_role.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
|
||||
| [aws_iam_role_policy_attachment.deployer](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource |
|
||||
| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
|
||||
| [aws_iam_policy_document.deployer](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
|
||||
| [aws_iam_policy_document.trust](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
|
||||
| [aws_partition.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/partition) | data source |
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Description | Type | Default | Required |
|
||||
| ---- | ----------- | ---- | ------- | :------: |
|
||||
| <a name="input_allow_secret_create"></a> [allow\_secret\_create](#input\_allow\_secret\_create) | When true, the deployer can create/update/delete Secrets Manager<br/>secrets matching `secret_name_prefix`. Set true if the secret is<br/>managed alongside the module in the same Terraform run; leave false if<br/>the secret is provisioned out of band (console / separate root TF) and<br/>only the ARN is passed in. | `bool` | `false` | no |
|
||||
| <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_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 |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
| ---- | ----------- |
|
||||
| <a name="output_policy_arn"></a> [policy\_arn](#output\_policy\_arn) | ARN of the inline-style managed policy attached to the role. |
|
||||
| <a name="output_role_arn"></a> [role\_arn](#output\_role\_arn) | ARN of the deployer role; pass to STS AssumeRole or use as the assume\_role target in a provider block. |
|
||||
| <a name="output_role_name"></a> [role\_name](#output\_role\_name) | Name of the deployer role. |
|
||||
<!-- END_TF_DOCS -->
|
||||
@@ -0,0 +1,331 @@
|
||||
terraform {
|
||||
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 = ["*"]
|
||||
}
|
||||
|
||||
# --- Edge: CloudFront ---
|
||||
#
|
||||
# CloudFront has no resource-ARN scoping for distribution create. Cache /
|
||||
# origin-request policies the module ships are also account-wide objects.
|
||||
statement {
|
||||
sid = "CloudFrontService"
|
||||
actions = ["cloudfront:*"]
|
||||
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 + ACM (custom-domain mode only) ---
|
||||
#
|
||||
# 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}"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# 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 = ["*"]
|
||||
}
|
||||
}
|
||||
|
||||
dynamic "statement" {
|
||||
for_each = length(var.route53_zone_ids) > 0 ? [1] : []
|
||||
content {
|
||||
sid = "AcmService"
|
||||
actions = ["acm:*"]
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
output "role_arn" {
|
||||
description = "ARN of the deployer role; pass to STS AssumeRole or use as the assume_role target in a provider block."
|
||||
value = aws_iam_role.this.arn
|
||||
}
|
||||
|
||||
output "role_name" {
|
||||
description = "Name of the deployer role."
|
||||
value = aws_iam_role.this.name
|
||||
}
|
||||
|
||||
output "policy_arn" {
|
||||
description = "ARN of the inline-style managed policy attached to the role."
|
||||
value = aws_iam_policy.deployer.arn
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
variable "role_name" {
|
||||
description = "Name of the deployer IAM role."
|
||||
type = string
|
||||
default = "nextcloud-mcp-deployer"
|
||||
}
|
||||
|
||||
variable "role_path" {
|
||||
description = "IAM path for the deployer role and its policy."
|
||||
type = string
|
||||
default = "/clients/"
|
||||
}
|
||||
|
||||
variable "trusted_principal_arns" {
|
||||
description = <<-EOT
|
||||
Principal ARNs allowed to assume this role. For testing in your own
|
||||
account: the user/role you want to assume from. For client deployments:
|
||||
typically a single root-account ARN of the deploying party (e.g.
|
||||
"arn:aws:iam::<your-account-id>:root"), with MFA or external-id
|
||||
conditions added at the trust-policy level if required.
|
||||
EOT
|
||||
type = list(string)
|
||||
validation {
|
||||
condition = length(var.trusted_principal_arns) > 0
|
||||
error_message = "trusted_principal_arns must contain at least one ARN."
|
||||
}
|
||||
}
|
||||
|
||||
variable "module_name_prefix" {
|
||||
description = <<-EOT
|
||||
The `var.name` value passed to the nextcloud-mcp-server module. Used to
|
||||
scope IAM/logs/secrets ARNs. Defaults match the module default; change
|
||||
only if the module is instantiated with a non-default name.
|
||||
EOT
|
||||
type = string
|
||||
default = "nextcloud-mcp-server"
|
||||
}
|
||||
|
||||
variable "secret_name_prefix" {
|
||||
description = <<-EOT
|
||||
Secrets Manager name prefix the deployer can read (and optionally
|
||||
create, see `allow_secret_create`). The module accepts a secret ARN as
|
||||
input; this prefix scopes the deployer's access to secrets matching
|
||||
that name pattern.
|
||||
EOT
|
||||
type = string
|
||||
default = "nextcloud-mcp"
|
||||
}
|
||||
|
||||
variable "allow_secret_create" {
|
||||
description = <<-EOT
|
||||
When true, the deployer can create/update/delete Secrets Manager
|
||||
secrets matching `secret_name_prefix`. Set true if the secret is
|
||||
managed alongside the module in the same Terraform run; leave false if
|
||||
the secret is provisioned out of band (console / separate root TF) and
|
||||
only the ARN is passed in.
|
||||
EOT
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
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.
|
||||
EOT
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
Reference in New Issue
Block a user