Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,12 @@ dist
# Intellij

.idea

# Local nullplatform API credentials — never commit
np-api-skill.*
*.key
*.token
*.pem

# Claude Code local config
.claude/
80 changes: 74 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ The service lives under [`aws-s3-bucket/`](./aws-s3-bucket) to keep the repo ope
│ ├── permissions/ # OpenTofu module: IAM user + access key + scoped policy (per link)
│ ├── requirements/ # OpenTofu module: IAM policies the agent role needs
│ ├── workflows/aws/ # Workflow YAMLs (create/update/delete/link/link-update/unlink)
│ ├── scripts/aws/ # build_context, do_tofu, write_service_outputs, write_link_outputs, delete_tfstate_bucket
│ ├── scripts/aws/ # build_context, do_tofu, write_service_outputs, write_link_outputs, delete_tfstate_bucket, assume_role, assume_role_lib (+ test/)
│ ├── entrypoint/ # entrypoint/service/link (agent entrypoint)
│ └── values.yaml # Static config (aws_profile for local dev)
│ └── values.yaml # Static config (aws_profile, assume_role_selector, assume_role_arn)
└── README.md
```

Expand Down Expand Up @@ -78,6 +78,70 @@ Only credentials are exposed at the link level — bucket identity (name / ARN /
| `link-update` | Link updated | In-place update of the IAM user policy (access_level or path_prefix changes). Credentials are preserved. |
| `unlink` | Application unlinked | Destroys the IAM user and access key |

## Agent AWS Authentication

The agent resolves the IAM role ARN to assume using the following **order of precedence**, then either assumes that role or falls back to IRSA:

1. `ASSUME_ROLE_ARN` already set in the environment (explicit override).
2. The **"AWS IAM" provider** (category *Identity & Access Control*) declared in nullplatform — matched by selector. *(preferred)*
3. `assume_role_arn` in `values.yaml` (static fallback / local testing).
4. None of the above → the pod's **IRSA** identity is used directly.

### Provider-based assume role (preferred)

Declare an **"AWS IAM" provider** (specification `aws-iam-configuration`) at the account level in nullplatform. Its `iam_role_arns.arns` is a list of `{selector, arn}` pairs, so a single provider holds the role ARNs for every service/scope in the account:

| selector | arn |
|---|---|
| `aws-s3-bucket` | `arn:aws:iam::<account-id>:role/<s3-role>` |
| `lambda` | `arn:aws:iam::<account-id>:role/<lambda-role>` |

The agent looks the provider up at the account NRN, then picks the ARN whose `selector` matches `assume_role_selector` from `values.yaml` (default: the service slug, `aws-s3-bucket`). It then calls `sts:AssumeRole` with its IRSA identity and uses the resulting temporary credentials for all subsequent AWS calls (CLI + Terraform).

```yaml
# values.yaml — selector to match in the IAM provider (empty -> service slug)
assume_role_selector: "aws-s3-bucket"
```

This is the right choice when the bucket lives in a **different account** than the agent (cross-account) or you want a **dedicated role per service type** rather than granting all permissions to the agent's base role — without committing any account-specific ARN to the repo.

### IRSA (default)

With no provider entry for the selector and `assume_role_arn` empty, the agent pod's IRSA role is used directly for all AWS calls. This is the right choice when the IRSA role already has the required S3 and IAM permissions in the target account.

### Static `assume_role_arn` (fallback)

For local testing or single-account back-compat, set `assume_role_arn` in `values.yaml` directly. It is used only when the provider yields no ARN for the selector.

```yaml
# values.yaml
assume_role_arn: "arn:aws:iam::123456789012:role/np-s3-creator-role"
```

### Trust policy

However the ARN is resolved, the target role must have a trust policy that allows the agent's IRSA role to assume it:

```json
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::<AGENT_ACCOUNT>:role/<IRSA_ROLE_NAME>"
},
"Action": "sts:AssumeRole"
}
```

The target role needs the same IAM permissions as listed in the [AWS IAM permissions](#aws-iam-permissions-for-the-agent-role) section below.

Once an ARN is resolved (from the provider or `assume_role_arn`), a failing `sts:AssumeRole` call (wrong ARN, missing trust policy, insufficient permissions) makes the workflow **abort immediately** — it does not fall back to the IRSA credentials. The IRSA fallback only applies when no ARN is resolved at all.

#### Credential isolation before assume role

Link actions (`link` / `link-update` / `unlink`) run `build_permissions_context`, which **unsets any AWS credentials inherited from earlier steps** (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`) before sourcing `assume_role`. This is deliberate: without it, `sts:AssumeRole` would be called using *already-assumed* temporary credentials instead of the pod's IRSA identity, which fails as a self-assume (the assumed role is usually not trusted to assume itself). Clearing the environment first guarantees the assume-role call always starts from the IRSA identity, whether or not a previous step had already assumed the role.

> **Note for overrides:** `values.yaml` ships with `assume_role_arn` empty so the published service stays account-agnostic. Prefer declaring the per-account ARNs in the **"AWS IAM" provider** (account-specific data stays in nullplatform, not the repo). The static `assume_role_arn` remains available per deployment via the `--overrides-path` mechanism for local testing or environments without the provider.

## Requirements

### nullplatform prerequisites
Expand All @@ -86,11 +150,15 @@ Only credentials are exposed at the link level — bucket identity (name / ARN /

### AWS IAM permissions (for the agent role)

The agent executing this service needs the IAM policies defined in [`aws-s3-bucket/requirements/main.tf`](./aws-s3-bucket/requirements/main.tf):
The agent executing this service (its IRSA role, or the `assume_role_arn` target) needs the three IAM policies defined in [`aws-s3-bucket/requirements/main.tf`](./aws-s3-bucket/requirements/main.tf). The `requirements/` module can also create the role itself (`create_role = true`) with a trust policy for `trusted_arns`.

| Policy | Purpose | Resource scope |
|---|---|---|
| `nullplatform_<name>_s3_policy` | Create / configure / delete the user-facing S3 buckets | `*` |
| `nullplatform_<name>_s3_iam_policy` | Manage the per-link IAM users + access keys | `arn:aws:iam::*:user/np-s3-*` |
| `nullplatform_<name>_s3_tfstate_policy` | Manage the per-service OpenTofu state buckets | `arn:aws:s3:::np-service-*` (+ `/*`) |

- S3 bucket management (`s3:CreateBucket`, `s3:PutBucketVersioning`, etc.) over `*`
- IAM user management (`iam:CreateUser`, `iam:CreateAccessKey`, `iam:PutUserPolicy`, etc.) over `arn:aws:iam::*:user/np-s3-*` (or `*` for simpler scope)
- S3 tfstate management over `arn:aws:s3:::np-service-*`
**Why so many `s3:Get*` read actions?** The AWS Terraform provider refreshes the *full* configuration of every managed bucket on each `plan`/`apply` (versioning, encryption, replication, public-access block, ACL, ownership, logging, lifecycle, CORS, website, etc.). Each of those reads maps to a distinct IAM action — e.g. `s3:GetEncryptionConfiguration`, `s3:GetReplicationConfiguration`, `s3:GetBucketPublicAccessBlock`. Missing any one of them makes the provider fail the refresh even when the bucket itself is fine, so the bucket-management policy grants the complete read set alongside the `Create`/`Put`/`Delete` actions.

### Runtime dependencies

Expand Down
65 changes: 51 additions & 14 deletions aws-s3-bucket/requirements/main.tf
Original file line number Diff line number Diff line change
@@ -1,22 +1,47 @@
################################################################################
# Policy attachments (only when role_name is provided)
# IAM role (only when create_role = true)
################################################################################

resource "aws_iam_role" "nullplatform_s3_role" {
count = var.create_role ? 1 : 0
name = "nullplatform_${var.name}_s3_role"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = { AWS = var.trusted_arns }
Action = "sts:AssumeRole"
}
]
})
}

################################################################################
# Policy attachments (when create_role = true or role_name is provided)
################################################################################

locals {
effective_role_name = var.create_role ? aws_iam_role.nullplatform_s3_role[0].name : var.role_name
attach_policies = var.create_role || var.role_name != null
}

resource "aws_iam_role_policy_attachment" "s3" {
count = var.role_name != null ? 1 : 0
role = var.role_name
count = local.attach_policies ? 1 : 0
role = local.effective_role_name
policy_arn = aws_iam_policy.nullplatform_s3_policy.arn
}

resource "aws_iam_role_policy_attachment" "s3_iam" {
count = var.role_name != null ? 1 : 0
role = var.role_name
count = local.attach_policies ? 1 : 0
role = local.effective_role_name
policy_arn = aws_iam_policy.nullplatform_s3_iam_policy.arn
}

resource "aws_iam_role_policy_attachment" "s3_tfstate" {
count = var.role_name != null ? 1 : 0
role = var.role_name
count = local.attach_policies ? 1 : 0
role = local.effective_role_name
policy_arn = aws_iam_policy.nullplatform_s3_tfstate_policy.arn
}

Expand All @@ -37,26 +62,38 @@ resource "aws_iam_policy" "nullplatform_s3_policy" {
"Action" : [
"s3:CreateBucket",
"s3:DeleteBucket",
"s3:HeadBucket",
"s3:ListBucket",
"s3:ListBucketVersions",
"s3:ListAllMyBuckets",
"s3:GetBucketLocation",
"s3:GetBucketVersioning",
"s3:GetBucketEncryption",
"s3:GetEncryptionConfiguration",
"s3:GetBucketPublicAccessBlock",
"s3:GetBucketPolicy",
"s3:GetBucketTagging",
"s3:GetBucketAcl",
"s3:GetBucketOwnershipControls",
"s3:GetBucketLogging",
"s3:GetBucketNotification",
"s3:GetBucketObjectLockConfiguration",
"s3:GetReplicationConfiguration",
"s3:GetBucketRequestPayment",
"s3:GetBucketWebsite",
"s3:GetBucketCORS",
"s3:GetLifecycleConfiguration",
"s3:GetAccelerateConfiguration",
"s3:PutBucketVersioning",
"s3:PutBucketEncryption",
"s3:PutEncryptionConfiguration",
"s3:PutBucketPublicAccessBlock",
"s3:PutBucketPolicy",
"s3:PutBucketTagging",
"s3:PutBucketOwnershipControls",
"s3:DeleteBucketPolicy",
"s3:HeadBucket",
"s3:ListBucket",
"s3:ListBucketVersions",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:ListAllMyBuckets"
"s3:DeleteObjectVersion"
],
"Resource" : "*"
}
Expand Down
10 changes: 10 additions & 0 deletions aws-s3-bucket/requirements/output.tf
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ output "s3_tfstate_policy_arn" {
description = "ARN of the tfstate bucket management policy"
value = aws_iam_policy.nullplatform_s3_tfstate_policy.arn
}

output "role_arn" {
description = "ARN of the IAM role created by this module. Empty string when create_role is false."
value = var.create_role ? aws_iam_role.nullplatform_s3_role[0].arn : ""
}

output "role_name" {
description = "Name of the IAM role created by this module. Empty string when create_role is false."
value = var.create_role ? aws_iam_role.nullplatform_s3_role[0].name : ""
}
14 changes: 13 additions & 1 deletion aws-s3-bucket/requirements/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,19 @@ variable "name" {
}

variable "role_name" {
description = "IAM role name to attach the S3 service policies to. If set, Terraform manages the attachments and will detach them automatically on destroy."
description = "IAM role name to attach the S3 service policies to. If set, Terraform manages the attachments and will detach them automatically on destroy. Ignored when create_role is true."
type = string
default = null
}

variable "create_role" {
description = "When true, creates a new IAM role for the S3 service and attaches all policies to it. The role will allow the ARNs in trusted_arns to assume it via sts:AssumeRole."
type = bool
default = false
}

variable "trusted_arns" {
description = "List of IAM principal ARNs (roles, users, accounts) allowed to assume the role created by this module. Only used when create_role is true."
type = list(string)
default = []
}
51 changes: 51 additions & 0 deletions aws-s3-bucket/scripts/aws/assume_role
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/bin/bash
# Sourceable helper — do NOT execute directly.
# Resolves the IAM role ARN to assume, in this order of precedence:
# 1. $ASSUME_ROLE_ARN already set in the environment (explicit override).
# 2. The "AWS IAM" provider (Identity & Access Control) in nullplatform,
# matched by $ASSUME_ROLE_SELECTOR at $ASSUME_ROLE_NRN. This is the
# declarative, per-service/scope source.
# 3. assume_role_arn from $VALUES (values.yaml) — local testing / back-compat.
# 4. None of the above -> empty -> use pod credentials (IRSA) directly.
#
# When an ARN is resolved, calls sts:AssumeRole and exports temporary
# credentials so all subsequent AWS calls (CLI + Terraform) run as that role.

# shellcheck source=assume_role_lib
. "$(dirname "${BASH_SOURCE[0]}")/assume_role_lib"

ASSUME_ROLE_ARN="${ASSUME_ROLE_ARN:-}"

# 2. nullplatform IAM provider, by selector.
if [ -z "$ASSUME_ROLE_ARN" ] && [ -n "${ASSUME_ROLE_SELECTOR:-}" ] && [ -n "${ASSUME_ROLE_NRN:-}" ]; then
ASSUME_ROLE_ARN=$(provider_arn_for_selector "$ASSUME_ROLE_NRN" "$ASSUME_ROLE_SELECTOR")
if [ -n "$ASSUME_ROLE_ARN" ]; then
echo "Resolved assume_role_arn from provider (selector=${ASSUME_ROLE_SELECTOR}): ${ASSUME_ROLE_ARN}"
else
echo "No ARN for selector '${ASSUME_ROLE_SELECTOR}' in IAM provider at ${ASSUME_ROLE_NRN}; trying values.yaml."
fi
fi

# 3. Static values.yaml fallback.
if [ -z "$ASSUME_ROLE_ARN" ]; then
ASSUME_ROLE_ARN=$(grep "^assume_role_arn:" "${VALUES:-/dev/null}" 2>/dev/null \
| sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1)
fi

# 4. Assume the role, or fall back to IRSA.
if [ -n "$ASSUME_ROLE_ARN" ]; then
echo "Assuming role: $ASSUME_ROLE_ARN"
ASSUMED_CREDS=$(aws sts assume-role \
--role-arn "$ASSUME_ROLE_ARN" \
--role-session-name "np-s3-${SERVICE_ID:-workflow}" \
--output json)
export AWS_ACCESS_KEY_ID=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.AccessKeyId')
export AWS_SECRET_ACCESS_KEY=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SecretAccessKey')
export AWS_SESSION_TOKEN=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SessionToken')
echo "Role assumed successfully."
else
echo "No assume role ARN resolved, using pod credentials (IRSA)."
export AWS_ACCESS_KEY_ID=""
export AWS_SECRET_ACCESS_KEY=""
export AWS_SESSION_TOKEN=""
fi
42 changes: 42 additions & 0 deletions aws-s3-bucket/scripts/aws/assume_role_lib
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/bin/bash
# Sourceable library of PURE helpers for assume-role resolution.
# Defines functions only — NO side effects on source, so it can be unit-tested
# (see scripts/aws/test/assume_role_lib_test) and reused by assume_role.

# arn_for_selector_from_json <provider_read_json> <selector>
# Given the JSON returned by `np provider read --id <id> --format json` and a
# selector string, echoes the matching IAM role ARN, or empty string if there
# is no match / the input is missing or malformed. First match wins.
arn_for_selector_from_json() {
local json="$1" selector="$2"
[ -n "$json" ] || return 0
[ -n "$selector" ] || return 0
printf '%s' "$json" | jq -r --arg sel "$selector" '
[ .attributes.iam_role_arns.arns[]?
| select(.selector == $sel)
| .arn ]
| first // ""' 2>/dev/null
}

# provider_arn_for_selector <nrn> <selector>
# Looks up the "AWS IAM" provider (specification aws-iam-configuration, category
# "Identity & Access Control") at <nrn>, reads it, and echoes the ARN matching
# <selector>. Empty string if no provider / no match. Requires np + jq.
# NOTE: `np provider list` does NOT return deep attributes, so we list to get the
# provider id and then `np provider read --id` to obtain the arns (same two-step
# pattern used for account.region resolution in build_context).
provider_arn_for_selector() {
local nrn="$1" selector="$2"
[ -n "$nrn" ] || return 0
[ -n "$selector" ] || return 0

local pid data
pid=$(np provider list --nrn "$nrn" \
--specification_slug aws-iam-configuration \
--format json --limit 100 2>/dev/null \
| jq -r '[ (.results // [])[] ] | first | .id // ""' 2>/dev/null)
[ -n "$pid" ] && [ "$pid" != "null" ] || return 0

data=$(np provider read --id "$pid" --format json 2>/dev/null)
arn_for_selector_from_json "$data" "$selector"
}
Loading