diff --git a/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/README.md b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/README.md
new file mode 100644
index 0000000..e83a129
--- /dev/null
+++ b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/README.md
@@ -0,0 +1,55 @@
+
+# spire/agent-iam-role
+
+Creates an IAM role for the SPIRE agent with AWS Secrets Manager permissions for
+the aws\_secretsmanager SVIDStore plugin. The plugin stores workload SVIDs as secrets
+in Secrets Manager; the agent needs create, update, describe, and delete permissions
+on secrets under the configured name prefix.
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| [terraform](#requirement\_terraform) | >= 1.10 |
+| [aws](#requirement\_aws) | ~> 6.0 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | ~> 6.0 |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_eks_pod_identity_association.spire_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eks_pod_identity_association) | resource |
+| [aws_iam_role.spire_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
+| [aws_iam_role_policy.secretsmanager](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
+| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
+| [aws_iam_policy_document.assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_iam_policy_document.secretsmanager](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [cluster\_name](#input\_cluster\_name) | EKS cluster name. Required for Pod Identity association. | `string` | `null` | no |
+| [iam\_mode](#input\_iam\_mode) | IAM binding mode. 'pod\_identity' uses EKS Pod Identity (requires eks-pod-identity-agent add-on). 'irsa' uses IAM Roles for Service Accounts (requires enable\_irsa = true in the cluster unit and oidc\_provider\_arn to be set). | `string` | `"pod_identity"` | no |
+| [namespace](#input\_namespace) | Kubernetes namespace the SPIRE agent runs in. Used for the Pod Identity association and IRSA trust condition. | `string` | `"spire-system"` | no |
+| [oidc\_provider\_arn](#input\_oidc\_provider\_arn) | ARN of the EKS cluster OIDC provider. Required for IRSA mode. | `string` | `null` | no |
+| [role\_name](#input\_role\_name) | Name for the SPIRE agent IAM role. | `string` | n/a | yes |
+| [service\_account\_name](#input\_service\_account\_name) | Kubernetes service account name for the SPIRE agent. Used for the Pod Identity association and IRSA trust condition. | `string` | `"spire-agent"` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [role\_arn](#output\_role\_arn) | ARN of the SPIRE agent IAM role. |
+| [role\_name](#output\_role\_name) | Name of the SPIRE agent IAM role. |
+
\ No newline at end of file
diff --git a/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/main.tf b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/main.tf
new file mode 100644
index 0000000..ba870a7
--- /dev/null
+++ b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/main.tf
@@ -0,0 +1,145 @@
+/**
+ * # spire/agent-iam-role
+ *
+ * Creates an IAM role for the SPIRE agent with AWS Secrets Manager permissions for
+ * the aws_secretsmanager SVIDStore plugin. The plugin stores workload SVIDs as secrets
+ * in Secrets Manager; the agent needs create, update, describe, and delete permissions
+ * on secrets under the configured name prefix.
+ */
+
+data "aws_caller_identity" "current" {}
+data "aws_region" "current" {}
+
+locals {
+ account_id = data.aws_caller_identity.current.account_id
+ region = data.aws_region.current.region
+
+ oidc_provider_url = var.oidc_provider_arn != null ? trimprefix(var.oidc_provider_arn, "arn:aws:iam::${local.account_id}:oidc-provider/") : null
+}
+
+# --- Trust policy ---
+
+data "aws_iam_policy_document" "assume_role" {
+ dynamic "statement" {
+ for_each = var.iam_mode == "pod_identity" ? [1] : []
+ content {
+ actions = ["sts:AssumeRole", "sts:TagSession"]
+ principals {
+ type = "Service"
+ identifiers = ["pods.eks.amazonaws.com"]
+ }
+ dynamic "condition" {
+ for_each = var.cluster_name != null ? [1] : []
+ content {
+ test = "StringEquals"
+ variable = "aws:RequestTag/eks-cluster-name"
+ values = [var.cluster_name]
+ }
+ }
+ condition {
+ test = "StringEquals"
+ variable = "aws:RequestTag/kubernetes-namespace"
+ values = [var.namespace]
+ }
+ condition {
+ test = "StringEquals"
+ variable = "aws:RequestTag/kubernetes-service-account"
+ values = [var.service_account_name]
+ }
+ }
+ }
+
+ dynamic "statement" {
+ for_each = var.iam_mode == "irsa" ? [1] : []
+ content {
+ actions = ["sts:AssumeRoleWithWebIdentity"]
+ principals {
+ type = "Federated"
+ identifiers = [var.oidc_provider_arn]
+ }
+ condition {
+ test = "StringEquals"
+ variable = "${local.oidc_provider_url}:sub"
+ values = ["system:serviceaccount:${var.namespace}:${var.service_account_name}"]
+ }
+ condition {
+ test = "StringEquals"
+ variable = "${local.oidc_provider_url}:aud"
+ values = ["sts.amazonaws.com"]
+ }
+ }
+ }
+}
+
+resource "aws_iam_role" "spire_agent" {
+ name = var.role_name
+ assume_role_policy = data.aws_iam_policy_document.assume_role.json
+}
+
+# --- Secrets Manager SVIDStore policy ---
+#
+# The plugin tags every secret it creates with spire-svid=true and validates that tag
+# before updating or deleting existing secrets. Tag conditions therefore scope each
+# statement precisely:
+# - CreateSecret: require the tag in the request so only tagged secrets can be created.
+# - DescribeSecret: StringEqualsIfExists so that the plugin can check whether a secret
+# exists before creating it — when the secret doesn't yet exist there is no resource
+# tag for IAM to evaluate, so StringEquals would deny the call.
+# - All other operations: require the tag on the resource.
+
+data "aws_iam_policy_document" "secretsmanager" {
+ statement {
+ sid = "CreateSVIDSecret"
+ actions = ["secretsmanager:CreateSecret"]
+ resources = ["*"]
+ condition {
+ test = "StringEquals"
+ variable = "aws:RequestTag/spire-svid"
+ values = ["true"]
+ }
+ }
+
+ statement {
+ sid = "DescribeSVIDSecret"
+ actions = ["secretsmanager:DescribeSecret"]
+ resources = ["*"]
+ condition {
+ test = "StringEqualsIfExists"
+ variable = "aws:ResourceTag/spire-svid"
+ values = ["true"]
+ }
+ }
+
+ statement {
+ sid = "ManageSVIDSecrets"
+ actions = [
+ "secretsmanager:DeleteSecret",
+ "secretsmanager:PutSecretValue",
+ "secretsmanager:RestoreSecret",
+ "secretsmanager:TagResource",
+ ]
+ resources = ["*"]
+ condition {
+ test = "StringEquals"
+ variable = "aws:ResourceTag/spire-svid"
+ values = ["true"]
+ }
+ }
+}
+
+resource "aws_iam_role_policy" "secretsmanager" {
+ name = "${var.role_name}-secretsmanager"
+ role = aws_iam_role.spire_agent.name
+ policy = data.aws_iam_policy_document.secretsmanager.json
+}
+
+# --- EKS Pod Identity association ---
+
+resource "aws_eks_pod_identity_association" "spire_agent" {
+ count = var.iam_mode == "pod_identity" && var.cluster_name != null ? 1 : 0
+
+ cluster_name = var.cluster_name
+ namespace = var.namespace
+ service_account = var.service_account_name
+ role_arn = aws_iam_role.spire_agent.arn
+}
diff --git a/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/outputs.tf b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/outputs.tf
new file mode 100644
index 0000000..e78e25f
--- /dev/null
+++ b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/outputs.tf
@@ -0,0 +1,9 @@
+output "role_arn" {
+ description = "ARN of the SPIRE agent IAM role."
+ value = aws_iam_role.spire_agent.arn
+}
+
+output "role_name" {
+ description = "Name of the SPIRE agent IAM role."
+ value = aws_iam_role.spire_agent.name
+}
diff --git a/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/variables.tf b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/variables.tf
new file mode 100644
index 0000000..b915f28
--- /dev/null
+++ b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/variables.tf
@@ -0,0 +1,45 @@
+variable "role_name" {
+ type = string
+ description = "Name for the SPIRE agent IAM role."
+}
+
+variable "iam_mode" {
+ type = string
+ default = "pod_identity"
+ description = "IAM binding mode. 'pod_identity' uses EKS Pod Identity (requires eks-pod-identity-agent add-on). 'irsa' uses IAM Roles for Service Accounts (requires enable_irsa = true in the cluster unit and oidc_provider_arn to be set)."
+
+ validation {
+ condition = contains(["pod_identity", "irsa"], var.iam_mode)
+ error_message = "iam_mode must be 'pod_identity' or 'irsa'."
+ }
+}
+
+variable "namespace" {
+ type = string
+ default = "spire-system"
+ description = "Kubernetes namespace the SPIRE agent runs in. Used for the Pod Identity association and IRSA trust condition."
+}
+
+variable "service_account_name" {
+ type = string
+ default = "spire-agent"
+ description = "Kubernetes service account name for the SPIRE agent. Used for the Pod Identity association and IRSA trust condition."
+}
+
+variable "cluster_name" {
+ type = string
+ default = null
+ description = "EKS cluster name. Required for Pod Identity association."
+}
+
+variable "oidc_provider_arn" {
+ type = string
+ default = null
+ description = "ARN of the EKS cluster OIDC provider. Required for IRSA mode."
+
+ validation {
+ condition = var.iam_mode != "irsa" || var.oidc_provider_arn != null
+ error_message = "oidc_provider_arn must be set when iam_mode is 'irsa'."
+ }
+}
+
diff --git a/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/versions.tf b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/versions.tf
new file mode 100644
index 0000000..208a27a
--- /dev/null
+++ b/control-plane/deployment/aws/infra/modules/spire/agent-iam-role/versions.tf
@@ -0,0 +1,10 @@
+terraform {
+ required_version = ">= 1.10"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ }
+}
diff --git a/workload/deployment/aws/infra/stack/connect/attestation-policy/.terraform.lock.hcl b/workload/deployment/aws/infra/stack/connect/attestation-policy/.terraform.lock.hcl
new file mode 100644
index 0000000..ba5bccf
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/connect/attestation-policy/.terraform.lock.hcl
@@ -0,0 +1,42 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/cofide/cofide" {
+ version = "0.10.0"
+ constraints = "~> 0.9"
+ hashes = [
+ "h1:QP4yF3u09uVHvkUhTiFr5YkWWD/Tey5Wpj0ssjultmU=",
+ "zh:3d3189e7594fad1b5ecf110b5c4ec802de9f1328487eec106408cecf0ba70284",
+ "zh:3e7afaff3a0e9c26877400f0e5978f14f62687b0a1ec9cbf57b4710738dbcfba",
+ "zh:6a023e6866b53a8539251fa2d5b077730637f387291b3a9708ccaeca0d80e6da",
+ "zh:6cf2446a0035a2531a251dad2a17efb964cc60e42085c2df987f55679d76b320",
+ "zh:79214fe521515584141cba4b8cbe595bcf06672ea8dc0f663445c96df0d3b03d",
+ "zh:a01ad42a7275248220d2c8d36411730e0ddb5e353728caabdd5feda3b9b5e9dc",
+ "zh:a4949d94348dde64a3e7fe828f833bb99ae652d8ee04f3f9b2b521c01ea58ed3",
+ "zh:bf6332522990a3cade376610bc780c79739a5610aa9cc67b64c5eb7ab91e5132",
+ "zh:dbeabda7c58b0e4f51aa81df10ca7af9eb451e4fe819c3145d071d2884d2377a",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "6.49.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:gW/1w7xNATTgTXKN9Du926VKZ84YgV6BLJDTPimMYkk=",
+ "zh:11a636bb415bf780f0ad300cab83d687aebdc51381112ae7b29862e0bee43017",
+ "zh:2d6c4bb861c073d9900a2afc39cc1e38492c6996653e53c7a2083b526fb10ae9",
+ "zh:49f7ee4a7488f3d31342c5e9dbb577c40e0847a0cab152a0082e9aeef45f5c0f",
+ "zh:561283c9c9bd36b9d09832e50769b941eb45c43c6ab031f27c8bf78256af4af1",
+ "zh:576bf944e66d097b29fc45b25a5bbc53e7d4e71a486e2cb126304cf77b51fe79",
+ "zh:6c6bf8860773c121b9ca22743f733feea943f890fa3aba8740a59579dea16fc4",
+ "zh:88b963a659e42daac7384a6abb99b3383f9f6c8abad5dedafcd443536b122b84",
+ "zh:947e9404235ca094e39e7f3f464f99436320a168e1607c550e581fbc70553d48",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:a9c5a37bd1e5e81e85ad3dc3141f1b453b96e9cb8e500bc15bfb9c488fc95dcc",
+ "zh:b7b8a028fb2eafb98c676f9438808318f7ff0ea76eba03a6653d0940848a31c7",
+ "zh:be29f31827d6d5567aaec26034e6cceb994460446778ba1d0a435fc7ccd8a9f5",
+ "zh:c24c60ecffe3a7d44c762e62a29a802aec604096715ad3110b7ca2207124eb3a",
+ "zh:e2a247c5c7815437969e87cdce1f27f323eea75b97ed53f7605fef05d6406071",
+ "zh:e590ce9aca3f4fd964f7bf908a24dc3bc88e0b03a8554ccc81b9edcc84690670",
+ ]
+}
diff --git a/workload/deployment/aws/infra/stack/connect/attestation-policy/common.local.hcl.example b/workload/deployment/aws/infra/stack/connect/attestation-policy/common.local.hcl.example
new file mode 100644
index 0000000..78b53c6
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/connect/attestation-policy/common.local.hcl.example
@@ -0,0 +1,38 @@
+locals {
+ # Name for the attestation policy in Cofide Connect.
+ policy_name = "connect-trust-zone-aws-reference-arch-svidstore"
+
+ # SPIFFE ID path assigned to workloads matching this policy (e.g. ns//sa/).
+ # Required — no default.
+ spiffe_id_path = "ns/default/sa/my-app"
+
+ # SPIFFE ID path of the parent node agent. For k8s-psat-attested nodes this follows the pattern
+ # /spire/agent/k8s_psat//. Can be derived from the SPIRE server entry list.
+ # Required — no default.
+ parent_id_path = "/spire/agent/k8s_psat/connect-trust-zone-aws-reference-arch/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
+
+ # SPIRE selectors used to match workloads to this attestation policy.
+ # Required — no default.
+ selectors = [
+ {
+ type = "aws_secretsmanager"
+ value = "secretname:my-secret"
+ },
+ ]
+
+ # Optional: override values if not reading from their respective dependency units.
+ # connect_url = "example.cofide.dev:443"
+}
+
+# Note: this unit requires a locally built cofide/cofide provider that includes the store_svid
+# field on the static attestation policy. Configure dev_overrides in ~/.terraformrc to use it:
+#
+# provider_installation {
+# dev_overrides {
+# "cofide/cofide" = "/path/to/built/binary/directory"
+# }
+# direct {}
+# }
+#
+# Build the provider from the terraform-provider-cofide repository (sibling of this repo) with:
+# go build -o ~/go/bin/terraform-provider-cofide
diff --git a/workload/deployment/aws/infra/stack/connect/attestation-policy/main.tf b/workload/deployment/aws/infra/stack/connect/attestation-policy/main.tf
new file mode 100644
index 0000000..f1ed238
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/connect/attestation-policy/main.tf
@@ -0,0 +1,15 @@
+resource "cofide_connect_attestation_policy" "this" {
+ name = var.policy_name
+
+ static = {
+ spiffe_id_path = var.spiffe_id_path
+ parent_id_path = var.parent_id_path
+ selectors = var.selectors
+ store_svid = true
+ }
+}
+
+resource "cofide_connect_ap_binding" "this" {
+ trust_zone_id = var.trust_zone_id
+ policy_id = cofide_connect_attestation_policy.this.id
+}
diff --git a/workload/deployment/aws/infra/stack/connect/attestation-policy/outputs.tf b/workload/deployment/aws/infra/stack/connect/attestation-policy/outputs.tf
new file mode 100644
index 0000000..6ba5562
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/connect/attestation-policy/outputs.tf
@@ -0,0 +1,9 @@
+output "policy_id" {
+ description = "The ID of the attestation policy registered with Cofide Connect."
+ value = cofide_connect_attestation_policy.this.id
+}
+
+output "binding_id" {
+ description = "The ID of the attestation policy binding."
+ value = cofide_connect_ap_binding.this.id
+}
diff --git a/workload/deployment/aws/infra/stack/connect/attestation-policy/providers.tf b/workload/deployment/aws/infra/stack/connect/attestation-policy/providers.tf
new file mode 100644
index 0000000..1b5abba
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/connect/attestation-policy/providers.tf
@@ -0,0 +1,4 @@
+provider "cofide" {
+ connect_url = var.connect_url
+ # api_token is configured via the COFIDE_API_TOKEN environment variable.
+}
diff --git a/workload/deployment/aws/infra/stack/connect/attestation-policy/terragrunt.hcl b/workload/deployment/aws/infra/stack/connect/attestation-policy/terragrunt.hcl
new file mode 100644
index 0000000..fc90704
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/connect/attestation-policy/terragrunt.hcl
@@ -0,0 +1,53 @@
+include "root" {
+ path = find_in_parent_folders("root.hcl")
+ expose = true
+}
+
+dependency "trust_zone" {
+ config_path = "../trust-zone"
+
+ mock_outputs_allowed_terraform_commands = ["apply", "destroy", "plan", "validate"]
+ mock_outputs = {
+ trust_zone_id = "00000000-0000-0000-0000-000000000000"
+ }
+}
+
+dependency "cp_dns" {
+ config_path = "${get_repo_root()}/control-plane/deployment/aws/infra/stack/base/dns"
+
+ mock_outputs_allowed_terraform_commands = ["apply", "destroy", "plan", "validate"]
+ mock_outputs = {
+ zone_name = "example.cofide.dev"
+ }
+}
+
+locals {
+ default_config = {
+ policy_name = "connect-trust-zone-aws-reference-arch-svidstore"
+ }
+
+ unit_config_path = "${get_terragrunt_dir()}/common.local.hcl"
+ local_config = read_terragrunt_config(local.unit_config_path).locals
+ merged_config = merge(local.default_config, local.local_config)
+
+ # spiffe_id_path, parent_id_path, and selectors have no defaults — they must be set in common.local.hcl.
+ spiffe_id_path = local.merged_config.spiffe_id_path
+ parent_id_path = local.merged_config.parent_id_path
+ selectors = local.merged_config.selectors
+
+ user_connect_url = try(local.merged_config.connect_url, null)
+}
+
+terraform {
+ source = "."
+}
+
+inputs = {
+ connect_url = local.user_connect_url != null ? local.user_connect_url : "${dependency.cp_dns.outputs.zone_name}:443"
+
+ trust_zone_id = dependency.trust_zone.outputs.trust_zone_id
+ policy_name = local.merged_config.policy_name
+ spiffe_id_path = local.spiffe_id_path
+ parent_id_path = local.parent_id_path
+ selectors = local.selectors
+}
diff --git a/workload/deployment/aws/infra/stack/connect/attestation-policy/variables.tf b/workload/deployment/aws/infra/stack/connect/attestation-policy/variables.tf
new file mode 100644
index 0000000..9c7686e
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/connect/attestation-policy/variables.tf
@@ -0,0 +1,32 @@
+variable "connect_url" {
+ type = string
+ description = "Cofide Connect API gRPC address in host:port form (e.g. example.cofide.dev:443). The provider prepends the connect. subdomain. No scheme."
+}
+
+variable "trust_zone_id" {
+ type = string
+ description = "The ID of the trust zone to bind the attestation policy to."
+}
+
+variable "policy_name" {
+ type = string
+ description = "The name of the attestation policy."
+}
+
+variable "spiffe_id_path" {
+ type = string
+ description = "The SPIFFE ID path assigned to workloads matching this policy (e.g. ns/default/sa/my-app)."
+}
+
+variable "parent_id_path" {
+ type = string
+ description = "The SPIFFE ID path of the parent node agent for workloads matching this policy."
+}
+
+variable "selectors" {
+ type = list(object({
+ type = string
+ value = string
+ }))
+ description = "The list of SPIRE selectors for this attestation policy."
+}
diff --git a/workload/deployment/aws/infra/stack/connect/attestation-policy/versions.tf b/workload/deployment/aws/infra/stack/connect/attestation-policy/versions.tf
new file mode 100644
index 0000000..f33c310
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/connect/attestation-policy/versions.tf
@@ -0,0 +1,14 @@
+terraform {
+ required_version = ">= 1.10"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 6.0"
+ }
+ cofide = {
+ source = "cofide/cofide"
+ version = "~> 0.9"
+ }
+ }
+}
diff --git a/workload/deployment/aws/infra/stack/spire-server/README.md b/workload/deployment/aws/infra/stack/spire-server/README.md
index 3193735..e6e0de5 100644
--- a/workload/deployment/aws/infra/stack/spire-server/README.md
+++ b/workload/deployment/aws/infra/stack/spire-server/README.md
@@ -1,6 +1,6 @@
# SPIRE Server Infrastructure
-Terraform unit that provisions the AWS resources needed by the SPIRE server on the trust zone workload cluster. Apply this after the cluster unit and before deploying the SPIRE server onto Kubernetes.
+Terraform units that provision the AWS resources needed by the SPIRE server and agent on the trust zone workload cluster. Apply these after the cluster unit and before deploying the SPIRE server onto Kubernetes.
## Units
@@ -15,3 +15,13 @@ cd spire-server/iam-role && terragrunt apply
```
See `common.local.hcl.example` for available configuration overrides, including switching between Pod Identity and IRSA.
+
+### `agent-iam-role/`
+
+Creates the IAM role for the SPIRE agent with Secrets Manager permissions for the `aws_secretsmanager` SVIDStore plugin. The plugin tags every secret it manages with `spire-svid=true`; the policy uses that tag as a condition on all statements rather than a name prefix. `CreateSecret` requires the tag in the request; `DescribeSecret` uses `StringEqualsIfExists` so the plugin can check secret existence before creating; all other operations (`DeleteSecret`, `PutSecretValue`, `RestoreSecret`, `TagResource`) require the tag on the resource.
+
+```sh
+cd spire-server/agent-iam-role && terragrunt apply
+```
+
+See `common.local.hcl.example` for available configuration overrides, including switching between Pod Identity and IRSA.
diff --git a/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/.terraform.lock.hcl b/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/.terraform.lock.hcl
new file mode 100644
index 0000000..74f6409
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/.terraform.lock.hcl
@@ -0,0 +1,25 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "6.49.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:gW/1w7xNATTgTXKN9Du926VKZ84YgV6BLJDTPimMYkk=",
+ "zh:11a636bb415bf780f0ad300cab83d687aebdc51381112ae7b29862e0bee43017",
+ "zh:2d6c4bb861c073d9900a2afc39cc1e38492c6996653e53c7a2083b526fb10ae9",
+ "zh:49f7ee4a7488f3d31342c5e9dbb577c40e0847a0cab152a0082e9aeef45f5c0f",
+ "zh:561283c9c9bd36b9d09832e50769b941eb45c43c6ab031f27c8bf78256af4af1",
+ "zh:576bf944e66d097b29fc45b25a5bbc53e7d4e71a486e2cb126304cf77b51fe79",
+ "zh:6c6bf8860773c121b9ca22743f733feea943f890fa3aba8740a59579dea16fc4",
+ "zh:88b963a659e42daac7384a6abb99b3383f9f6c8abad5dedafcd443536b122b84",
+ "zh:947e9404235ca094e39e7f3f464f99436320a168e1607c550e581fbc70553d48",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:a9c5a37bd1e5e81e85ad3dc3141f1b453b96e9cb8e500bc15bfb9c488fc95dcc",
+ "zh:b7b8a028fb2eafb98c676f9438808318f7ff0ea76eba03a6653d0940848a31c7",
+ "zh:be29f31827d6d5567aaec26034e6cceb994460446778ba1d0a435fc7ccd8a9f5",
+ "zh:c24c60ecffe3a7d44c762e62a29a802aec604096715ad3110b7ca2207124eb3a",
+ "zh:e2a247c5c7815437969e87cdce1f27f323eea75b97ed53f7605fef05d6406071",
+ "zh:e590ce9aca3f4fd964f7bf908a24dc3bc88e0b03a8554ccc81b9edcc84690670",
+ ]
+}
diff --git a/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/common.local.hcl.example b/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/common.local.hcl.example
new file mode 100644
index 0000000..ace5c16
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/common.local.hcl.example
@@ -0,0 +1,21 @@
+# Optional overrides for the spire-server agent-iam-role unit. Copy to common.local.hcl and
+# uncomment the values you want to change. All fields are optional — omitting a field
+# uses the default defined in terragrunt.hcl or reads from the relevant unit output.
+
+locals {
+ # Optional: override the IAM role name.
+ # role_name = "my-spire-agent-role"
+
+ # Optional: switch to IRSA instead of EKS Pod Identity.
+ # Requires enable_irsa = true in the cluster unit and oidc_provider_arn to be set.
+ # iam_mode = "irsa"
+
+ # Optional: override the Kubernetes namespace and service account.
+ # namespace = "spire-system"
+ # service_account_name = "spire-agent"
+
+ # Optional: provide dependency values directly instead of reading from unit outputs.
+ # Use these when deploying against an existing cluster not provisioned by this stack.
+ # cluster_name = "my-cluster"
+ # oidc_provider_arn = "arn:aws:iam::012345678901:oidc-provider/..." # irsa only
+}
diff --git a/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/terragrunt.hcl b/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/terragrunt.hcl
new file mode 100644
index 0000000..0825933
--- /dev/null
+++ b/workload/deployment/aws/infra/stack/spire-server/agent-iam-role/terragrunt.hcl
@@ -0,0 +1,45 @@
+include "root" {
+ path = find_in_parent_folders("root.hcl")
+ expose = true
+}
+
+dependency "cluster" {
+ config_path = "../../eks-cluster/cluster"
+
+ mock_outputs_allowed_terraform_commands = ["apply", "destroy", "plan", "validate"]
+ mock_outputs = {
+ cluster_name = "mock-cluster"
+ oidc_provider_arn = "arn:aws:iam::012345678901:oidc-provider/oidc.eks.eu-west-2.amazonaws.com/id/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
+ }
+}
+
+locals {
+ default_config = {
+ role_name = "cofide-connect-trust-zone-aws-reference-arch-spire-agent"
+ iam_mode = "pod_identity"
+ namespace = "spire-system"
+ service_account_name = "spire-agent"
+ }
+
+ unit_config_path = "${get_terragrunt_dir()}/common.local.hcl"
+ has_local_config = fileexists(local.unit_config_path)
+ user_config = local.has_local_config ? read_terragrunt_config(local.unit_config_path).locals : {}
+ merged_config = merge(local.default_config, local.user_config)
+
+ user_cluster_name = try(local.merged_config.cluster_name, null)
+ user_oidc_provider_arn = try(local.merged_config.oidc_provider_arn, null)
+}
+
+terraform {
+ source = "${get_repo_root()}//control-plane/deployment/aws/infra/modules/spire/agent-iam-role"
+}
+
+inputs = {
+ role_name = local.merged_config.role_name
+ iam_mode = local.merged_config.iam_mode
+ namespace = local.merged_config.namespace
+ service_account_name = local.merged_config.service_account_name
+
+ cluster_name = local.user_cluster_name != null ? local.user_cluster_name : try(dependency.cluster.outputs.cluster_name, null)
+ oidc_provider_arn = local.user_oidc_provider_arn != null ? local.user_oidc_provider_arn : try(dependency.cluster.outputs.oidc_provider_arn, null)
+}
diff --git a/workload/deployment/aws/k8s/spire-server/spire/generate-local-values.sh b/workload/deployment/aws/k8s/spire-server/spire/generate-local-values.sh
index 7cd51d6..5d13c52 100755
--- a/workload/deployment/aws/k8s/spire-server/spire/generate-local-values.sh
+++ b/workload/deployment/aws/k8s/spire-server/spire/generate-local-values.sh
@@ -6,6 +6,7 @@
# Requires the following units to have been applied:
# infra/stack/eks-cluster/cluster/
# infra/stack/spire-server/iam-role/
+# infra/stack/spire-server/agent-iam-role/
# infra/stack/connect/trust-zone/
# control-plane/deployment/aws/infra/stack/base/dns/
# control-plane/deployment/aws/infra/stack/connect/bundle-distribution/
@@ -44,6 +45,10 @@ echo "Reading IAM role ARN from spire-server/iam-role..."
ROLE_ARN=$(terragrunt --working-dir "${STACK_DIR}/spire-server/iam-role" output -raw role_arn)
echo " role_arn: ${ROLE_ARN}"
+echo "Reading agent IAM role ARN from spire-server/agent-iam-role..."
+AGENT_ROLE_ARN=$(terragrunt --working-dir "${STACK_DIR}/spire-server/agent-iam-role" output -raw role_arn)
+echo " agent_role_arn: ${AGENT_ROLE_ARN}"
+
echo "Reading zone name from control-plane base/dns..."
CP_ZONE_NAME=$(terragrunt --working-dir "${CP_STACK_DIR}/base/dns" output -raw zone_name)
CONNECT_URL="${CP_ZONE_NAME}:443"
@@ -110,11 +115,24 @@ spire-server:
name: selfsigned
kind: ClusterIssuer
+spire-agent:
+ unsupportedBuiltInPlugins:
+ # Note: the key must be lowercase 'svidstore' (not 'svidStore') to match the chart's
+ # template check, which maps it to the 'SVIDStore' SPIRE plugin section.
+ svidstore:
+ aws_secretsmanager:
+ plugin_data:
+ region: ${REGION}
+
# Optional: uncomment to use IRSA instead of EKS Pod Identity.
# spire-server:
# serviceAccount:
# annotations:
# eks.amazonaws.com/role-arn: ${ROLE_ARN}
+# spire-agent:
+# serviceAccount:
+# annotations:
+# eks.amazonaws.com/role-arn: ${AGENT_ROLE_ARN}
EOF
echo "Written to ${OUTPUT}."
diff --git a/workload/deployment/aws/k8s/spire-server/spire/install.sh b/workload/deployment/aws/k8s/spire-server/spire/install.sh
index 6172c01..e39ea50 100755
--- a/workload/deployment/aws/k8s/spire-server/spire/install.sh
+++ b/workload/deployment/aws/k8s/spire-server/spire/install.sh
@@ -18,9 +18,15 @@ fi
helm repo add cofide https://charts.cofide.dev
helm repo update cofide
+OVERRIDE_VALUES_ARGS=()
+if [[ -f "${SCRIPT_DIR}/values.override.yaml" ]]; then
+ OVERRIDE_VALUES_ARGS=(-f "${SCRIPT_DIR}/values.override.yaml")
+fi
+
helm upgrade --install spire cofide/spire \
--version "${CHART_VERSION}" \
--namespace spire-mgmt \
--create-namespace \
-f "${SCRIPT_DIR}/values.yaml" \
- -f "${SCRIPT_DIR}/values.local.yaml"
+ -f "${SCRIPT_DIR}/values.local.yaml" \
+ "${OVERRIDE_VALUES_ARGS[@]}"
diff --git a/workload/deployment/aws/k8s/spire-server/spire/values.local.yaml.example b/workload/deployment/aws/k8s/spire-server/spire/values.local.yaml.example
index 848b759..edac2c5 100644
--- a/workload/deployment/aws/k8s/spire-server/spire/values.local.yaml.example
+++ b/workload/deployment/aws/k8s/spire-server/spire/values.local.yaml.example
@@ -57,9 +57,24 @@ spire-server:
name: selfsigned
kind: ClusterIssuer
+spire-agent:
+ unsupportedBuiltInPlugins:
+ # Note: the key must be lowercase 'svidstore' (not 'svidStore') to match the chart's
+ # template check, which maps it to the 'SVIDStore' SPIRE plugin section.
+ svidstore:
+ aws_secretsmanager:
+ plugin_data:
+ # [tf: eks-cluster/cluster: region]
+ region: eu-west-2
+
# Optional: uncomment to use IRSA instead of EKS Pod Identity.
# spire-server:
# serviceAccount:
# annotations:
# # [tf: spire-server/iam-role: role_arn]
# eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/cofide-connect-trust-zone-aws-reference-arch-spire-server
+# spire-agent:
+# serviceAccount:
+# annotations:
+# # [tf: spire-server/agent-iam-role: role_arn]
+# eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/cofide-connect-trust-zone-aws-reference-arch-spire-agent
diff --git a/workload/deployment/aws/k8s/spire-server/spire/values.override.yaml.example b/workload/deployment/aws/k8s/spire-server/spire/values.override.yaml.example
new file mode 100644
index 0000000..8e921e3
--- /dev/null
+++ b/workload/deployment/aws/k8s/spire-server/spire/values.override.yaml.example
@@ -0,0 +1,8 @@
+# Persistent overrides that survive regeneration of values.local.yaml.
+# Copy to values.override.yaml and fill in your values. This file is gitignored.
+# Merged last by install.sh, so it takes precedence over both values.yaml and values.local.yaml.
+
+# Override the image tag, e.g. for a custom build:
+# spire-server:
+# image:
+# tag: v0.0.0-my-custom-build