Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions infrastructure/aws/iam/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@

## Description

Creates an IRSA (IAM Roles for Service Accounts) role for a nullplatform agent on EKS, with an assume-role policy allowing the agent to assume a conventional permissions role and any additional roles
Creates an IRSA (IAM Roles for Service Accounts) role for a nullplatform agent on EKS, with an assume-role policy allowing the agent to assume the roles explicitly provided by the caller (via assume_role_arns and/or permissions_roles)

## Architecture

The module uses the terraform-aws-modules/iam//modules/iam-role-for-service-accounts module to create an aws_iam_role with an OIDC trust policy scoped to a specific Kubernetes namespace and service account. An aws_iam_policy (nullplatform_assume_role_policy) is created and attached to the agent role, granting sts:AssumeRole on a conventionally named permissions role ARN, any extra permissions roles, and any caller-supplied assume_role_arns. Optionally, one or more aws_iam_role resources (extra_permissions) are created via for_each from var.permissions_roles, each trusting only the agent role ARN, with aws_iam_role_policy_attachment resources wiring the provided policy ARNs to each extra role.
The module uses the terraform-aws-modules/iam//modules/iam-role-for-service-accounts module to create an aws_iam_role with an OIDC trust policy scoped to a specific Kubernetes namespace and service account. An aws_iam_policy (nullplatform_assume_role_policy) is created and attached to the agent role, granting sts:AssumeRole only on the roles the caller supplies: any extra permissions roles created via var.permissions_roles plus any caller-supplied var.assume_role_arns. The module no longer injects any permissions role by naming convention; a lifecycle precondition requires at least one role to be provided. Optionally, one or more aws_iam_role resources (extra_permissions) are created via for_each from var.permissions_roles, each trusting only the agent role ARN, with aws_iam_role_policy_attachment resources wiring the provided policy ARNs to each extra role.

## Features

- Creates IRSA-enabled aws_iam_role scoped to a specific Kubernetes namespace and service account via OIDC provider trust
- Creates aws_iam_policy granting sts:AssumeRole on a conventional permissions role ARN and any additional supplied role ARNs
- Creates aws_iam_policy granting sts:AssumeRole only on the role ARNs explicitly supplied by the caller (no implicit permissions role by convention)
- Requires at least one assumable role (via assume_role_arns or permissions_roles) through a lifecycle precondition
- Creates optional extra aws_iam_role resources (permissions_roles) trusted exclusively by the agent role with configurable policy attachments
- Attaches additional caller-supplied policy ARNs directly to the agent role via var.additional_policies
- Outputs the agent role ARN, the conventional permissions role ARN, and a map of extra permissions role ARNs for downstream consumption
- Outputs the agent role ARN and a map of extra permissions role ARNs for downstream consumption
- Derives deterministic role ARNs from account ID and name locals to avoid circular dependencies between role trust and assume policies

## Basic Usage
Expand All @@ -26,6 +27,12 @@ module "agent" {
agent_namespace = "your-agent-namespace"
aws_iam_openid_connect_provider_arn = "your-aws-iam-openid-connect-provider-arn"
cluster_name = "your-cluster-name"

# The agent no longer assumes any role by convention. Pass every role it must
# assume explicitly, e.g. the k8s permissions role created by the k8s scope module:
assume_role_arns = [
"arn:aws:iam::123456789012:role/nullplatform-your-cluster-name-agent-permissions-role",
]
}
```

Expand Down Expand Up @@ -70,7 +77,6 @@ resource "example_resource" "this" {
| <a name="input_assume_role_arns"></a> [assume\_role\_arns](#input\_assume\_role\_arns) | List of IAM role ARNs the agent is allowed to assume via sts:AssumeRole | `list(string)` | `[]` | no |
| <a name="input_aws_iam_openid_connect_provider_arn"></a> [aws\_iam\_openid\_connect\_provider\_arn](#input\_aws\_iam\_openid\_connect\_provider\_arn) | ARN of the AWS IAM OIDC provider for EKS service account authentication | `string` | n/a | yes |
| <a name="input_cluster_name"></a> [cluster\_name](#input\_cluster\_name) | Name of the cluster where the policy runs | `string` | n/a | yes |
| <a name="input_permissions_role_name"></a> [permissions\_role\_name](#input\_permissions\_role\_name) | Override for the permissions IAM role name. Defaults to nullplatform-{cluster\_name}-agent-permissions-role | `string` | `""` | no |
| <a name="input_permissions_roles"></a> [permissions\_roles](#input\_permissions\_roles) | Additional permissions roles created by this module and assumable by the agent role. Map key is a logical name; name overrides the role name (defaults to nullplatform-{cluster\_name}-{key}); policy\_arns are the policy ARNs attached to the role. | <pre>map(object({<br/> name = optional(string)<br/> policy_arns = optional(list(string), [])<br/> }))</pre> | `{}` | no |
| <a name="input_policies_name_prefix"></a> [policies\_name\_prefix](#input\_policies\_name\_prefix) | Override for IAM policy name prefix. Defaults to nullplatform\_{cluster\_name} | `string` | `""` | no |
| <a name="input_role_name"></a> [role\_name](#input\_role\_name) | Override for the IAM role name. Defaults to nullplatform-{cluster\_name}-agent-role | `string` | `""` | no |
Expand All @@ -81,21 +87,21 @@ resource "example_resource" "this" {
| Name | Description |
|------|-------------|
| <a name="output_nullplatform_agent_extra_permissions_role_arns"></a> [nullplatform\_agent\_extra\_permissions\_role\_arns](#output\_nullplatform\_agent\_extra\_permissions\_role\_arns) | Map of logical name to ARN for each additional permissions role created via permissions\_roles |
| <a name="output_nullplatform_agent_permissions_role_arn"></a> [nullplatform\_agent\_permissions\_role\_arn](#output\_nullplatform\_agent\_permissions\_role\_arn) | Conventional ARN of the permissions role the agent role is allowed to assume. The role itself is created externally (k8s scope tofu module), not by this module. |
| <a name="output_nullplatform_agent_role_arn"></a> [nullplatform\_agent\_role\_arn](#output\_nullplatform\_agent\_role\_arn) | ARN of the agent role |
<!-- END_TF_DOCS -->

<!-- BEGIN_AI_METADATA
{
"name": "agent",
"description": "Creates an IRSA (IAM Roles for Service Accounts) role for a nullplatform agent on EKS, with an assume-role policy allowing the agent to assume a conventional permissions role and any additional roles",
"architecture": "The module uses the terraform-aws-modules/iam//modules/iam-role-for-service-accounts module to create an aws_iam_role with an OIDC trust policy scoped to a specific Kubernetes namespace and service account. An aws_iam_policy (nullplatform_assume_role_policy) is created and attached to the agent role, granting sts:AssumeRole on a conventionally named permissions role ARN, any extra permissions roles, and any caller-supplied assume_role_arns. Optionally, one or more aws_iam_role resources (extra_permissions) are created via for_each from var.permissions_roles, each trusting only the agent role ARN, with aws_iam_role_policy_attachment resources wiring the provided policy ARNs to each extra role.",
"description": "Creates an IRSA (IAM Roles for Service Accounts) role for a nullplatform agent on EKS, with an assume-role policy allowing the agent to assume the roles explicitly provided by the caller (via assume_role_arns and/or permissions_roles)",
"architecture": "The module uses the terraform-aws-modules/iam//modules/iam-role-for-service-accounts module to create an aws_iam_role with an OIDC trust policy scoped to a specific Kubernetes namespace and service account. An aws_iam_policy (nullplatform_assume_role_policy) is created and attached to the agent role, granting sts:AssumeRole only on the roles the caller supplies: any extra permissions roles created via var.permissions_roles plus any caller-supplied var.assume_role_arns. The module no longer injects any permissions role by naming convention; a lifecycle precondition requires at least one role to be provided. Optionally, one or more aws_iam_role resources (extra_permissions) are created via for_each from var.permissions_roles, each trusting only the agent role ARN, with aws_iam_role_policy_attachment resources wiring the provided policy ARNs to each extra role.",
"features": [
"Creates IRSA-enabled aws_iam_role scoped to a specific Kubernetes namespace and service account via OIDC provider trust",
"Creates aws_iam_policy granting sts:AssumeRole on a conventional permissions role ARN and any additional supplied role ARNs",
"Creates aws_iam_policy granting sts:AssumeRole only on the role ARNs explicitly supplied by the caller (no implicit permissions role by convention)",
"Requires at least one assumable role (via assume_role_arns or permissions_roles) through a lifecycle precondition",
"Creates optional extra aws_iam_role resources (permissions_roles) trusted exclusively by the agent role with configurable policy attachments",
"Attaches additional caller-supplied policy ARNs directly to the agent role via var.additional_policies",
"Outputs the agent role ARN, the conventional permissions role ARN, and a map of extra permissions role ARNs for downstream consumption",
"Outputs the agent role ARN and a map of extra permissions role ARNs for downstream consumption",
"Derives deterministic role ARNs from account ID and name locals to avoid circular dependencies between role trust and assume policies"
],
"inputs": [
Expand Down Expand Up @@ -139,11 +145,6 @@ resource "example_resource" "this" {
"description": "Override for the IAM role name. Defaults to nullplatform-{cluster_name}-agent-role",
"required": false
},
{
"name": "permissions_role_name",
"description": "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-agent-permissions-role",
"required": false
},
{
"name": "policies_name_prefix",
"description": "Override for IAM policy name prefix. Defaults to nullplatform_{cluster_name}",
Expand All @@ -152,7 +153,6 @@ resource "example_resource" "this" {
],
"outputs": [
"nullplatform_agent_role_arn",
"nullplatform_agent_permissions_role_arn",
"nullplatform_agent_extra_permissions_role_arns"
],
"hash": "080cc2f1402698f5884c98e39f0ef01a"
Expand Down
42 changes: 25 additions & 17 deletions infrastructure/aws/iam/agent/main.tf
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
locals {
role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-agent-role"
permissions_role_name = var.permissions_role_name != "" ? var.permissions_role_name : "nullplatform-${var.cluster_name}-agent-permissions-role"
policies_name_prefix = var.policies_name_prefix != "" ? var.policies_name_prefix : "nullplatform_${var.cluster_name}"
role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-agent-role"
policies_name_prefix = var.policies_name_prefix != "" ? var.policies_name_prefix : "nullplatform_${var.cluster_name}"

# ARNs built from names + account id to avoid a circular dependency between
# the agent role (assume policy -> permissions role) and the permissions role
# (trust policy -> agent role).
agent_role_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.role_name}"
permissions_role_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.permissions_role_name}"
# ARN built from name + account id to avoid a circular dependency between the
# agent role (extra permissions trust) and the extra permissions roles (trust
# policy -> agent role).
agent_role_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.role_name}"

# Resolve the name and (computed) ARN of each extra permissions role up front,
# so both the agent assume policy and the role trust policies reference the
Expand Down Expand Up @@ -63,12 +61,13 @@ module "nullplatform_agent_role" {
}

# NOTE: The permissions role (nullplatform_agent_permissions) and its workload
# policies (Route53, EKS, ELB, AVP) are no longer created by this module. They
# are now provisioned per-cluster by the k8s scope's OpenTofu module
# (scopes: k8s/scope/tofu/iam/modules). This module keeps only the agent IRSA
# role and an assume policy that authorizes assuming that externally-created
# permissions role by its conventional ARN (see nullplatform_assume_role_policy
# and local.permissions_role_arn).
# policies (Route53, EKS, ELB, AVP) are not created by this module. They are
# provisioned per-cluster by the k8s scope's OpenTofu module (scopes:
# k8s/scope/tofu/iam/modules). This module keeps only the agent IRSA role and an
# assume policy. The agent role is NOT granted permission to assume that
# externally-created permissions role by convention: callers must pass every
# role the agent should assume explicitly via var.assume_role_arns (see
# nullplatform_assume_role_policy).

################################################################################
# Additional permissions roles assumed by the agent role
Expand Down Expand Up @@ -106,23 +105,32 @@ resource "aws_iam_role_policy_attachment" "extra_permissions" {

resource "aws_iam_policy" "nullplatform_assume_role_policy" {
name = "${local.policies_name_prefix}_assume_role_policy"
description = "Policy allowing the agent to assume the permissions role and any additional roles"
description = "Policy allowing the agent to assume the roles passed via permissions_roles and assume_role_arns"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Resource = concat(
[local.permissions_role_arn],
local.extra_permissions_role_arns,
var.assume_role_arns
)
}]
})

# The agent role no longer assumes any role by convention: at least one role
# must be provided explicitly (via permissions_roles or assume_role_arns),
# otherwise the policy would render an empty Resource and AWS would reject it.
lifecycle {
precondition {
condition = length(concat(local.extra_permissions_role_arns, var.assume_role_arns)) > 0
error_message = "The agent role must be allowed to assume at least one role: pass it via var.assume_role_arns (e.g. the k8s permissions role ARN) or define var.permissions_roles."
}
}
}

# The assume role policy used to be conditional (count). It is now always
# created because the agent role must be able to assume the permissions role.
# created because the agent role holds its sts:AssumeRole grants here.
moved {
from = aws_iam_policy.nullplatform_assume_role_policy[0]
to = aws_iam_policy.nullplatform_assume_role_policy
Expand Down
5 changes: 0 additions & 5 deletions infrastructure/aws/iam/agent/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@ output "nullplatform_agent_role_arn" {
value = module.nullplatform_agent_role.arn
}

output "nullplatform_agent_permissions_role_arn" {
description = "Conventional ARN of the permissions role the agent role is allowed to assume. The role itself is created externally (k8s scope tofu module), not by this module."
value = local.permissions_role_arn
}

output "nullplatform_agent_extra_permissions_role_arns" {
description = "Map of logical name to ARN for each additional permissions role created via permissions_roles"
value = { for k, r in aws_iam_role.extra_permissions : k => r.arn }
Expand Down
50 changes: 29 additions & 21 deletions infrastructure/aws/iam/agent/tests/agent.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ variables {
aws_iam_openid_connect_provider_arn = "arn:aws:iam::123456789012:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE"
}

run "assume_role_policy_always_created_and_targets_permissions_role" {
run "assume_role_policy_targets_only_provided_arns" {
command = plan

variables {
assume_role_arns = ["arn:aws:iam::123456789012:role/some-role"]
}

assert {
condition = aws_iam_policy.nullplatform_assume_role_policy.name == "nullplatform_test-cluster_assume_role_policy"
error_message = "assume_role policy name should follow naming convention"
Expand All @@ -36,39 +40,35 @@ run "assume_role_policy_always_created_and_targets_permissions_role" {
assert {
condition = contains(
jsondecode(aws_iam_policy.nullplatform_assume_role_policy.policy).Statement[0].Resource,
"arn:aws:iam::123456789012:role/nullplatform-test-cluster-agent-permissions-role"
"arn:aws:iam::123456789012:role/some-role"
)
error_message = "assume_role policy should allow assuming the permissions role by default"
}
}

run "assume_role_policy_includes_additional_arns" {
command = plan

variables {
assume_role_arns = ["arn:aws:iam::123456789012:role/some-role"]
error_message = "assume_role policy should include the provided assume_role_arns"
}

assert {
condition = contains(
condition = !contains(
jsondecode(aws_iam_policy.nullplatform_assume_role_policy.policy).Statement[0].Resource,
"arn:aws:iam::123456789012:role/nullplatform-test-cluster-agent-permissions-role"
)
error_message = "assume_role policy should still allow assuming the permissions role"
error_message = "assume_role policy must NOT inject the permissions role by convention anymore"
}
}

assert {
condition = contains(
jsondecode(aws_iam_policy.nullplatform_assume_role_policy.policy).Statement[0].Resource,
"arn:aws:iam::123456789012:role/some-role"
)
error_message = "assume_role policy should include additional assume_role_arns"
}
run "assume_role_policy_fails_without_any_role" {
command = plan

expect_failures = [
aws_iam_policy.nullplatform_assume_role_policy,
]
}

run "extra_permissions_roles_not_created_by_default" {
command = plan

variables {
assume_role_arns = ["arn:aws:iam::123456789012:role/some-role"]
}

assert {
condition = length(aws_iam_role.extra_permissions) == 0
error_message = "No extra permissions roles should be created when permissions_roles is empty"
Expand Down Expand Up @@ -101,7 +101,7 @@ run "extra_permissions_roles_created_and_assumable" {
}

assert {
condition = jsondecode(aws_iam_role.extra_permissions["data"].assume_role_policy).Statement[0].Principal.AWS == "arn:aws:iam::123456789012:role/nullplatform-test-cluster-agent-role"
condition = jsondecode(aws_iam_role.extra_permissions["data"].assume_role_policy).Statement[0].Principal.AWS == "arn:aws:iam::123456789012:role/nullplatform-test-cluster-agent-role"
error_message = "Extra role trust policy should allow the agent role to assume it"
}

Expand All @@ -125,4 +125,12 @@ run "extra_permissions_roles_created_and_assumable" {
)
error_message = "agent assume_role policy should include the ops extra role"
}

assert {
condition = !contains(
jsondecode(aws_iam_policy.nullplatform_assume_role_policy.policy).Statement[0].Resource,
"arn:aws:iam::123456789012:role/nullplatform-test-cluster-agent-permissions-role"
)
error_message = "permissions_roles alone must not re-introduce the convention permissions role ARN"
}
}
6 changes: 0 additions & 6 deletions infrastructure/aws/iam/agent/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@ variable "role_name" {
default = ""
}

variable "permissions_role_name" {
description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-agent-permissions-role"
type = string
default = ""
}

variable "permissions_roles" {
description = "Additional permissions roles created by this module and assumable by the agent role. Map key is a logical name; name overrides the role name (defaults to nullplatform-{cluster_name}-{key}); policy_arns are the policy ARNs attached to the role."
type = map(object({
Expand Down