diff --git a/.gitignore b/.gitignore index c1ae460..cc05805 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,12 @@ dist # Intellij .idea + +# Local nullplatform API credentials — never commit +np-api-skill.* +*.key +*.token +*.pem + +# Claude Code local config +.claude/ diff --git a/README.md b/README.md index 918ad5a..02f87b5 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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:::role/` | +| `lambda` | `arn:aws:iam:::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:::role/" + }, + "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 @@ -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__s3_policy` | Create / configure / delete the user-facing S3 buckets | `*` | +| `nullplatform__s3_iam_policy` | Manage the per-link IAM users + access keys | `arn:aws:iam::*:user/np-s3-*` | +| `nullplatform__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 diff --git a/aws-s3-bucket/requirements/main.tf b/aws-s3-bucket/requirements/main.tf index c22da2a..2bc072e 100644 --- a/aws-s3-bucket/requirements/main.tf +++ b/aws-s3-bucket/requirements/main.tf @@ -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 } @@ -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" : "*" } diff --git a/aws-s3-bucket/requirements/output.tf b/aws-s3-bucket/requirements/output.tf index ed628df..b9d8c8b 100644 --- a/aws-s3-bucket/requirements/output.tf +++ b/aws-s3-bucket/requirements/output.tf @@ -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 : "" +} diff --git a/aws-s3-bucket/requirements/variables.tf b/aws-s3-bucket/requirements/variables.tf index 4c72b77..67eebb8 100644 --- a/aws-s3-bucket/requirements/variables.tf +++ b/aws-s3-bucket/requirements/variables.tf @@ -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 = [] +} diff --git a/aws-s3-bucket/scripts/aws/assume_role b/aws-s3-bucket/scripts/aws/assume_role new file mode 100644 index 0000000..9276e0f --- /dev/null +++ b/aws-s3-bucket/scripts/aws/assume_role @@ -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 diff --git a/aws-s3-bucket/scripts/aws/assume_role_lib b/aws-s3-bucket/scripts/aws/assume_role_lib new file mode 100644 index 0000000..90e8185 --- /dev/null +++ b/aws-s3-bucket/scripts/aws/assume_role_lib @@ -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 +# Given the JSON returned by `np provider read --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 +# Looks up the "AWS IAM" provider (specification aws-iam-configuration, category +# "Identity & Access Control") at , reads it, and echoes the ARN matching +# . 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" +} diff --git a/aws-s3-bucket/scripts/aws/assume_role_step b/aws-s3-bucket/scripts/aws/assume_role_step new file mode 100755 index 0000000..59f6bb9 --- /dev/null +++ b/aws-s3-bucket/scripts/aws/assume_role_step @@ -0,0 +1,58 @@ +#!/bin/bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# assume_role_step — FIRST step of every aws workflow. +# +# Resolves the IAM role to assume (nullplatform "AWS IAM" provider by selector, +# or the values.yaml fallback), calls sts:AssumeRole once, and exports temporary +# AWS credentials. Every subsequent step (build_context, +# build_permissions_context, do_tofu, ...) inherits and re-exports these, so the +# whole workflow runs as that role. If no ARN resolves, falls back to the pod's +# IRSA identity (empty credentials -> default chain). +# +# Doing the assume exactly once, up front, avoids the self-assume failure that +# happened when a second step tried to re-assume on top of already-assumed +# credentials (see commit 5671ac6). +# +# Inputs (from the NP notification context / values): +# $CONTEXT — notification JSON (.service.nrn, .service.slug) +# $VALUES — path to values.yaml (aws_profile, assume_role_selector, ...) +# Outputs (captured by the workflow YAML as type: environment): +# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN +# --------------------------------------------------------------------------- + +# CRITICAL: $VALUES is a FILE PATH (set by np service workflow exec --values), +# NOT JSON. Read individual keys with yaml_value(). +yaml_value() { + local key="$1" default="$2" file="$3" + local val + val=$(grep "^${key}:" "$file" 2>/dev/null | sed 's/^[^:]*: *//;s/^"//;s/"$//' | head -1) + echo "${val:-$default}" +} + +# Named AWS profile for local testing (e.g. an SSO profile). Used only to obtain +# the base credentials that call sts:AssumeRole; once a role is assumed the +# AWS_* credentials below take precedence. +AWS_PROFILE_VAL=$(yaml_value "aws_profile" "" "$VALUES") +if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then + export AWS_PROFILE="${AWS_PROFILE_VAL}" +fi + +# Derive the account NRN (strip everything from :namespace= onward) and the +# service slug; both feed the per-service ARN lookup in the IAM provider. +ACCOUNT_NRN=$(echo "$CONTEXT" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:namespace=.*$//') +if [ -z "$ACCOUNT_NRN" ]; then + echo "ERROR: could not derive account NRN from .service.nrn in context" >&2 + exit 1 +fi +SERVICE_SLUG=$(echo "$CONTEXT" | jq -r '.service.slug // ""') + +# Selector for the "AWS IAM" provider (Identity & Access Control). Defaults to +# the service slug; override with assume_role_selector in values.yaml if the +# selector configured in the UI differs from the service slug. +export ASSUME_ROLE_NRN="$ACCOUNT_NRN" +export ASSUME_ROLE_SELECTOR=$(yaml_value "assume_role_selector" "$SERVICE_SLUG" "$VALUES") + +# shellcheck source=assume_role +. "$(dirname "${BASH_SOURCE[0]}")/assume_role" diff --git a/aws-s3-bucket/scripts/aws/build_context b/aws-s3-bucket/scripts/aws/build_context index 8e2ae71..0e6d928 100755 --- a/aws-s3-bucket/scripts/aws/build_context +++ b/aws-s3-bucket/scripts/aws/build_context @@ -71,17 +71,25 @@ if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then export AWS_PROFILE="${AWS_PROFILE_VAL}" fi -# --- Resolve AWS region from nullplatform provider -------------------------- +# --- Derive the account NRN (CONTEXT-only; needed for provider lookups) ------ # We derive the account NRN from the service NRN by stripping everything from -# :namespace= onward, then query the provider with stored_keys: [account.region]. +# :namespace= onward. ACCOUNT_NRN=$(echo "$CONTEXT" | jq -r '.service.nrn // .entity_nrn // ""' | sed 's/:namespace=.*$//') if [ -z "$ACCOUNT_NRN" ]; then - echo "ERROR: could not derive account NRN from .service.nrn in context" >&2 + echo "ERROR: could not derive account NRN from .service.nrn in context" exit 1 fi +# --- AWS credentials -------------------------------------------------------- +# The role was already assumed by the "assume role" workflow step, which exported +# AWS_ACCESS_KEY_ID/SECRET/SESSION_TOKEN. They are inherited here and re-exported +# in this step's output block, so all AWS CLI + Terraform calls run as that role. + +# --- Resolve AWS region from nullplatform provider -------------------------- +# Query the account providers and pick the one exposing account.region. + NP_PROVIDERS=$(np provider list --nrn "$ACCOUNT_NRN" --format json --limit 100) echo "Resolving region for account: ${ACCOUNT_NRN}" @@ -90,7 +98,8 @@ ACCOUNT_PROVIDER_ID=$(echo "$NP_PROVIDERS" \ | jq -r '[(.results // [])[] | select((.data_source.stored_keys // []) | contains(["account.region"]))] | first | .id // ""') if [ -z "$ACCOUNT_PROVIDER_ID" ] || [ "$ACCOUNT_PROVIDER_ID" = "null" ]; then - echo "ERROR: no account provider with account.region found for ${ACCOUNT_NRN}" >&2 + echo "ERROR: no account provider with account.region found for ${ACCOUNT_NRN}" + echo "DEBUG providers response: ${NP_PROVIDERS}" exit 1 fi @@ -98,7 +107,7 @@ ACCOUNT_PROVIDER_DATA=$(np provider read --id "$ACCOUNT_PROVIDER_ID" --format js REGION=$(echo "$ACCOUNT_PROVIDER_DATA" | jq -r '.attributes.account.region // ""') if [ -z "$REGION" ]; then - echo "ERROR: account.region not found in provider ${ACCOUNT_PROVIDER_ID}" >&2 + echo "ERROR: account.region not found in provider ${ACCOUNT_PROVIDER_ID}" exit 1 fi @@ -142,13 +151,11 @@ export TOFU_VARIABLES="-var=service_id=${SERVICE_ID} -var=region=${REGION} -var= # --- Extract link context (link workflows only) ----------------------------- # These are consumed by build_permissions_context in the next workflow step. -if [ "${ACTION_SOURCE:-}" = "link" ]; then - export LINK_ID=$(echo "$CONTEXT" | jq -r '.link.id // ""') - export LINK_NAME=$(echo "$CONTEXT" | jq -r '.link.name // ""') - export SCOPE_ID=$(echo "$CONTEXT" | jq -r '.link.scope.id // ""') - export SCOPE_NRN=$(echo "$CONTEXT" | jq -r '.link.scope.nrn // ""') +export LINK_ID=$(echo "$CONTEXT" | jq -r '.link.id // ""') +export LINK_NAME=$(echo "$CONTEXT" | jq -r '.link.name // ""') +export SCOPE_ID=$(echo "$CONTEXT" | jq -r '.link.scope.id // ""') +export SCOPE_NRN=$(echo "$CONTEXT" | jq -r '.link.scope.nrn // ""') - LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // {})') - export LINK_ACCESS_LEVEL=$(echo "$LINK_ATTRS" | jq -r '.access_level // "read-write"') - export LINK_PATH_PREFIX=$(echo "$LINK_ATTRS" | jq -r '.path_prefix // ""') -fi +LINK_ATTRS=$(echo "$CONTEXT" | jq -r '(.link.attributes // {}) * (.parameters // {})') +export LINK_ACCESS_LEVEL=$(echo "$LINK_ATTRS" | jq -r '.access_level // "read-write"') +export LINK_PATH_PREFIX=$(echo "$LINK_ATTRS" | jq -r '.path_prefix // ""') diff --git a/aws-s3-bucket/scripts/aws/build_permissions_context b/aws-s3-bucket/scripts/aws/build_permissions_context index 96cd775..824a489 100755 --- a/aws-s3-bucket/scripts/aws/build_permissions_context +++ b/aws-s3-bucket/scripts/aws/build_permissions_context @@ -36,6 +36,14 @@ if [ -n "${AWS_PROFILE_VAL}" ] && [ -z "${AWS_PROFILE:-}" ]; then export AWS_PROFILE="${AWS_PROFILE_VAL}" fi +# --- AWS credentials -------------------------------------------------------- +# The role was already assumed by the "assume role" workflow step (the same +# selector this link flow needs, since it operates on S3 resources in the +# bucket's account). The temporary credentials are inherited here and +# re-exported in this step's output block, so the IAM user is created as that +# role. No re-assume is needed — doing it once up front avoids the self-assume +# failure that occurred when re-assuming on top of already-assumed credentials. + # --- Read service outputs (set by write_service_outputs after bucket creation) - SERVICE_ID=$(echo "$CONTEXT" | jq -r '.service.id') diff --git a/aws-s3-bucket/scripts/aws/do_tofu b/aws-s3-bucket/scripts/aws/do_tofu index c8d1c31..f00f418 100755 --- a/aws-s3-bucket/scripts/aws/do_tofu +++ b/aws-s3-bucket/scripts/aws/do_tofu @@ -50,7 +50,7 @@ cp -r "$TOFU_MODULE_DIR"/* . echo "Running: tofu init" # shellcheck disable=SC2086 -tofu init $TOFU_INIT_VARIABLES +tofu init -reconfigure $TOFU_INIT_VARIABLES echo "Running: tofu $TOFU_ACTION" # shellcheck disable=SC2086 diff --git a/aws-s3-bucket/scripts/aws/test/assume_role_lib_test b/aws-s3-bucket/scripts/aws/test/assume_role_lib_test new file mode 100644 index 0000000..327f0b2 --- /dev/null +++ b/aws-s3-bucket/scripts/aws/test/assume_role_lib_test @@ -0,0 +1,44 @@ +#!/bin/bash +# Unit tests for the pure resolution functions in assume_role_lib. +# No AWS / np calls — only the jq/selector logic that carries the real risk. +# Run: bash scripts/aws/test/assume_role_lib_test +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../assume_role_lib +. "$HERE/../assume_role_lib" + +fail=0 +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS: $desc" + else + echo "FAIL: $desc" + echo " expected: [$expected]" + echo " actual: [$actual]" + fail=1 + fi +} + +# A provider read payload as returned by `np provider read --id --format json`. +JSON='{"attributes":{"iam_role_arns":{"arns":[{"selector":"s3","arn":"arn:aws:iam::111:role/s3"},{"selector":"lambda","arn":"arn:aws:iam::111:role/lambda"}]}}}' + +assert_eq "selector s3 returns its arn" "arn:aws:iam::111:role/s3" "$(arn_for_selector_from_json "$JSON" s3)" +assert_eq "selector lambda returns its arn" "arn:aws:iam::111:role/lambda" "$(arn_for_selector_from_json "$JSON" lambda)" +assert_eq "unknown selector returns empty" "" "$(arn_for_selector_from_json "$JSON" ecs)" +assert_eq "missing arns key returns empty" "" "$(arn_for_selector_from_json '{"attributes":{}}' s3)" +assert_eq "empty input returns empty" "" "$(arn_for_selector_from_json '' s3)" +assert_eq "malformed json returns empty" "" "$(arn_for_selector_from_json 'not json' s3)" +assert_eq "empty selector returns empty" "" "$(arn_for_selector_from_json "$JSON" '')" + +# First match wins when duplicate selectors exist. +DUP='{"attributes":{"iam_role_arns":{"arns":[{"selector":"s3","arn":"first"},{"selector":"s3","arn":"second"}]}}}' +assert_eq "duplicate selector takes first" "first" "$(arn_for_selector_from_json "$DUP" s3)" + +if [ "$fail" -eq 0 ]; then + echo "--- all assertions passed ---" +else + echo "--- failures present ---" +fi +exit $fail diff --git a/aws-s3-bucket/scripts/aws/test/assume_role_provider_test b/aws-s3-bucket/scripts/aws/test/assume_role_provider_test new file mode 100644 index 0000000..f358db8 --- /dev/null +++ b/aws-s3-bucket/scripts/aws/test/assume_role_provider_test @@ -0,0 +1,73 @@ +#!/bin/bash +# Tests for provider_arn_for_selector — the np list->read orchestration. +# Uses a fake `np` on PATH as a test double for the external CLI (the only +# way to exercise the orchestration without a live nullplatform connection). +# Run: bash scripts/aws/test/assume_role_provider_test +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=../assume_role_lib +. "$HERE/../assume_role_lib" + +fail=0 +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo "PASS: $desc" + else + echo "FAIL: $desc" + echo " expected: [$expected]" + echo " actual: [$actual]" + fail=1 + fi +} + +# --- Fake np on PATH -------------------------------------------------------- +FAKE_DIR=$(mktemp -d) +cat > "$FAKE_DIR/np" <<'EOF' +#!/bin/bash +args="$*" +case "$args" in + *"provider list"*) + if [ "${FAKE_NP_MODE:-}" = "no_provider" ]; then + echo '{"results":[]}' + else + echo '{"results":[{"id":"prov-123"}]}' + fi + ;; + *"provider read"*) + echo '{"attributes":{"iam_role_arns":{"arns":[{"selector":"aws-s3-bucket","arn":"arn:aws:iam::123456789012:role/test-s3-role"}]}}}' + ;; + *) echo '{}' ;; +esac +EOF +chmod +x "$FAKE_DIR/np" +export PATH="$FAKE_DIR:$PATH" + +ARN_S3="arn:aws:iam::123456789012:role/test-s3-role" + +assert_eq "resolves arn for matching selector" "$ARN_S3" \ + "$(provider_arn_for_selector "organization=1:account=2" aws-s3-bucket)" + +export FAKE_NP_MODE=no_provider +assert_eq "no provider instance returns empty" "" \ + "$(provider_arn_for_selector "organization=1:account=2" aws-s3-bucket)" +unset FAKE_NP_MODE + +assert_eq "selector not in provider returns empty" "" \ + "$(provider_arn_for_selector "organization=1:account=2" does-not-exist)" + +assert_eq "empty nrn returns empty" "" \ + "$(provider_arn_for_selector "" aws-s3-bucket)" + +assert_eq "empty selector returns empty" "" \ + "$(provider_arn_for_selector "organization=1:account=2" "")" + +rm -rf "$FAKE_DIR" + +if [ "$fail" -eq 0 ]; then + echo "--- all assertions passed ---" +else + echo "--- failures present ---" +fi +exit $fail diff --git a/aws-s3-bucket/values.yaml b/aws-s3-bucket/values.yaml index 025cea0..d42a884 100644 --- a/aws-s3-bucket/values.yaml +++ b/aws-s3-bucket/values.yaml @@ -10,3 +10,22 @@ # will export it so Terraform and AWS CLI use the correct credentials. # Run "aws sso login --profile " before starting np-agent locally. aws_profile: "" + +# --- Assume role ------------------------------------------------------------ +# Preferred source: the "AWS IAM" provider (category "Identity & Access Control") +# declared in nullplatform. The agent looks up that provider at the account NRN +# and picks the ARN whose `selector` matches assume_role_selector below. This +# keeps role ARNs declarative and per-service/scope, with no account-specific +# value committed here. +# +# selector to match in the IAM provider's arns list. Leave empty to default to +# the service slug (.service.slug from the notification context). Set explicitly +# if the selector configured in the UI differs from the service slug. +assume_role_selector: "aws-s3-bucket" + +# Static fallback used only when the IAM provider yields no ARN for the selector +# (e.g. local testing without nullplatform, or back-compat single-account setups). +# When set, the agent's base credentials (e.g. IRSA) are used only to call +# sts:AssumeRole; all subsequent AWS calls run as this role. Empty -> IRSA direct. +# assume_role_arn: "arn:aws:iam:::role/" +assume_role_arn: "" diff --git a/aws-s3-bucket/workflows/aws/create.yaml b/aws-s3-bucket/workflows/aws/create.yaml index b8b3809..79a0b15 100644 --- a/aws-s3-bucket/workflows/aws/create.yaml +++ b/aws-s3-bucket/workflows/aws/create.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context @@ -15,6 +26,12 @@ steps: type: environment - name: TOFU_VARIABLES type: environment + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment - name: tofu type: script diff --git a/aws-s3-bucket/workflows/aws/delete.yaml b/aws-s3-bucket/workflows/aws/delete.yaml index 18d0923..29b5eb0 100644 --- a/aws-s3-bucket/workflows/aws/delete.yaml +++ b/aws-s3-bucket/workflows/aws/delete.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context @@ -15,6 +26,12 @@ steps: type: environment - name: TOFU_VARIABLES type: environment + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment - name: tofu type: script diff --git a/aws-s3-bucket/workflows/aws/link-update.yaml b/aws-s3-bucket/workflows/aws/link-update.yaml index baed09e..069545d 100644 --- a/aws-s3-bucket/workflows/aws/link-update.yaml +++ b/aws-s3-bucket/workflows/aws/link-update.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context @@ -34,6 +45,12 @@ steps: type: environment - name: TOFU_VARIABLES type: environment + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment - name: tofu type: script diff --git a/aws-s3-bucket/workflows/aws/link.yaml b/aws-s3-bucket/workflows/aws/link.yaml index baed09e..069545d 100644 --- a/aws-s3-bucket/workflows/aws/link.yaml +++ b/aws-s3-bucket/workflows/aws/link.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context @@ -34,6 +45,12 @@ steps: type: environment - name: TOFU_VARIABLES type: environment + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment - name: tofu type: script diff --git a/aws-s3-bucket/workflows/aws/unlink.yaml b/aws-s3-bucket/workflows/aws/unlink.yaml index 8e00019..f367fdd 100644 --- a/aws-s3-bucket/workflows/aws/unlink.yaml +++ b/aws-s3-bucket/workflows/aws/unlink.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context @@ -34,6 +45,12 @@ steps: type: environment - name: TOFU_VARIABLES type: environment + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment - name: tofu type: script diff --git a/aws-s3-bucket/workflows/aws/update.yaml b/aws-s3-bucket/workflows/aws/update.yaml index b8b3809..79a0b15 100644 --- a/aws-s3-bucket/workflows/aws/update.yaml +++ b/aws-s3-bucket/workflows/aws/update.yaml @@ -1,4 +1,15 @@ steps: + - name: assume role + type: script + file: $SERVICE_PATH/scripts/aws/assume_role_step + output: + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment + - name: build context type: script file: $SERVICE_PATH/scripts/aws/build_context @@ -15,6 +26,12 @@ steps: type: environment - name: TOFU_VARIABLES type: environment + - name: AWS_ACCESS_KEY_ID + type: environment + - name: AWS_SECRET_ACCESS_KEY + type: environment + - name: AWS_SESSION_TOKEN + type: environment - name: tofu type: script