diff --git a/.gitignore b/.gitignore index 10d7b0f7..a3c4d809 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ web_modules/ .env.test.local .env.production.local .env.local +*.env # parcel-bundler cache (https://parceljs.org/) .cache @@ -142,9 +143,28 @@ DS_Store # Integration test runtime data frontend/deployment/tests/integration/volume/ +# Local sts:AssumeRole credentials cache (parameters/utils/assume_role) +credentials/ + # Terraform/OpenTofu .terraform/ .terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +*.tfvars.json +*.tfbackend +override.tf +override.tf.json +*_override.tf +*_override.tf.json +backend-local.tf +!*.tfvars.example +!*.tfbackend.example +!*_override.tf.example # Generated test certificates testing/docker/certs/ @@ -153,4 +173,6 @@ testing/docker/certs/ .claude/ # Visual Studio Code -.vscode/ \ No newline at end of file +.vscode/ + +.DS_Store \ No newline at end of file diff --git a/aws-secret-manager-strategies.docx b/aws-secret-manager-strategies.docx new file mode 100644 index 00000000..3502ddd5 Binary files /dev/null and b/aws-secret-manager-strategies.docx differ diff --git a/parameters/PENDING.md b/parameters/PENDING.md new file mode 100644 index 00000000..86bba336 --- /dev/null +++ b/parameters/PENDING.md @@ -0,0 +1,139 @@ +# Parameters Package — Pending Work + +Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. Para vista de la arquitectura completa: `parameters/docs/architecture.md`. + +--- + +## Estado actual + +| Componente | Estado | +|---|---| +| Skeleton (entrypoint, build_context, dispatch, utils, workflows) | ✅ Implementado | +| Provider `hashicorp-vault` | ✅ Implementado | +| Provider `aws-secrets-manager` | ✅ Implementado | +| Provider `aws-parameter-store` | ✅ Implementado | +| Provider `azure-key-vault` | ✅ Implementado | +| Error handling (not_found → idempotent, otros → fail loud) | ✅ Aplicado a deletes y retrieves | +| Tests BATS | ✅ 151 tests pasando | +| Docs globales | ✅ architecture.md, configuration.md, adding_a_provider.md | +| Docs por provider | ✅ architecture.md (4 providers), iam-policy.md (SM + PS) | +| Decision doc para equipo | ✅ `aws-secret-manager-strategies.docx` (en root del repo) | +| **Resolución de provider via `provider.specification_id`** | **✅ Implementado** (era pendiente, hecho hoy) | +| **`PROVIDER_CONFIG` desde `provider.attributes`** | **✅ Implementado** (era pendiente como `fetch_configuration`, ahora viene en payload) | +| Naming NRN+slug-based | ✅ Implementado (utils/build_external_id + 4 providers refactorizados) | +| Rename `secret_manager` → `aws-secrets-manager` | ✅ Implementado | + +--- + +## Decisiones tomadas + +| Decisión | Valor | Origen | +|---|---|---| +| Estrategia de granularidad | 1:1 mapping (un secret por parámetro) | Review del equipo sobre el decision doc | +| Naming convention | NRN entities con slugs+ids + dimensiones + parameter_id | Conversación de diseño | +| Provider AWS Secrets Manager | Nombre futuro: `aws-secrets-manager` | Conversación de diseño | +| Provider selection | Via `provider.specification_id` → np CLI → slug | Cambio reciente con payload real | +| Provider config source | `provider.attributes` en el payload (no env vars, no fetch script) | Cambio reciente | +| Workflow YAMLs | 4 workflows unificados (store, retrieve, delete, notify) | Cleanup arquitectónico | +| Discriminación secret/param | En `build_context` desde `$CONTEXT.secret`, no en entrypoint | Cleanup | +| Logging | Todos los niveles routean a stderr (stdout reservado para JSON) | Bug encontrado durante tests | +| Delete failure semantics | "not found" → success idempotente, otros → exit 1 | Feedback de revisión | +| Retrieve failure semantics | Idem delete | Feedback de revisión | + +--- + +## Pendiente + +Sin items pendientes a la fecha. Todas las decisiones aprobadas están implementadas. + +--- + +## Contrato del payload — referencia rápida + +`$CONTEXT` (después de que el entrypoint extrae `.notification`): + +| Campo | Tipo | Acciones | Notas | +|---|---|---|---| +| `parameter_id` | number | todas | nullplatform parameter ID | +| `value` | string | store | el valor a persistir | +| `external_id` | string | retrieve, delete, notify | handle generado en store | +| `secret` | bool | todas | discriminador secret/parameter (informativo en 1:1) | +| `parameter_name` | string | todas | display name | +| `encoding` | string | todas | `plain`, `base64`, etc. | +| `entities` | object | todas | IDs only — slugs vía np CLI (solo en store, para naming) | +| `dimensions` | object | opcional | top-level, NO en `provider.dimensions` | +| `provider.specification_id` | uuid | todas | **el que decide qué provider usar** | +| `provider.attributes` | object | todas | **config del provider, viene en el payload** | +| `provider.nrn` | string | todas | informacional (NRN del provider instance) | +| `provider.dimensions` | object | todas | informacional (dimensions del provider instance) | +| `provider.id` | uuid | todas | informacional | + +Ejemplo de payload completo de store: + +```json +{ + "action": "parameter:store", + "parameter_id": 359535238, + "value": "the-value", + "parameter_name": "test_param", + "secret": false, + "encoding": "plaintext", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": { + "environment": "development", + "country": "argentina" + }, + "provider": { + "id": "e4105634-4ee0-4ffa-996b-1fb8213e56b6", + "nrn": "organization=1255165411:account=95118862:namespace=37094320:application=321402625", + "dimensions": {}, + "specification_id": "ec885dd0-7c38-45b8-af2c-0b9e1deb7d3d", + "attributes": {} + } +} +``` + +--- + +## Cómo correr los tests + +```bash +bats $(find parameters/tests -name "*.bats") +``` + +Distribución actual (151 tests): + +- Skeleton (entrypoint, build_context, dispatch, utils): 56 tests +- hashicorp-vault: 27 tests +- aws-secrets-manager: 17 tests (renombrado desde `secret_manager`) +- aws-parameter-store: 23 tests +- azure-key-vault: 15 tests +- utils/log + utils/get_config_value: 13 tests + +--- + +## Estructura del paquete + +``` +parameters/ +├── PENDING.md # este archivo +├── entrypoint, build_context # router + provider resolution via spec_id +├── store, retrieve, delete, notify # dispatch one-liners +├── workflows/ # 4 YAMLs (acción-only) +├── utils/ +│ ├── get_config_value # priority: provider config > env > default +│ └── log # todos los niveles a stderr +├── providers/ +│ ├── README.md # contrato del provider +│ ├── hashicorp-vault/ +│ ├── aws-secrets-manager/ +│ ├── aws-parameter-store/ +│ └── azure-key-vault/ +├── tests/ # 151 BATS tests +└── docs/ # docs globales del paquete +``` diff --git a/parameters/docs/adding_a_provider.md b/parameters/docs/adding_a_provider.md new file mode 100644 index 00000000..ee525be3 --- /dev/null +++ b/parameters/docs/adding_a_provider.md @@ -0,0 +1,313 @@ +# Adding a New Provider + +Step-by-step guide to add a new backend (e.g. Google Secret Manager, Doppler, 1Password Secrets Automation). + +The parameters package is designed so that adding a provider is **strictly additive**. You drop a directory under `providers/`; nothing outside it changes. + +--- + +## Core principles (read this before anything else) + +Every provider in this package follows the same two cross-cutting principles. Your implementation MUST honor them; deviation is not a per-provider choice. + +### 1. Naming is human-friendly and hierarchical + +The storage path/name for every secret is composed from the parameter's context: account + namespace + application + (scope if present) + (dimensions if present) + parameter name + parameter id + revision. Both the entity slug AND id are included so the name is readable AND stable across renames. + +All names are grouped under a `nullplatform` top-level prefix (default — operators can override via provider config). This is the IAM scoping anchor and the visual marker in the backend's console. + +The shared helper `parameters/utils/build_external_id` constructs the canonical form. Your `store` calls it once at the top: + +``` +nullplatform/organization=-/account=-/namespace=-/application=-[/scope=-][/=...]/- +``` + +Two segments are conditional: + +- **`scope` entity** is optional — appears only when the parameter is bound to a specific deployment scope, between `application` and dimensions. +- **Dimensions** are optional — a parameter may have zero, one, or many. Present dimensions are sorted alphabetically by key. + +The required entities (`organization`, `account`, `namespace`, `application`) are always present in nullplatform's NRN, in that canonical order. + +The principle: an operator who opens the backend's UI (AWS Console, Vault UI, Azure Portal) must be able to navigate to any secret by knowing the parameter's nullplatform context, without consulting the platform's database. The path tells the story. + +If your backend has naming restrictions (e.g. Azure Key Vault disallows `/` and `=`), transform the canonical form deterministically inside your `store`/`retrieve`/`delete`. The `external_id` returned to nullplatform always uses the canonical (slash) form so it's portable across providers. + +### 2. Versioning is mandatory and cost-aware + +nullplatform parameter values are **immutable**. Every update to a `(parameter_id, NRN, dimensions)` tuple creates a new revision. nullplatform may ask you to retrieve a specific historical revision (e.g. to display in UI or to support restore — which is implemented as "read old revision + store as new revision"). + +Your provider MUST keep the version history. Two rules: + +- **Don't lose old revisions on update**. If the backend has native versioning (AWS SM, Vault KV v2, AWS Parameter Store, Azure Key Vault all do), use it: append a new version to the same key. If the backend doesn't have native versioning, store revisions inside a single record (e.g. JSON list, append-only structure) — keep them in one logical entity to avoid cost explosion. +- **Never create a new top-level entity per revision** if the backend charges per entity. AWS Secrets Manager charges $0.40/secret/month regardless of version count; creating one secret per version would multiply cost linearly with update frequency. Native versioning is essentially free. + +Version identity is encoded in the `external_id` returned by store: + +``` +# +``` + +Where `version_id` is the **literal native identifier the backend returns** — not invented, not normalized. AWS SM gives a UUID; Vault gives an integer; Parameter Store gives an integer; AKV gives a 32-char hex. Use whatever the backend hands you, verbatim. + +Because nullplatform persists and re-sends `external_id` on every operation, the version reference round-trips automatically without any platform-side state. On `retrieve`, split on `#` to get both pieces; use the version via the backend's native lookup (e.g. `--version-id`, `?version=N`, `:N`, `--version`). On `delete`, ignore the version suffix — delete removes all revisions. + +--- + +## What you need to know about the backend + +Before you start, answer these questions: + +| Question | Why it matters | +|---------------------------------------------------------|-------------------------------------------------| +| What CLI / API do you call? | Determines your tooling (curl, aws, az, gcloud) | +| How does authentication work? | Defines what `setup` validates | +| What's the naming convention for stored items? | Defines your prefix + UUID scheme | +| Does it have soft-delete? | Determines if `delete` needs a purge step | +| Does it distinguish secret vs plain types at the API? | Determines if `store` branches on PARAMETER_KIND | + +--- + +## Step 1: Create the provider directory + +```bash +mkdir -p parameters/providers//docs +mkdir -p parameters/tests/providers/ +``` + +`` must match the `slug` field of the `parameters-storage` provider specification you (or the platform admin) registered in nullplatform. The agent's `build_context` calls `np provider specification read --id `, reads `.slug`, and uses it to find your directory. + +--- + +## Step 2: Write `setup` + +Validate config and export connection handles. Don't repeat this in operation scripts — `setup` is the DRY anchor. + +Config values can come from **two sources**, and `get_config_value` picks whichever is present (provider config wins, env fallback, defaults last): + +1. **`parameters-storage` provider in nullplatform** — values set when the provider is registered, sent to the agent in `$CONTEXT.provider.attributes`. Good for non-sensitive operational settings (region, name prefix, vault address, etc.). +2. **Environment variables on the agent** — set by the operator outside nullplatform. **Recommended for credentials, tokens, and any sensitive material** that should not be stored in nullplatform's database. This keeps ownership of sensitive data 100% on the operator side and lets them use their own protection mechanisms (secret stores, rotation, etc.). + +```bash +#!/bin/bash +set -euo pipefail + +# Read config (provider config wins, env fallback, defaults last) +MY_ENDPOINT=$(get_config_value --env MY_ENDPOINT --provider '.endpoint') +# Token: only env var — do NOT pass via provider config (keep credentials off-platform) +MY_TOKEN=$(get_config_value --env MY_TOKEN) +MY_PREFIX=$(get_config_value --env MY_PREFIX --provider '.prefix' --default 'nullplatform-') + +if [ -z "$MY_ENDPOINT" ]; then + log error "❌ endpoint not configured" + log error "" + log error "💡 Possible causes:" + log error " • MY_ENDPOINT env var is not set" + log error " • .endpoint is missing in PROVIDER_CONFIG" + log error "" + log error "🔧 How to fix:" + log error " • Set MY_ENDPOINT=" + exit 1 +fi + +# Validate format / shape if relevant +# ... + +export MY_ENDPOINT MY_TOKEN MY_PREFIX +``` + +--- + +## Step 3: Write the four operation scripts + +### `store` + +Generate a UUID, persist the value, return `{external_id, metadata}`. + +```bash +#!/bin/bash +set -euo pipefail + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +NAME="${MY_PREFIX}${EXTERNAL_ID}" + +if ! HANDLE=$(my_cli create --endpoint "$MY_ENDPOINT" --name "$NAME" --value "$PARAMETER_VALUE" 2>/dev/null); then + log error "❌ Failed to store in " + log error "" + log error "💡 Possible causes:" + log error " • " + log error "" + log error "🔧 How to fix:" + log error " • " + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg handle "$HANDLE" \ + --arg name "$NAME" \ + '{external_id: $external_id, metadata: {handle: $handle, name: $name}}' +``` + +If your backend distinguishes types (like `aws-parameter-store` does with String/SecureString), branch on `PARAMETER_KIND` here. + +### `retrieve` + +Read the value and return `{value}`. On any failure — including "not found" — exit non-zero with troubleshooting. Do NOT return a sentinel value like "value not found" as if it were a real value; that would be a misleading payload (the platform would treat the literal string as the parameter's value). + +If the backend distinguishes "not found" from other errors, include that distinction in the troubleshooting message so the platform can categorize. Both still exit non-zero. + +```bash +#!/bin/bash +set -euo pipefail + +NAME="${MY_PREFIX}${EXTERNAL_ID_PATH}" # use _PATH (canonical, no version suffix) + +err_file=$(mktemp) +if VALUE=$(my_cli get --endpoint "$MY_ENDPOINT" --name "$NAME" 2>"$err_file"); then + rm -f "$err_file" + jq -n --arg value "$VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q ""; then + log error "❌ Secret '$NAME' not found in " + log error "" + log error "💡 Possible causes:" + log error " • The secret was manually deleted from the backend" + log error " • The external_id is stale" + log error "" + log error "🔧 How to fix:" + log error " • Verify: my_cli describe --name $NAME" + exit 1 + else + log error "❌ Failed to retrieve from " + log error "" + log error "💡 Possible causes:" + log error " • " + log error "" + log error "🔧 How to fix:" + log error " • " + log error "Underlying error: $err" + exit 1 + fi +fi +``` + +### `delete` + +Idempotent — re-deleting a missing resource is success. But **only "not found" is suppressed**; any other failure (permission denied, network error, server error) MUST propagate as exit 1 with troubleshooting. Reporting success when the work didn't actually happen leads to "client thinks it's deleted but it's still there" bugs. + +```bash +#!/bin/bash +set -euo pipefail + +NAME="${MY_PREFIX}${EXTERNAL_ID_PATH}" # delete removes ALL versions; ignore version suffix + +err_file=$(mktemp) +if my_cli delete --endpoint "$MY_ENDPOINT" --name "$NAME" >/dev/null 2>"$err_file"; then + rm -f "$err_file" + echo '{ + "success": true +}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q ""; then + log debug "Resource '$NAME' does not exist, treating delete as idempotent success" + echo '{ + "success": true +}' + else + log error "❌ Failed to delete '$NAME' from " + log error "" + log error "💡 Possible causes:" + log error " • " + log error "" + log error "🔧 How to fix:" + log error " • " + log error "Underlying error: $err" + exit 1 + fi +fi +``` + +### `notify` (optional) + +Skip the file unless your backend needs a per-notify side effect. The dispatch returns the default `{success: true}` if `notify` doesn't exist. + +--- + +## Step 5: Write tests + +Mirror the source structure under `parameters/tests/providers//`: + +``` +tests/providers// +├── setup.bats # Config resolution, validation, error paths +├── store.bats # JSON output shape, CLI args, error paths +├── retrieve.bats # Hit case, miss case, CLI args +└── delete.bats # Always-success, CLI args, idempotency +``` + +Use the patterns from existing providers (`hashicorp-vault`, `aws-secrets-manager`, `aws-parameter-store`, `azure-key-vault`): + +- Mock the backend CLI as a script in `$BATS_TEST_TMPDIR/bin/`, export PATH to find it. +- Capture CLI args to a log file, assert on them. +- Mock `uuidgen` for deterministic `external_id` in store tests. +- Use the `DEPS="source $PARAMETERS_DIR/utils/log"` pattern to make `log` available in `bash -c` subshells. + +Aim for at least these scenarios per provider: + +| Script | Required tests | +|-----------|-----------------------------------------------------------------------------| +| setup | Missing required config fails with troubleshooting; PROVIDER_CONFIG wins over env; defaults applied | +| store | Output JSON shape; external_id includes path + #version suffix; first store uses create, subsequent uses native versioning API; failure paths exit non-zero with troubleshooting | +| retrieve | Hit returns value; not-found exits non-zero with troubleshooting; with-version targets historical revision; auth/network errors exit non-zero | +| delete | Returns `{success: true}` on success; not-found is treated as success (idempotent); other errors propagate as exit 1 with troubleshooting | + +--- + +## Step 6: Write the docs + +Add at least `parameters/providers//docs/architecture.md` describing: + +- Storage layout (naming, prefix, encryption model) +- Cost model +- Authentication +- Any quirks (soft-delete, regions, multi-tenant constraints) + +If the backend needs IAM-style permissions (AWS, GCP), add `iam-policy.md` with a least-privilege example using placeholders for accounts/regions/keys. + +--- + +## Step 7: Wire it up + +1. Register a `parameters-storage` provider in nullplatform with `slug: ` and the schema for your provider's config attributes (use a `.json.tpl` file as the spec — see existing providers for examples). +2. Bind parameters in nullplatform to that provider specification. + +Done. The agent receives `provider.specification_id` and `provider.attributes` in every notification for those parameters; `build_context` resolves the slug, finds your directory, and dispatches. + +--- + +## Checklist + +Before considering a new provider complete: + +- [ ] Naming follows the human-friendly hierarchical convention (entity slug-id + dimensions + parameter name-id), under the `nullplatform/` prefix +- [ ] `store` uses `build_external_id` to compose the canonical path +- [ ] `store` returns `external_id = #` with the native backend version identifier (not invented) +- [ ] Versioning uses the backend's native mechanism (not new top-level entities per revision) +- [ ] `setup` validates config and exits with troubleshooting on missing fields +- [ ] `store` outputs `{external_id, metadata}` JSON +- [ ] `retrieve` outputs `{value}` on success; **exits non-zero on not-found with clear troubleshooting** (no "value not found" sentinel) +- [ ] `retrieve` honors `EXTERNAL_ID_VERSION` to target historical revisions +- [ ] `delete` outputs `{success: true}` on success and on not-found (idempotent); **exits non-zero on any other error** (no `|| true` blanket suppression) +- [ ] Scripts use `set -euo pipefail` +- [ ] Errors go to stderr via `log error "..."` +- [ ] No stdout output other than the final JSON +- [ ] Every error has `💡 Possible causes:` and `🔧 How to fix:` blocks +- [ ] BATS tests cover setup error paths, store output shape (incl. version suffix), retrieve hit + not-found-error + auth-error, delete success + not-found-idempotent + other-error-propagation +- [ ] `architecture.md` documents storage layout, versioning behavior, and the native version_id format +- [ ] If the backend has IAM, `iam-policy.md` shows least-privilege scoping +- [ ] A `_configuration.json.tpl` exists with the `parameters-storage` category and the schema for the provider's config attributes diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md new file mode 100644 index 00000000..4494d6de --- /dev/null +++ b/parameters/docs/architecture.md @@ -0,0 +1,159 @@ +# Parameters Package — Architecture + +A pluggable parameter and secret storage layer for nullplatform scopes. The provider for each parameter is chosen by the platform itself (via `provider.specification_id` in the notification payload), and the provider's configuration travels in the same payload. + +--- + +## What problem this solves + +nullplatform scopes need to persist parameter values somewhere. Different organizations want different backends: + +- AWS-native: Secrets Manager and/or Parameter Store +- Azure-native: Key Vault +- Existing HashiCorp infrastructure: Vault +- Hybrid setups: any combination of the above + +A monolithic scope tied to one backend forces fork-and-modify for every variation. This package inverts the relationship: the **dispatch layer is the package**, the **backends are pluggable modules** dropped into `providers/`. + +The platform decides which provider handles each parameter and which configuration it uses. Operators register `parameters-storage` providers in nullplatform (one per backend they want to support — AWS Secrets Manager, Vault, etc.) with their region/address/etc. The notification payload then carries both the choice and its configuration to the agent. A single agent can serve parameters routed to multiple backends simultaneously without per-agent configuration. + +--- + +## Layered design + +``` +┌────────────────────────────────────────────────────────────────┐ +│ nullplatform sends action notification │ +│ (NOTIFICATION_ACTION="parameter:", NP_ACTION_CONTEXT) │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/entrypoint │ +│ - Clean NP_ACTION_CONTEXT, export CONTEXT (= .notification) │ +│ - Pick workflow: workflows/.yaml │ +│ - Honor OVERRIDES_PATH for consumer-side workflow overrides │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ workflows/.yaml │ +│ - Step 1: build_context │ +│ - Step 2: (store / retrieve / delete / notify) │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/utils/build_context │ +│ - Parse CONTEXT → EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE │ +│ - Derive PARAMETER_KIND from $CONTEXT.secret │ +│ - Read $CONTEXT.provider.specification_id │ +│ - np provider specification read --id → slug │ +│ - ACTIVE_PROVIDER = slug; PROVIDER_DIR = providers/$slug │ +│ - PROVIDER_CONFIG = $CONTEXT.provider.attributes │ +│ - Source providers/$ACTIVE_PROVIDER/setup │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/utils/dispatch (unified dispatcher) │ +│ - Reads $ACTION (set by workflow `configuration:` block) │ +│ - source providers/$ACTIVE_PROVIDER/$ACTION │ +│ - Special-case: ACTION=notify with no provider notify → ack │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ providers// │ +│ - Executes the actual backend call (curl, aws, az, ...) │ +│ - Writes JSON result to stdout │ +└────────────────────────────────────────────────────────────────┘ +``` + +The dispatch layer is **provider-agnostic**. It has zero knowledge of any specific provider's existence. Adding a new provider is strictly additive — no edits to `entrypoint`, `build_context`, `workflows/`, or other providers. + +--- + +## Storage naming: human-friendliness principle + +Every provider composes its storage path from the parameter's NRN entities (slugs + IDs), dimensions, and parameter name + ID. The principle is that an operator entering the storage layer manually (AWS console, Vault UI, az portal) must be able to find any secret by knowing the parameter's context, without consulting nullplatform's database. + +The shared helper `parameters/utils/build_external_id` constructs the canonical form, fetching slugs from the np CLI in parallel: + +``` +/organization=-/account=-/namespace=-/application=-[/scope=-][/=...]/- +``` + +**Required entities** (always present, always in this order): `organization`, `account`, `namespace`, `application`. + +**Optional entity**: `scope` — appears as a segment between `application` and dimensions, only when the parameter is bound to a specific deployment scope. + +**Optional dimensions**: zero or more `key=value` segments, sorted alphabetically by key for determinism. A parameter without dimensions has no dimension segments in the path at all. + +Each provider applies the prefix (default `nullplatform/`) and any backend-specific sanitization (Azure Key Vault flattens slashes and equals to dashes; everyone else uses the canonical form). The canonical `external_id` returned to nullplatform is the same across all providers, which makes parameter migration between backends mechanically possible. + +### Version encoding in external_id + +The `external_id` also carries the version identifier as a suffix: + +``` +# +``` + +The `version_id` is **the native version identifier returned by each backend** — no normalization, no invention. Each provider copies it verbatim from the backend's response. The format varies per backend: + +| Provider | Version ID format | Example | +|----------------------|----------------------------------|--------------------------------------------------| +| `aws-secrets-manager` | UUID v4 (from `VersionId`) | `a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d` | +| `hashicorp-vault` | Integer (from `.data.version`) | `3` | +| `aws-parameter-store` | Integer (from `.Version`) | `7` | +| `azure-key-vault` | 32-char hex (URL last segment) | `93a0b2eb12a64fa7b3acb18900a8d33d` | + +Because nullplatform already persists and re-sends `external_id` on every operation, this versioning works without any platform-side changes. On `retrieve`, the dispatcher's `build_context` splits the suffix; provider scripts use it to target a specific historical version via the backend's native version-fetching mechanism (`--version-id`, `?version=N`, `:N`, `--version`). + +--- + +## How the provider is chosen + +For each parameter, nullplatform stores which provider should handle it. That choice travels with every notification as `provider.specification_id` — a UUID pointing to a "provider specification" entity in nullplatform. + +`build_context` resolves this UUID into a slug using the np CLI: + +``` +np provider specification read --id --format json +→ { "slug": "aws-secrets-manager", ... } +``` + +The slug becomes `ACTIVE_PROVIDER`, which must match a directory under `parameters/providers/`. The match is exact, case-sensitive. + +The provider's configuration travels in the same payload at `provider.attributes`. `build_context` exports it as `PROVIDER_CONFIG` (a JSON string). Each provider's `setup` reads from `PROVIDER_CONFIG` via `get_config_value --provider '.field'` to extract specific fields (region, kms_key_id, etc.). + +The provider's configuration is registered upfront as a `parameters-storage` provider in nullplatform. The platform then attaches that configuration to each parameter via `provider.specification_id`. A single agent can serve parameters routed to multiple backends at the same time, without per-agent configuration. + +--- + +## File tree + +``` +parameters/ +├── entrypoint # Action router (the only loose script — entry point) +├── workflows/ # 4 YAMLs (one per action), each sets ACTION via configuration +├── utils/ # All shared scripts live here +│ ├── build_context # Resolves ACTIVE_PROVIDER from spec_id, sources setup +│ ├── build_external_id # Composes # via parallel np slug fetches +│ ├── dispatch # Unified action dispatcher (reads $ACTION) +│ ├── get_config_value # Priority: provider config > env > default +│ └── log # All levels route to stderr +├── providers/ +│ ├── README.md # Contract every provider must satisfy +│ ├── hashicorp-vault/ # HTTP API +│ ├── aws-secrets-manager/ # aws CLI +│ ├── aws-parameter-store/ # aws CLI (only kind-branching provider) +│ └── azure-key-vault/ # az CLI +├── tests/ # BATS — mirrors source structure +└── docs/ # This file, configuration.md, adding_a_provider.md +``` + +See `parameters/providers/README.md` for the provider contract spec. +See `configuration.md` for the payload shape and how `PROVIDER_CONFIG` is structured. +See `adding_a_provider.md` to drop in a new backend. diff --git a/parameters/docs/configuration.md b/parameters/docs/configuration.md new file mode 100644 index 00000000..97d819cc --- /dev/null +++ b/parameters/docs/configuration.md @@ -0,0 +1,145 @@ +# Configuration + +How the parameters package decides which provider to use and where each provider gets its config — all from the notification payload. + +--- + +## Where everything comes from + +Each notification from nullplatform includes the full information needed to handle the parameter: + +| Field in `$CONTEXT` | Purpose | +|---|---| +| `parameter_id` | nullplatform parameter ID | +| `value` | the value to persist (only on store) | +| `external_id` | provider's handle for the parameter (on retrieve/delete/notify) | +| `secret` | bool — discriminates secret vs plain parameter | +| `parameter_name` | human-readable name | +| `encoding` | encoding of the value (`plain`, `base64`, etc.) | +| `entities` | NRN parsed into entity IDs (organization, account, namespace, application) | +| `dimensions` | optional object — parameter scoping (env, country, etc.) | +| **`provider.specification_id`** | **UUID identifying which provider handles this parameter** | +| **`provider.attributes`** | **Provider-specific configuration (region, vault address, etc.)** | +| `provider.nrn` | Provider-instance NRN (informational) | +| `provider.dimensions` | Provider-instance dimensions. **Do NOT use this field** — it is internal to the platform's provider system and unrelated to the parameter's `.dimensions`. Parameter dimensions come from top-level `.dimensions` only. | +| `provider.id` | Provider-instance ID (informational) | + +The two fields that drive the dispatch are `provider.specification_id` (which provider) and `provider.attributes` (its config). + +--- + +## Provider resolution + +`build_context` calls: + +```bash +np provider specification read --id --format json +``` + +The response includes a `slug` field. That slug must match the name of a directory under `parameters/providers/`. For example: + +| Slug returned | Provider directory used | +|---|---| +| `hashicorp-vault` | `parameters/providers/hashicorp-vault/` | +| `aws-secrets-manager` | `parameters/providers/aws-secrets-manager/` | +| `aws-parameter-store` | `parameters/providers/aws-parameter-store/` | +| `azure-key-vault` | `parameters/providers/azure-key-vault/` | + +If the slug doesn't match any installed provider, `build_context` fails with a list of available providers and instructions to either rename the spec slug or add the missing provider. + +--- + +## Provider config + +`build_context` exports `PROVIDER_CONFIG` as a JSON string containing whatever is in `$CONTEXT.provider.attributes`. The shape is provider-specific. + +Each provider's `setup` script reads from `PROVIDER_CONFIG` via `get_config_value`: + +```bash +REGION=$(get_config_value --env AWS_REGION --provider '.region') +``` + +Priority order (highest to lowest): + +1. Provider config (`get_config_value --provider '.field'`) +2. Environment variable (`get_config_value --env NAME`) +3. Default (`get_config_value --default 'value'`) + +Env vars take precedence ONLY when the provider attribute is missing. This lets you override config in a local dev environment by setting env vars while keeping the platform-controlled config as the production source of truth. + +--- + +## Per-provider config shapes + +The shape of `$CONTEXT.provider.attributes` for each provider: + +### `hashicorp-vault` + +```json +{ + "address": "https://vault.example.com", + "token": "hvs.xxx", + "path_prefix": "secret/data/parameters" +} +``` + +### `aws-secrets-manager` (currently named `aws-secrets-manager`) + +```json +{ + "region": "us-east-1", + "name_prefix": "parameters/", + "kms_key_id": "alias/aws/secretsmanager" +} +``` + +`kms_key_id` is optional (defaults to AWS-managed key). + +### `aws-parameter-store` + +```json +{ + "region": "us-east-1", + "name_prefix": "/nullplatform/parameters/", + "kms_key_id": "alias/parameters-secure", + "tier": "Standard" +} +``` + +`kms_key_id` only matters for `kind=secret` (SecureString). `tier` ∈ {`Standard`, `Advanced`, `Intelligent-Tiering`}. + +### `azure-key-vault` + +```json +{ + "vault_name": "my-keyvault", + "secret_prefix": "parameters-" +} +``` + +Authentication comes from the Azure CLI's default credential chain. + +--- + +## Local development + +For local testing without involving the platform, set the relevant env vars and use a stubbed `np` CLI that returns a known slug: + +```bash +# Stub np in PATH +cat > /tmp/np << 'EOF' +#!/bin/bash +echo '{"slug": "hashicorp-vault"}' +EOF +chmod +x /tmp/np +export PATH=/tmp:$PATH + +# Set the provider's env vars +export VAULT_ADDR=http://localhost:8200 +export VAULT_TOKEN=root-token + +# Now invoke the entrypoint +NP_ACTION_CONTEXT='...' NOTIFICATION_ACTION='parameter:store' ./parameters/entrypoint +``` + +All providers fall through to env vars when `PROVIDER_CONFIG` is missing fields, making local-only iteration possible. diff --git a/parameters/entrypoint b/parameters/entrypoint new file mode 100755 index 00000000..0ad0d078 --- /dev/null +++ b/parameters/entrypoint @@ -0,0 +1,71 @@ +#!/bin/bash +set -euo pipefail + +# Entry point for the parameters package. +# - Cleans NP_ACTION_CONTEXT (strips surrounding single quotes some runners add) +# - Exports CONTEXT scoped to the .notification body (what every script reads) +# - Resolves the workflow file from the action name (parameter:) +# - Honors OVERRIDES_PATH for consumer-side workflow overrides +# +# Kind discrimination (secret vs parameter) is NOT done here. It's derived at +# the script layer: build_context reads $CONTEXT.secret and exports +# PARAMETER_KIND; providers that branch on it (e.g. aws-parameter-store choosing +# String vs SecureString) read PARAMETER_KIND directly. + +ENTRYPOINT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "$ENTRYPOINT_DIR/utils/log" ] && source "$ENTRYPOINT_DIR/utils/log" +T_TOTAL=$(timer_now 2>/dev/null || echo 0) +T_ENTRY_PREP=$(timer_now 2>/dev/null || echo 0) + +if [ -z "${NP_ACTION_CONTEXT:-}" ]; then + echo "❌ NP_ACTION_CONTEXT is not set" >&2 + exit 1 +fi + +type -t log >/dev/null 2>&1 && log debug "🚀 entrypoint start (pid=$$)" + +CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//") +export NP_ACTION_CONTEXT="$CLEAN_CONTEXT" +export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification') + +NOTIFICATION_ACTION=$(echo "$CONTEXT" | jq -r '.action // empty') +IFS=':' read -ra ACTION_PARTS <<< "$NOTIFICATION_ACTION" +ACTION_TO_EXECUTE="${ACTION_PARTS[1]:-}" + +if [ -z "$ACTION_TO_EXECUTE" ]; then + echo "❌ CONTEXT.action is missing the action part (got '$NOTIFICATION_ACTION', expected 'parameter:')" >&2 + exit 1 +fi + +SERVICE_PATH=$ENTRYPOINT_DIR + +export SERVICE_PATH + +WORKFLOW_PATH="$ENTRYPOINT_DIR/workflows/$ACTION_TO_EXECUTE.yaml" +if [ ! -f "$WORKFLOW_PATH" ]; then + echo "❌ No workflow found at $WORKFLOW_PATH" >&2 + exit 1 +fi + +CMD="np service workflow exec --no-output --workflow $WORKFLOW_PATH" + +if [ -n "${OVERRIDES_PATH:-}" ]; then + IFS=',' read -ra OVERRIDE_PATHS <<< "$OVERRIDES_PATH" + for path in "${OVERRIDE_PATHS[@]}"; do + path=$(echo "$path" | xargs) + [ -z "$path" ] && continue + override_yaml="$path/parameters/workflows/$ACTION_TO_EXECUTE.yaml" + [ -f "$override_yaml" ] && CMD="$CMD --overrides $override_yaml" + done +fi + +type -t log >/dev/null 2>&1 && log debug "⏱ entrypoint.prep $(timer_elapsed "$T_ENTRY_PREP" 2>/dev/null || echo "?")" + +T_WF=$(timer_now 2>/dev/null || echo 0) +WF_RC=0 +eval $CMD || WF_RC=$? +if type -t log >/dev/null 2>&1; then + log debug "⏱ np service workflow exec $(timer_elapsed "$T_WF" 2>/dev/null || echo "?")" + log debug "⏱ entrypoint total $(timer_elapsed "$T_TOTAL" 2>/dev/null || echo "?")" +fi +exit $WF_RC diff --git a/parameters/providers/README.md b/parameters/providers/README.md new file mode 100644 index 00000000..93432aee --- /dev/null +++ b/parameters/providers/README.md @@ -0,0 +1,184 @@ +# Provider Contract + +This directory contains all concrete provider implementations. Each subdirectory is one provider — fully self-contained. + +The dispatch layer (`parameters/build_context` + `parameters/{store,retrieve,delete,notify}`) is provider-agnostic. It selects a provider at runtime from env vars `SECRET_PROVIDER` / `PARAMETER_PROVIDER` and sources the matching scripts. + +**Adding a new provider is a strictly additive change**: drop a directory here that satisfies this contract. No edits to dispatch, build_context, workflows, or other providers are required. The parameters package has zero knowledge of any specific provider. + +--- + +## Required layout + +``` +providers// +├── fetch_configuration # (optional) Fetch this provider's config from wherever it lives +├── setup # (optional) Validate config, prepare connection handles +├── store # (required) Persist a parameter value +├── retrieve # (required) Read a value by external_id +├── delete # (required) Idempotent delete by external_id +├── notify # (optional) Per-provider notify hook (default is {"success":true}) +└── docs/ # (recommended) architecture.md, iam-policy.md, etc. +``` + +`` is the string users set in `SECRET_PROVIDER` / `PARAMETER_PROVIDER`. Use `snake_case` (e.g. `hashicorp-vault`, `azure-key-vault`, `aws-parameter-store`). + +--- + +## Lifecycle of one workflow run + +1. `entrypoint` cleans `NP_ACTION_CONTEXT`, exports `CONTEXT` (= notification body), routes to the right workflow YAML. +2. Workflow's `build_context` step: + - Determines `PARAMETER_KIND` from workflow `configuration` or `$CONTEXT.secret`. + - Resolves `ACTIVE_PROVIDER` from `SECRET_PROVIDER` or `PARAMETER_PROVIDER` env var. + - Sources `providers/$ACTIVE_PROVIDER/fetch_configuration` if present. + - Sources `providers/$ACTIVE_PROVIDER/setup` if present. +3. Workflow's operation step (`store`/`retrieve`/`delete`/`notify`) sources `providers/$ACTIVE_PROVIDER/` and produces the JSON response. + +All steps share the same bash session — env vars set in any step are visible to the next. + +--- + +## Environment available to your scripts + +By the time any of your scripts runs, `build_context` has exported: + +| Variable | Description | +|----------------------|-----------------------------------------------------------------| +| `CONTEXT` | JSON of the notification body (`.notification` of the action) | +| `PARAMETER_KIND` | `"secret"` or `"parameter"` | +| `EXTERNAL_ID` | Existing handle for retrieve/delete/notify; empty for store | +| `PARAMETER_ID` | nullplatform parameter ID | +| `PARAMETER_VALUE` | The value to store (only set for store) | +| `PARAMETER_NAME` | Display name (e.g. `DB_PASSWORD`) | +| `PARAMETER_ENCODING` | Encoding of the value (e.g. `plain`, `base64`) | +| `PROVIDER_DIR` | Absolute path to your provider directory | +| `PARAMETERS_ROOT` | Absolute path to the parameters package root | +| `PROVIDER_CONFIG` | (optional) JSON your `fetch_configuration` set — its shape is up to you | + +The function `get_config_value` is already sourced — see usage below. + +--- + +## `fetch_configuration` (optional) + +Your provider's place to bring config in from the outside world. Sourced **once** at the start of every workflow run, before `setup`. + +Free-form by design — each provider knows best how to fetch its own config. Examples: + +- Call `np provider get --type ` and parse JSON +- `curl` a REST endpoint +- Read a file mounted by the runner +- Just rely on env vars (do nothing — omit the file) + +Convention: if you produce a JSON blob with your config, export it as `PROVIDER_CONFIG`. Then `get_config_value --provider '.field'` reads from it directly: + +```bash +#!/bin/bash +# providers/example/fetch_configuration +PROVIDER_CONFIG=$(np provider get --type my-thing --output json) +export PROVIDER_CONFIG +``` + +Then in `setup`: + +```bash +ADDR=$(get_config_value --env MY_ADDR --provider '.address') +``` + +If you don't need provider config, just skip `fetch_configuration` entirely. Operations can use env vars directly: + +```bash +ADDR="${MY_ADDR:-}" +[ -z "$ADDR" ] && { log error "❌ MY_ADDR not set"; exit 1; } +``` + +--- + +## `setup` (optional) + +Sourced after `fetch_configuration`. Use it to: + +1. Read provider-specific config (from env vars and/or `PROVIDER_CONFIG`). +2. Validate that all required fields are present. Fail fast with troubleshooting guidance if not. +3. Export connection handles (URLs, tokens, regions, prefixes) for the operation scripts. + +Do **not** repeat credential validation inside `store`/`retrieve`/`delete`. That's the whole point of `setup`. + +Example: + +```bash +#!/bin/bash +# providers/hashicorp-vault/setup +VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') +VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') + +[ -z "$VAULT_ADDR" ] && { log error "❌ vault address missing"; exit 1; } +[ -z "$VAULT_TOKEN" ] && { log error "❌ vault token missing"; exit 1; } + +export VAULT_ADDR VAULT_TOKEN +``` + +--- + +## Operation scripts + +Each produces **JSON on stdout** and routes **error messages to stderr**. The platform parses stdout as the action result. + +### `store` — required + +Input env: `PARAMETER_VALUE`, `PARAMETER_ID`, `PARAMETER_KIND`, plus your `setup` exports. + +Output: +```json +{ + "external_id": "", + "metadata": { "...": "provider-specific" } +} +``` + +`external_id` becomes the canonical handle. `metadata` is opaque to nullplatform but useful for auditing. + +### `retrieve` — required + +Input env: `EXTERNAL_ID`, plus `setup` exports. + +Output: +```json +{ "value": "" } +``` + +If not found, return `{"value": "value not found"}` rather than erroring (precedent: existing vault/aws-secrets-manager impls). + +### `delete` — required + +Input env: `EXTERNAL_ID`, plus `setup` exports. + +Output: +```json +{ "success": true } +``` + +Must be **idempotent**: re-deleting a missing handle is not an error. + +### `notify` — optional + +Input env: `EXTERNAL_ID`, `PARAMETER_ID`, plus `setup` exports. + +Output: +```json +{ "success": true } +``` + +Omit the file if your provider has nothing to do — the dispatch returns the default ack. + +--- + +## Conventions + +- Start every script with `set -euo pipefail`. +- Use `log error "..."` for error messages — it routes to stderr automatically. +- Every error message must include `💡 Possible causes:` and `🔧 How to fix:` blocks. +- Never print anything to stdout other than the final JSON result. The platform reads stdout literally. +- Don't validate `PROVIDER_DIR`, `EXTERNAL_ID`, or other dispatch-exported vars — assume `build_context` produced valid state. Validate only your provider-specific config in `setup`. +- Each operation should be **idempotent where it makes sense** (delete always, retrieve when missing, store typically not — the platform enforces store idempotency at its layer). diff --git a/parameters/providers/aws-parameter-store/delete b/parameters/providers/aws-parameter-store/delete new file mode 100755 index 00000000..f48dfc24 --- /dev/null +++ b/parameters/providers/aws-parameter-store/delete @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a parameter from AWS Parameter Store. +# +# Idempotency semantics: +# - Successful delete → success +# - ParameterNotFound → success (already gone) +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, PS_NAME_PREFIX + +T_PS_DELETE=$(timer_now) + +# SSM path components only accept [A-Za-z0-9._-]; `=` is replaced with `_` +# (same transformation as store). +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID_PATH//=/_}" + +err_file=$(mktemp) +T_AWS=$(timer_now) +if aws ssm delete-parameter \ + --region "$AWS_REGION" \ + --name "$PARAM_NAME" >/dev/null 2>"$err_file"; then + log debug " ⏱ aws ssm delete-parameter $(timer_elapsed "$T_AWS")" + rm -f "$err_file" + echo '{ + "success": true +}' + log debug "⏱ ps.delete total $(timer_elapsed "$T_PS_DELETE")" +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ParameterNotFound"; then + log debug "Parameter '$PARAM_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + else + log error "❌ Failed to delete parameter '$PARAM_NAME' in AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:DeleteParameter on this resource" + log error " • Region '$AWS_REGION' unreachable or wrong" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/aws-parameter-store/docs/architecture.md b/parameters/providers/aws-parameter-store/docs/architecture.md new file mode 100644 index 00000000..854ad6cd --- /dev/null +++ b/parameters/providers/aws-parameter-store/docs/architecture.md @@ -0,0 +1,107 @@ +# AWS Systems Manager Parameter Store — Provider Architecture + +This document describes the `parameters/providers/aws-parameter-store/` implementation. It stores nullplatform parameters in AWS SSM Parameter Store, using `String` for plain parameters and `SecureString` (KMS-encrypted) for secrets. + +Cheapest provider in the package — Standard tier is free up to 10,000 parameters. + +--- + +## Lifecycle + +| Step | What happens | +|------|-----------------------------------------------------------------------------| +| `setup` | `AWS_REGION` from the agent runtime (IRSA / instance profile). `PS_NAME_PREFIX` hardcoded to `/nullplatform/`. Reads `PS_KMS_KEY_ID` and `PS_TIER` from `PROVIDER_CONFIG`. | +| `store` | Composes path via `build_external_id`. Calls `aws ssm put-parameter --overwrite`. Captures `.Version` from response. Returns `external_id = #`. | +| `retrieve` | Parses path + version. Calls `aws ssm get-parameter --with-decryption`. If a version is present in external_id, appends `:` to target that specific version. | +| `delete` | Calls `aws ssm delete-parameter`. Idempotent (suppresses `ParameterNotFound`). | +| `notify` | Not implemented — dispatcher returns default `{success: true}`. | + +--- + +## Storage layout + +Every parameter name is composed by `parameters/utils/build_external_id`: + +``` +/nullplatform/organization=-/account=-/namespace=-/application=-[/scope=-][/=...]/- +``` + +The `scope` entity is optional (only present when the parameter is bound to a deployment scope). Dimensions are also optional. See `parameters/docs/architecture.md` for the complete naming convention. + +The `/nullplatform/` prefix is hardcoded — it's the invariant namespace anchor for all platform parameters and the basis for least-privilege IAM scoping (`parameter/nullplatform/*`). + +Example: + +``` +/nullplatform/organization=acme-1255165411/account=prod-95118862/.../DB_PASSWORD-42 +``` + +IAM can target the hierarchy via ARN pattern `arn:aws:ssm:::parameter/nullplatform/*`. + +--- + +## Type selection via PARAMETER_KIND + +This is the only provider in the package that branches on `PARAMETER_KIND`: + +| Kind | SSM Type | KMS | +|-------------|-----------------|------------------------------------------------------| +| `parameter` | `String` | None (plain text) | +| `secret` | `SecureString` | `PS_KMS_KEY_ID` if set, otherwise `alias/aws/ssm` | + +For other providers in the package, kind is informational — their backends encrypt all values uniformly. + +--- + +## Versioning + +Parameter Store retains versions automatically. `put-parameter --overwrite` creates a new version on every call. + +### Version identity in external_id + +The `external_id` returned by `store` encodes both the path and the version: + +``` +# +``` + +For Parameter Store, `version_id` is **the literal integer `.Version` returned by `put-parameter`** — we do not invent or normalize it. AWS returns it; we copy it verbatim. Real example: + +``` +organization=acme-1255165411/.../DB_PASSWORD-42#7 +``` + +Here `7` means "the 7th version of this parameter". SSM addresses historical versions by suffixing the parameter name with `:`, so on retrieve we call `get-parameter --name ":7"`. + +On `retrieve`: +- With `#` → fetch version N. +- Without → fetch latest. + +On `delete`, the version suffix is ignored — `delete-parameter` removes all versions. + +--- + +## Tiers + +| Tier | Free | Value size | Use case | +|-----------------------|-----------------------|-------------|-----------------------------------| +| `Standard` (default) | up to 10,000 params | 4 KB | Most cases | +| `Advanced` | $0.05/param/month | 8 KB | Large values or > 10k params | +| `Intelligent-Tiering` | Auto-promotes | varies | Mixed sizes | + +Switch tiers via provider config `tier` attribute. + +--- + +## Configuration + +`PROVIDER_CONFIG` shape (only operator-configurable knobs — `AWS_REGION` comes from the agent runtime, `name_prefix` is hardcoded to `/nullplatform/`): + +```json +{ + "kms_key_id": "alias/parameters-secure", + "tier": "Standard" +} +``` + +`kms_key_id` only matters for `kind=secret` (SecureString). For `kind=parameter` (String) it's ignored. diff --git a/parameters/providers/aws-parameter-store/docs/iam-policy.md b/parameters/providers/aws-parameter-store/docs/iam-policy.md new file mode 100644 index 00000000..27300fcc --- /dev/null +++ b/parameters/providers/aws-parameter-store/docs/iam-policy.md @@ -0,0 +1,101 @@ +# IAM Policy + +Minimum IAM permissions for `parameters/providers/aws-parameter-store/`. Scoped to the `nullplatform/*` namespace so the agent cannot reach any parameter outside this provider's domain. + +--- + +## Required actions + +| Action | Used by | Why | +|------------------------------|------------|--------------------------------------------------------| +| `ssm:PutParameter` | `store` | Creates the parameter (String or SecureString) | +| `ssm:GetParameter` | `retrieve` | Reads the value back | +| `ssm:DeleteParameter` | `delete` | Removes the parameter | + +--- + +## Recommended policy + +Replace `` and `` before applying. The `nullplatform/*` resource pattern restricts the agent to parameters created and managed by this provider. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageNullplatformParameters", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:GetParameter", + "ssm:DeleteParameter" + ], + "Resource": [ + "arn:aws:ssm:::parameter/nullplatform/*" + ] + } + ] +} +``` + +--- + +## KMS (only when storing SecureString with a CMK) + +If the provider's configuration sets `kms_key_id` to a customer-managed key (rather than the default `alias/aws/ssm`), the agent also needs KMS permissions on that key: + +```json +{ + "Sid": "UseCustomerManagedKmsKeyForParameterStore", + "Effect": "Allow", + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey" + ], + "Resource": [ + "arn:aws:kms:::key/" + ], + "Condition": { + "StringEquals": { + "kms:ViaService": "ssm..amazonaws.com" + } + } +} +``` + +The `kms:ViaService` condition restricts the key to SSM use — without it, the role could decrypt arbitrary ciphertexts encrypted with the same key. The CMK's **key policy** must also allow the role principal; IAM permissions alone aren't enough for KMS. + +If you use the default `alias/aws/ssm` (AWS-managed), no extra KMS statement is needed — Parameter Store handles encryption transparently. + +--- + +## Splitting agent vs consumer + +The writer (this provider's scripts) needs put + get + delete. A runtime consumer typically only needs read: + +```json +{ + "Sid": "ReadNullplatformParameters", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": [ + "arn:aws:ssm:::parameter/nullplatform/*" + ] +} +``` + +`GetParametersByPath` is useful if a consumer wants to enumerate all parameters under a hierarchical prefix (e.g. fetching all secrets for an app in one call). + +--- + +## What not to grant + +- `ssm:*` (account-wide) — opens access to OS commands (`AWS-RunShellScript`), maintenance windows, session manager, etc. +- `ssm:PutParameter` with `Resource: "*"` — lets the role write to ANY parameter in the account (including other apps' secrets). +- `ssm:LabelParameterVersion`, `ssm:UnlabelParameterVersion` — versioning workflows; not used by this provider. +- `iam:*` — this provider doesn't manage IAM. diff --git a/parameters/providers/aws-parameter-store/retrieve b/parameters/providers/aws-parameter-store/retrieve new file mode 100755 index 00000000..fdccf792 --- /dev/null +++ b/parameters/providers/aws-parameter-store/retrieve @@ -0,0 +1,67 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a parameter from AWS Parameter Store by external_id. +# Uses --with-decryption (no-op for String, decrypts SecureString). +# +# Semantics: +# - Success → return {value: ""} +# - ParameterNotFound → return {value: "value not found"} +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, PS_NAME_PREFIX + +T_PS_RETRIEVE=$(timer_now) + +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context. +# SSM path components only accept [A-Za-z0-9._-]; `=` is replaced with `_` +# (same transformation as store). +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID_PATH//=/_}" + +# PS addresses historical versions by suffixing `:` to the parameter name. +LOOKUP_NAME="$PARAM_NAME" +[ -n "${EXTERNAL_ID_VERSION:-}" ] && LOOKUP_NAME="${PARAM_NAME}:${EXTERNAL_ID_VERSION}" + +err_file=$(mktemp) +T_AWS=$(timer_now) +if VALUE=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$LOOKUP_NAME" \ + --with-decryption \ + --query Parameter.Value \ + --output text 2>"$err_file"); then + log debug " ⏱ aws ssm get-parameter $(timer_elapsed "$T_AWS")" + rm -f "$err_file" + jq -n --arg value "$VALUE" '{value: $value}' + log debug "⏱ ps.retrieve total $(timer_elapsed "$T_PS_RETRIEVE")" +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ParameterNotFound"; then + log error "❌ Parameter '$PARAM_NAME' not found in AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • The parameter was manually deleted from AWS SSM" + log error " • The external_id is stale" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' is outside Parameter Store's retention window" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify: aws ssm describe-parameters --filters Key=Name,Values=$PARAM_NAME --region $AWS_REGION" + log error " • If genuinely missing, the parameter value needs to be re-stored" + exit 1 + else + log error "❌ Failed to retrieve parameter '$PARAM_NAME' from AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:GetParameter on this resource" + log error " • KMS key permission missing (kms:Decrypt) for SecureString" + log error " • Region '$AWS_REGION' unreachable" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/aws-parameter-store/setup b/parameters/providers/aws-parameter-store/setup new file mode 100755 index 00000000..2ad72652 --- /dev/null +++ b/parameters/providers/aws-parameter-store/setup @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +# Validates AWS Systems Manager Parameter Store connection config. +# +# AWS_REGION is injected by the agent's runtime, not by provider config. +# The name prefix is hardcoded to `/nullplatform/` — invariant namespace for +# all platform parameters. +# +# Operator-configurable: kms_key_id (for SecureString) and tier. +# +# Required env (from build_context): +# PARAMETER_KIND — "secret" or "parameter"; determines String vs SecureString +# +# Exports: AWS_REGION, PS_NAME_PREFIX, PS_KMS_KEY_ID, PS_TIER + +: "${AWS_REGION:?AWS_REGION must be set by the agent runtime}" + +# Resolve and assume the platform-configured IAM role (if any). After this, +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN are set in the +# environment, so every subsequent aws-cli call inherits the assumed identity. +ASSUME_ROLE_SELECTOR="parameter_store" +ASSUME_ROLE_OVERRIDE_ENV="PARAMETER_STORE_ASSUME_ROLE_ARN" +ASSUME_ROLE_DEFAULT_ENV="PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT" +ASSUME_ROLE_SESSION_PREFIX="np-parameter-store" +source "$PARAMETERS_ROOT/utils/assume_role_step" + +PS_NAME_PREFIX="/nullplatform/" + +PS_KMS_KEY_ID=$(get_config_value \ + --provider '.setup.kms_key_id' \ + --default '') + +PS_TIER=$(get_config_value \ + --provider '.setup.tier' \ + --default 'Standard') + +case "$PS_TIER" in + Standard|Advanced|Intelligent-Tiering) ;; + *) + log error "❌ Invalid PS_TIER '$PS_TIER'" + log error "" + log error "🔧 How to fix:" + log error " • Set PS_TIER to one of: Standard, Advanced, Intelligent-Tiering" + exit 1 + ;; +esac + +export AWS_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER diff --git a/parameters/providers/aws-parameter-store/specs/aws-parameter-store-configuration.json.tpl b/parameters/providers/aws-parameter-store/specs/aws-parameter-store-configuration.json.tpl new file mode 100644 index 00000000..0cf68b02 --- /dev/null +++ b/parameters/providers/aws-parameter-store/specs/aws-parameter-store-configuration.json.tpl @@ -0,0 +1,68 @@ +{ + "name": "AWS Parameter Store", + "description": "Stores nullplatform parameter values in AWS SSM Parameter Store with native versioning. Cheapest option (Standard tier is free up to 10,000 parameters)", + "slug": "aws-parameter-store", + "category": "parameters-storage", + "icon": "mdi:aws", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "allow_dimensions": true, + "schema": { + "type": "object", + "required": ["sensibility", "setup"], + "additionalProperties": false, + "properties": { + "sensibility": { + "type": "object", + "order": 1, + "required": ["applies_to"], + "description": "The sensibility of the parameters stored in this backend.", + "properties": { + "applies_to": { + "type": "array", + "title": "Applies to", + "description": "Which parameters this backend stores — secret, non-secret, or both.", + "order": 1, + "inline": true, + "uniqueItems": true, + "minItems": 1, + "default": ["non_secret"], + "items": { + "oneOf": [ + { "const": "secret", "title": "Secret parameters" }, + { "const": "non_secret", "title": "Non-secret parameters" } + ] + } + } + } + }, + "setup": { + "type": "object", + "order": 2, + "description": "The setup for the AWS Parameter Store backend.", + "properties": { + "kms_key_id": { + "type": "string", + "title": "KMS Key ID (optional)", + "description": "Customer-managed KMS key for SecureString parameters. If empty, the default alias/aws/ssm key is used", + "default": "", + "order": 1 + }, + "tier": { + "type": "string", + "title": "Parameter Tier", + "description": "Standard is free for up to 10,000 parameters. Advanced supports larger values but costs $0.05/param/month", + "default": "Standard", + "order": 2, + "oneOf": [ + { "const": "Standard", "title": "Standard (free)" }, + { "const": "Advanced", "title": "Advanced ($0.05/param/month)" }, + { "const": "Intelligent-Tiering", "title": "Intelligent-Tiering" } + ] + } + } + } + } + } +} diff --git a/parameters/providers/aws-parameter-store/specs/data.tf b/parameters/providers/aws-parameter-store/specs/data.tf new file mode 100644 index 00000000..3e34413c --- /dev/null +++ b/parameters/providers/aws-parameter-store/specs/data.tf @@ -0,0 +1,13 @@ +################################################################################ +# Optional: IAM role with least-privilege permissions for this provider. +# Toggle with var.iam_role.enable. Outputs the role ARN so operators can wire +# it into the identity-access-control provider config (selector="parameter_store"). +################################################################################ + +data "aws_caller_identity" "current" { + count = var.iam_role.enable ? 1 : 0 +} + +data "aws_region" "current" { + count = var.iam_role.enable ? 1 : 0 +} diff --git a/parameters/providers/aws-parameter-store/specs/locals.tf b/parameters/providers/aws-parameter-store/specs/locals.tf new file mode 100644 index 00000000..a0691c80 --- /dev/null +++ b/parameters/providers/aws-parameter-store/specs/locals.tf @@ -0,0 +1,79 @@ +locals { + template_path = "${path.module}/aws-parameter-store-configuration.json.tpl" + template_raw = file(local.template_path) + template_rendered = replace(local.template_raw, "{{ env.Getenv \"NRN\" }}", var.nrn) + config = jsondecode(local.template_rendered) + cmdline_path = "nullplatform/scopes/parameters/entrypoint" + + instance_nrns = distinct([for _, inst in var.instances : inst.nrn]) + spec_visible_to = distinct(concat( + [var.nrn], + local.instance_nrns, + var.extra_visible_to_nrns, + )) + + iam_enabled = var.iam_role.enable + aws_account_id = local.iam_enabled ? data.aws_caller_identity.current[0].account_id : "" + aws_region = local.iam_enabled ? data.aws_region.current[0].name : "" + + # If trusted_principals isn't provided, default to the current account root. + # That allows IAM principals within the account to assume the role (subject + # to their own IAM policies). To lock it down further, pass explicit ARNs. + effective_trusted_principals = local.iam_enabled ? ( + length(var.iam_role.trusted_principals) > 0 + ? var.iam_role.trusted_principals + : ["arn:aws:iam::${local.aws_account_id}:root"] + ) : [] + + base_policy_statement = { + Sid = "ManageNullplatformParameters" + Effect = "Allow" + Action = [ + "ssm:PutParameter", + "ssm:GetParameter", + "ssm:DeleteParameter", + ] + Resource = "arn:aws:ssm:${local.aws_region}:${local.aws_account_id}:parameter/nullplatform/*" + } + + kms_policy_statement = { + Sid = "UseCustomerManagedKmsKey" + Effect = "Allow" + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + ] + Resource = var.iam_role.kms_key_arn + Condition = { + StringEquals = { + "kms:ViaService" = "ssm.${local.aws_region}.amazonaws.com" + } + } + } + + # Build the policy JSON conditionally at the string level — Terraform's strict + # typing rejects ternaries that return tuples of differently-shaped objects + # (base has 4 keys, kms statement adds Condition for the 5th). + policy_doc = var.iam_role.mode == "with_kms" ? jsonencode({ + Version = "2012-10-17" + Statement = [local.base_policy_statement, local.kms_policy_statement] + }) : jsonencode({ + Version = "2012-10-17" + Statement = [local.base_policy_statement] + }) + + # Instances that get their own agent API key + notification channel. + notification_instances = { + for key, instance in var.instances : key => instance + if instance.notification_channel_enabled + } + + api_key_grants = [ + "controlplane:agent", + "developer", + "ops", + "secops", + "secrets-reader", + ] +} \ No newline at end of file diff --git a/parameters/providers/aws-parameter-store/specs/main.tf b/parameters/providers/aws-parameter-store/specs/main.tf new file mode 100644 index 00000000..19f49de9 --- /dev/null +++ b/parameters/providers/aws-parameter-store/specs/main.tf @@ -0,0 +1,127 @@ +################################################################################ +# AWS Parameter Store — specs module +# +# Responsibilities: +# +# 1. nullplatform_provider_specification.this +# Created from ./aws-parameter-store-configuration.json.tpl. The JSON file +# is the canonical declaration of the provider's metadata and its config +# schema (kms_key_id, tier). +# +# 2. module.scope_configuration (for_each = var.instances) +# One concrete instance per entry in var.instances, each with its own NRN, +# dimensions, KMS key, and tier. Delegates to the upstream +# `nullplatform/scope_configuration` module. +# +# 3. nullplatform_api_key.this + nullplatform_notification_channel.from_template +# Per instance (unless notification_channel_enabled=false): an agent API key +# and its notification channel, anchored at the instance NRN, that handle +# parameter storage and retrieval. +# +# 4. aws_iam_role.this (optional, var.iam_role.enable) +# Least-privilege role for the provider. See data.tf / locals.tf. +################################################################################ + +resource "nullplatform_provider_specification" "this" { + name = local.config.name + icon = local.config.icon + description = local.config.description + category = local.config.category + allow_dimensions = local.config.allow_dimensions + visible_to = local.spec_visible_to + schema = jsonencode(local.config.schema) +} + +module "scope_configuration" { + for_each = var.instances + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_configuration?ref=v4.5.1" + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = local.config.slug + dimensions = each.value.dimensions + + attributes = { + sensibility = { + applies_to = each.value.applies_to + } + setup = { + kms_key_id = each.value.kms_key_id + tier = each.value.tier + } + } + + depends_on = [nullplatform_provider_specification.this] +} + +resource "nullplatform_api_key" "this" { + for_each = local.notification_instances + + name = "parameter-api-key-${each.key}" + dynamic "grants" { + for_each = toset(local.api_key_grants) + content { + nrn = each.value.nrn + role_slug = grants.value + } + } + + tags { + key = "managedBy" + value = "IaC" + } + + lifecycle { + create_before_destroy = true + } +} + +resource "nullplatform_notification_channel" "from_template" { + for_each = local.notification_instances + + nrn = each.value.nrn + type = "agent" + source = ["parameters"] + description = "Notification channel to handle parameter storage and retrieval" + configuration { + agent { + api_key = nullplatform_api_key.this[each.key].api_key + selector = each.value.tags_selectors + command { + data = { + "cmdline" : local.cmdline_path + "environment" : jsonencode({ + NP_ACTION_CONTEXT = "'$${NOTIFICATION_CONTEXT}'" + LOG_LEVEL = "debug" + }) + } + type = "exec" + } + } + } +} + +resource "aws_iam_role" "this" { + count = local.iam_enabled ? 1 : 0 + name = var.iam_role.name + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = local.effective_trusted_principals + } + Action = "sts:AssumeRole" + } + ] + }) +} + +resource "aws_iam_role_policy" "this" { + count = local.iam_enabled ? 1 : 0 + name = "${var.iam_role.name}-policy" + role = aws_iam_role.this[0].name + policy = local.policy_doc +} diff --git a/parameters/providers/aws-parameter-store/specs/outputs.tf b/parameters/providers/aws-parameter-store/specs/outputs.tf new file mode 100644 index 00000000..bd7e7a75 --- /dev/null +++ b/parameters/providers/aws-parameter-store/specs/outputs.tf @@ -0,0 +1,19 @@ +output "specification_id" { + description = "ID of the nullplatform_provider_specification created from aws-parameter-store-configuration.json.tpl." + value = nullplatform_provider_specification.this.id +} + +output "iam_role_arn" { + description = "ARN of the IAM role created when iam_role.enable=true. Wire this into the identity-access-control provider's iam_role_arns.arns[] with selector=\"parameter_store\". Empty when iam_role.enable=false." + value = length(aws_iam_role.this) > 0 ? aws_iam_role.this[0].arn : "" +} + +output "notification_channel_ids" { + description = "Map of instance key => ID of its agent notification channel. Only includes instances with notification_channel_enabled=true." + value = { for key, channel in nullplatform_notification_channel.from_template : key => channel.id } +} + +output "notification_channel_statuses" { + description = "Map of instance key => status of its agent notification channel. Only includes instances with notification_channel_enabled=true." + value = { for key, channel in nullplatform_notification_channel.from_template : key => channel.status } +} diff --git a/parameters/providers/aws-parameter-store/specs/terraform.tfvars.example b/parameters/providers/aws-parameter-store/specs/terraform.tfvars.example new file mode 100644 index 00000000..49b69d62 --- /dev/null +++ b/parameters/providers/aws-parameter-store/specs/terraform.tfvars.example @@ -0,0 +1,35 @@ +nrn = "organization=1" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +instances = { + prod-billing = { + nrn = "organization=1:account=2:namespace=3" + dimensions = { environment = "production" } + kms_key_id = "alias/parameters-prod-billing" + tier = "Standard" + applies_to = ["non_secret"] + + # Each instance gets its own agent API key + notification channel anchored at its nrn. + tags_selectors = { environment = "production" } + } + staging-billing = { + nrn = "organization=1:account=2:namespace=3" + dimensions = { environment = "staging" } + kms_key_id = "" + tier = "Advanced" + applies_to = ["non_secret"] + + # Skip the agent channel + API key for this instance (e.g. managed elsewhere). + notification_channel_enabled = false + } +} + +iam_role = { + enable = true + name = "nullplatform-parameter-store" + mode = "with_kms" + trusted_principals = ["arn:aws:iam::688720756067:role/nullplatform-agent"] + kms_key_arn = "arn:aws:kms:us-east-1:688720756067:key/abcd1234-..." +} diff --git a/parameters/providers/aws-parameter-store/specs/variables.tf b/parameters/providers/aws-parameter-store/specs/variables.tf new file mode 100644 index 00000000..49cb5686 --- /dev/null +++ b/parameters/providers/aws-parameter-store/specs/variables.tf @@ -0,0 +1,81 @@ +variable "nrn" { + description = "NRN where the provider specification is anchored (the top-level scope it belongs to)." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the upstream scope_configuration module to register provider instances." + type = string + sensitive = true +} + +variable "extra_visible_to_nrns" { + description = "Additional NRNs that should see the provider specification besides var.nrn and the per-instance NRNs." + type = list(string) + default = [] +} + +variable "instances" { + description = <<-EOT + Provider instances to create. Map key is a stable identifier (used in for_each). + Each entry carries its own NRN, dimensions, KMS key (for SecureString), tier, and the + parameter sensibility set this instance handles (secret / non_secret / both). + Each instance also gets its own agent API key + notification channel (anchored at the + instance NRN) unless notification_channel_enabled=false. Fields: + notification_channel_enabled — create the agent channel + its API key for this instance (default true). + tags_selectors — tag key/value pairs the agent uses to match this instance's channel + against scope tags (e.g. { environment = "development" }). + EOT + type = map(object({ + nrn = string + dimensions = map(string) + kms_key_id = string + tier = string + applies_to = list(string) + notification_channel_enabled = optional(bool, true) + tags_selectors = optional(map(string), {}) + })) + default = {} +} + +variable "iam_role" { + description = <<-EOT + Optionally create the AWS IAM role with least-privilege permissions this provider needs. + Fields: + enable — set true to create the role + inline policy. + name — role name (required when enable=true). + mode — "default" (ssm + default KMS) or "with_kms" (adds customer-managed KMS perms). + trusted_principals — list of ARNs allowed to assume the role. Defaults to the current account root + (any principal in the account, further controlled by their own IAM policies). + kms_key_arn — required when mode="with_kms". The customer-managed KMS key the role can use. + The role's ARN is exposed via the `iam_role_arn` output so operators can plug it into the + identity-access-control provider's iam_role_arns.arns[].arn field with selector="parameter_store". + EOT + type = object({ + enable = bool + name = string + mode = optional(string, "default") + trusted_principals = optional(list(string), []) + kms_key_arn = optional(string, "") + }) + default = { + enable = false + name = "" + } + + validation { + condition = !var.iam_role.enable || var.iam_role.name != "" + error_message = "iam_role.name is required when iam_role.enable=true." + } + + validation { + condition = !var.iam_role.enable || contains(["default", "with_kms"], var.iam_role.mode) + error_message = "iam_role.mode must be \"default\" or \"with_kms\"." + } + + validation { + condition = !var.iam_role.enable || var.iam_role.mode != "with_kms" || var.iam_role.kms_key_arn != "" + error_message = "iam_role.kms_key_arn is required when iam_role.enable=true and iam_role.mode=\"with_kms\"." + } +} + diff --git a/parameters/providers/aws-parameter-store/specs/versions.tf b/parameters/providers/aws-parameter-store/specs/versions.tf new file mode 100644 index 00000000..05df113c --- /dev/null +++ b/parameters/providers/aws-parameter-store/specs/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.95" + } + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} diff --git a/parameters/providers/aws-parameter-store/store b/parameters/providers/aws-parameter-store/store new file mode 100755 index 00000000..0f17e886 --- /dev/null +++ b/parameters/providers/aws-parameter-store/store @@ -0,0 +1,82 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter in AWS SSM Parameter Store. +# +# Versioning model: +# nullplatform parameter values are immutable. Each update is a new VERSION. +# AWS Parameter Store retains versions automatically inside the same parameter +# name. We use `--overwrite` so the first store creates the parameter and +# subsequent stores append a new version. Version 1 is created on first store; +# each subsequent store increments the version counter. +# +# Type=SecureString for kind=secret, Type=String for kind=parameter. +# +# Required env: PARAMETER_KIND, PARAMETER_VALUE, AWS_REGION, PS_NAME_PREFIX, PS_TIER +# Optional env: PS_KMS_KEY_ID + +T_PS_STORE=$(timer_now) + +source "$PARAMETERS_ROOT/utils/build_external_id" +build_external_id + +# SSM Parameter Store path components only accept [A-Za-z0-9._-]. The canonical +# external_id uses `=` to bind entities (organization=acme-1, ...); replace +# with `_` to satisfy SSM. EXTERNAL_ID itself (returned to the platform) keeps +# the canonical form — retrieve/delete apply the same transformation. +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID//=/_}" +log debug " ⏱ ps.store.prep $(timer_elapsed "$T_PS_STORE")" + +SSM_TYPE="String" +[ "${PARAMETER_KIND:-}" = "secret" ] && SSM_TYPE="SecureString" + +put_args=( + --region "$AWS_REGION" + --name "$PARAM_NAME" + --value "$PARAMETER_VALUE" + --type "$SSM_TYPE" + --tier "$PS_TIER" + --overwrite +) +if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then + put_args+=(--key-id "$PS_KMS_KEY_ID") +fi + +err_file=$(mktemp) +T_AWS=$(timer_now) +if ! PUT_RESULT=$(aws ssm put-parameter "${put_args[@]}" --output json 2>"$err_file"); then + err=$(cat "$err_file") + rm -f "$err_file" + log error "❌ Failed to store parameter in AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:PutParameter for $PARAM_NAME" + log error " • Tier '$PS_TIER' rejects this value size (Standard caps at 4KB)" + if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then + log error " • IAM principal lacks kms:Encrypt on $PS_KMS_KEY_ID" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error " • For large values, set PS_TIER=Advanced" + log error "Underlying error: $err" + exit 1 +fi +log debug " ⏱ aws ssm put-parameter $(timer_elapsed "$T_AWS")" +rm -f "$err_file" + +VERSION_ID=$(echo "$PUT_RESULT" | jq -r '.Version // empty') + +# Encode version into external_id (the canonical handle nullplatform persists). +[ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" + +T_PS_OUT=$(timer_now) +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg parameter_name "$PARAM_NAME" \ + --arg region "$AWS_REGION" \ + --arg type "$SSM_TYPE" \ + --arg tier "$PS_TIER" \ + '{external_id: $external_id, metadata: {parameter_name: $parameter_name, region: $region, type: $type, tier: $tier}}' +log debug " ⏱ ps.store.output $(timer_elapsed "$T_PS_OUT")" +log debug "⏱ ps.store total $(timer_elapsed "$T_PS_STORE")" diff --git a/parameters/providers/aws-secrets-manager/delete b/parameters/providers/aws-secrets-manager/delete new file mode 100755 index 00000000..91ee477d --- /dev/null +++ b/parameters/providers/aws-secrets-manager/delete @@ -0,0 +1,51 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from AWS Secrets Manager. +# +# Idempotency semantics: +# - Successful delete → success +# - ResourceNotFoundException → success (already gone, idempotent) +# - Any other error → exit 1 with troubleshooting +# +# Uses --force-delete-without-recovery to end billing immediately and free the name. +# +# Required env: EXTERNAL_ID, AWS_REGION, SM_NAME_PREFIX + +# Delete removes ALL versions; EXTERNAL_ID_VERSION suffix (if present) is ignored. +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID_PATH}" + +err_file=$(mktemp) +T_AWS=$(timer_now) +if aws secretsmanager delete-secret \ + --region "$AWS_REGION" \ + --secret-id "$SECRET_NAME" \ + --force-delete-without-recovery >/dev/null 2>"$err_file"; then + log debug " ⏱ aws secretsmanager delete-secret $(timer_elapsed "$T_AWS")" + rm -f "$err_file" + echo '{ + "success": true +}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ResourceNotFoundException"; then + log debug "Secret '$SECRET_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + else + log error "❌ Failed to delete secret '$SECRET_NAME' in AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:DeleteSecret on this resource" + log error " • Region '$AWS_REGION' unreachable or wrong" + log error " • AWS API throttling" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error " • Check IAM policy: aws iam simulate-principal-policy --action-names secretsmanager:DeleteSecret" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/aws-secrets-manager/docs/architecture.md b/parameters/providers/aws-secrets-manager/docs/architecture.md new file mode 100644 index 00000000..296549b8 --- /dev/null +++ b/parameters/providers/aws-secrets-manager/docs/architecture.md @@ -0,0 +1,215 @@ +# AWS Secrets Manager — Architecture + +This document describes how the `parameters/providers/aws-secrets-manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM). + +--- + +## Role in the parameter lifecycle + +A nullplatform parameter has a `kind` that decides where its values are persisted: + +| Kind | Storage location | This provider | +|-----------------------|------------------------------------------|---------------| +| `nullplatform-storage` | nullplatform's own datastore | Not involved | +| `third-party-storage` | External provider (AWS SM, Vault, etc.) | Used | + +This provider handles parameters configured for third-party storage that the platform routes to AWS Secrets Manager. The platform's choice is per-parameter, and a single parameter — secret or not — can be routed here. Routing a non-secret parameter to AWS SM is supported but costlier than alternatives like Parameter Store; the choice is the platform operator's. + +The interaction is event-driven via four actions: + +| Action | Trigger | Effect on AWS SM | +|------------|--------------------------------------------------|----------------------------------------------------| +| `store` | A parameter value is created or updated | `CreateSecret` first time, `PutSecretValue` otherwise (new version) | +| `retrieve` | A consumer needs the value | `GetSecretValue`, returns the AWSCURRENT version | +| `delete` | The parameter is deleted | `DeleteSecret --force-delete-without-recovery` | +| `notify` | nullplatform-side ack hook | No-op (returns `{success: true}`) | + +--- + +## Naming strategy + +Every secret name is composed from the parameter's NRN entities (with slugs and IDs), its dimensions, and the parameter name + ID: + +``` +nullplatform/organization=-/account=-/.../=/- +``` + +The path follows the **human-friendliness principle**: anyone entering the AWS Secrets Manager console must be able to find the secret by knowing the parameter's context, without consulting nullplatform metadata. + +### Optional path components + +Two segments of the path are conditional: + +- **`scope` entity** — optional. It appears as a segment between `application=...` and the dimensions, only when the parameter is bound to a specific deployment scope. When absent, the path goes directly from `application=...` to dimensions (or to the parameter name if no dimensions). +- **Dimensions** — optional. A parameter may have zero dimensions, in which case no `key=value` segments appear. If present, they are sorted alphabetically by key. + +The canonical entity order is `organization → account → namespace → application → scope`. The first four are always present in nullplatform's NRN; `scope` is added on demand. + +### Examples + +**Minimal** — parameter with required entities only, no scope, no dimensions: + +``` +nullplatform/organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/DB_PASSWORD-42 +``` + +**With scope** — same parameter bound to a deployment scope: + +``` +nullplatform/organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/scope=staging-789/DB_PASSWORD-42 +``` + +**With dimensions** — same parameter without scope but with two dimensions: + +``` +nullplatform/organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/country=argentina/environment=production/DB_PASSWORD-42 +``` + +**Full** — parameter with scope AND dimensions: + +``` +nullplatform/organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/scope=staging-789/country=argentina/environment=production/DB_PASSWORD-42 +``` + +Notes: + +- **Slug-id format** (`-`): slugs are human-readable, IDs are stable. Combining both gives both readability and resilience to potential slug rename support in the future. +- **Slugs are fetched via `np read --id --format json --query '.slug'`** in parallel during the `store` operation. +- **Dimensions are sorted alphabetically by key** for determinism — the same (NRN, dimensions, parameter) tuple always produces the same secret name. +- **`parameter_name-parameter_id`** at the end: name for legibility, ID for uniqueness across renames. + +### IAM anchor + +The fixed `nullplatform/` prefix is the IAM scoping anchor: + +``` +arn:aws:secretsmanager:::secret:nullplatform/* +``` + +A single ARN pattern covers everything this provider creates, without granting account-wide access. See `iam-policy.md`. + +### sts:AssumeRole + +Before any AWS call, this provider's `setup` declares its IAM identity and sources the shared `utils/assume_role_step`: + +```bash +ASSUME_ROLE_SELECTOR="secret_manager" +ASSUME_ROLE_OVERRIDE_ENV="SECRET_MANAGER_ASSUME_ROLE_ARN" +ASSUME_ROLE_DEFAULT_ENV="SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" +ASSUME_ROLE_SESSION_PREFIX="np-secret-manager" +source "$PARAMETERS_ROOT/utils/assume_role_step" +``` + +The step is provider-agnostic — `aws-parameter-store` does the same with its own selector (`parameter_store`) and env-var names (`PARAMETER_STORE_ASSUME_ROLE_ARN[_DEFAULT]`). The step: + +1. Reads the scope's NRN and dimensions from `CONTEXT` (falling back to `np scope read` when dimensions are not in the payload). +2. Calls `np provider list --categories identity-access-control --nrn [--dimensions ...]` to fetch the IAM provider that the platform has dimension-resolved for this scope. +3. Picks the ARN from `.iam_role_arns.arns[]` whose `selector` matches `ASSUME_ROLE_SELECTOR`. +4. Calls `sts:AssumeRole` and exports `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`. + +Precedence: `${!ASSUME_ROLE_OVERRIDE_ENV}` (e.g. `SECRET_MANAGER_ASSUME_ROLE_ARN`) → IAM provider selector → `${!ASSUME_ROLE_DEFAULT_ENV}` (per-account agent default) → agent credentials. + +### ARN suffix + +AWS SM appends a random 6-character suffix to every secret ARN: + +``` +arn:aws:secretsmanager:::secret:nullplatform/.../DB_PASSWORD-42-XXXXXX +``` + +Use the wildcard form (`nullplatform/*`) in IAM policies — exact ARN matches without the suffix will not match. + +--- + +## Versioning + +nullplatform parameter values are **immutable**. Each update of the same (parameter_id, NRN, dimensions) tuple creates a new VERSION of the same value, not a new value. + +AWS Secrets Manager has native version retention. We use it as the source of truth: + +- **First `store`** for a given path → `CreateSecret`. A new secret is created with version 1. +- **Subsequent `store`** for the same path → `PutSecretValue`. A new version is appended, and `AWSCURRENT` moves to it. +- **All previous versions are retained inside the same secret** (up to AWS SM's 100-version cap, after which the oldest unlabeled versions are pruned automatically). + +### Version identity in external_id + +The `external_id` returned by `store` encodes both the path and the version: + +``` +# +``` + +For AWS SM, `version_id` is **the literal `VersionId` UUID v4 returned by `CreateSecret` / `PutSecretValue`** — we do not invent or normalize it. AWS returns it in the response; we copy it verbatim into the suffix. Real example: + +``` +organization=acme-1255165411/.../DB_PASSWORD-42#a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d +``` + +The hex string after `#` is the exact VersionId AWS reports for that PutSecretValue / CreateSecret call. It can be used as-is with `aws secretsmanager get-secret-value --version-id` to fetch that specific version. + +This means nullplatform — which already persists and re-sends `external_id` on every operation — automatically retains the version reference without needing a separate field. On `retrieve`: + +- If `external_id` carries `#` → fetch that specific historical version using `--version-id `. +- If `external_id` has no `#` suffix → fetch `AWSCURRENT` (latest). + +On `delete`, the version suffix is ignored — `DeleteSecret` removes all versions of the secret. + +### Why this matters for cost + +AWS SM charges $0.40 per secret per month, **regardless of version count**. Putting versions in the same secret is essentially free; creating a new secret per version would multiply cost linearly with update frequency. The implementation enforces the cheap path. + +### Why this matters for history + +Storing all versions in a single secret means operators can view and restore older values. Restoration is platform-orchestrated: read an old version via `retrieve(external_id with #version)`, then store the value again — that becomes the new latest version. + +--- + +## Secret payload shape + +The value stored in AWS SM is a JSON envelope, not the raw value: + +```json +{ + "parameter_id": 42, + "value": "the-actual-secret-value", + "stored_at": "2026-06-23T12:34:56Z", + "external_id": "organization=acme-1255165411/.../DB_PASSWORD-42" +} +``` + +| Field | Purpose | +|----------------|--------------------------------------------------------------| +| `parameter_id` | nullplatform parameter ID (reverse lookup) | +| `value` | The actual stored value | +| `stored_at` | UTC timestamp of this version (audit trail) | +| `external_id` | Canonical handle nullplatform persists (matches secret name) | + +Each version of the secret carries its own `stored_at` and the value that was active at that point in time. + +--- + +## Lifecycle notes + +### Hard delete + +`delete` uses `--force-delete-without-recovery`. This bypasses AWS SM's default 7–30 day soft-delete window. The trade-off: + +- Recoverability after deletion: lost. +- Cost: no longer paying for soft-deleted secrets. +- Name reuse: immediate. + +For nullplatform's model — where the version history is the recovery mechanism, not the soft-delete window — this is the right default. An operator who needs the soft-delete window can override via provider config (future extension). + +### Error handling + +| Error condition | `store` | `retrieve` | `delete` | +|----------------------------------------|-------------------|--------------------------|--------------------| +| Resource exists (on store) | New version added | N/A | N/A | +| ResourceNotFoundException | N/A | Exit 1 + troubleshooting | Idempotent success | +| Any other error (IAM, network, region) | Exit 1 + troubleshooting | Exit 1 + troubleshooting | Exit 1 + troubleshooting | + +For `delete`, `ResourceNotFoundException` is treated as idempotent success — the resource is already in the desired state, the work is done. For `retrieve`, not-found is a real error: returning a sentinel like "value not found" as the value would mislead the platform into displaying that string as the parameter's actual value. Every other error — particularly IAM permission failures — propagates as a real error with troubleshooting guidance. + +### Encryption at rest + +All values are encrypted by AWS SM. By default, SM uses the AWS-managed KMS key `aws/secretsmanager`. To use a customer-managed KMS key (CMK), set `kms_key_id` in the provider's configuration; the agent then grants `kms:Decrypt` and `kms:GenerateDataKey` on that key (see `iam-policy.md`). diff --git a/parameters/providers/aws-secrets-manager/docs/iam-policy.md b/parameters/providers/aws-secrets-manager/docs/iam-policy.md new file mode 100644 index 00000000..056e8160 --- /dev/null +++ b/parameters/providers/aws-secrets-manager/docs/iam-policy.md @@ -0,0 +1,72 @@ +# IAM Policy + +Minimum IAM permissions for `parameters/providers/aws-secrets-manager/`. Scoped to the `nullplatform/*` namespace so the agent cannot reach any secret outside this provider's domain. + +--- + +## Required actions + +| Action | Used by | Why | +|-----------------------------------|------------|-----------------------------------------------------| +| `secretsmanager:CreateSecret` | `store` | Creates the secret on the first version | +| `secretsmanager:PutSecretValue` | `store` | Adds a new version when the secret already exists | +| `secretsmanager:GetSecretValue` | `retrieve` | Reads the current value | +| `secretsmanager:DeleteSecret` | `delete` | Removes the secret entirely | + +--- + +## Recommended policy + +Replace `` and `` before applying. The `nullplatform/*` resource pattern restricts the agent to secrets created and managed by this provider. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageNullplatformParameters", + "Effect": "Allow", + "Action": [ + "secretsmanager:CreateSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:GetSecretValue", + "secretsmanager:DeleteSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:::secret:nullplatform/*" + ] + } + ] +} +``` + +The trailing `*` in the resource ARN absorbs both the path under `nullplatform/` and the random 6-character suffix AWS SM appends to every secret ARN. + +--- + +## KMS (only if using a customer-managed key) + +If the provider's configuration sets `kms_key_id` to a customer-managed key (rather than the default `aws/secretsmanager`), the agent also needs KMS permissions on that key: + +```json +{ + "Sid": "UseCustomerManagedKmsKey", + "Effect": "Allow", + "Action": [ + "kms:Decrypt", + "kms:GenerateDataKey" + ], + "Resource": [ + "arn:aws:kms:::key/" + ], + "Condition": { + "StringEquals": { + "kms:ViaService": "secretsmanager..amazonaws.com" + } + } +} +``` + +`kms:ViaService` restricts the key to SSM use only — without it, the role could decrypt arbitrary ciphertexts encrypted with the same key. + +The CMK's **key policy** must also allow the role principal. IAM permissions alone are not enough for KMS. diff --git a/parameters/providers/aws-secrets-manager/retrieve b/parameters/providers/aws-secrets-manager/retrieve new file mode 100755 index 00000000..6e957995 --- /dev/null +++ b/parameters/providers/aws-secrets-manager/retrieve @@ -0,0 +1,69 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a value from AWS Secrets Manager. +# +# Versioning: +# - If $CONTEXT.version_id is present, retrieves that specific historical version. +# - Otherwise retrieves AWSCURRENT (the latest version). +# +# Semantics: +# - Success → return {value: ""} (extracted from JSON envelope) +# - ResourceNotFoundException → return {value: "value not found"} +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, SM_NAME_PREFIX + +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context (split on `#`). +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID_PATH}" + +get_args=( + --region "$AWS_REGION" + --secret-id "$SECRET_NAME" + --query SecretString + --output text +) +if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + get_args+=(--version-id "$EXTERNAL_ID_VERSION") +fi + +err_file=$(mktemp) +T_AWS=$(timer_now) +if SECRET_STRING=$(aws secretsmanager get-secret-value "${get_args[@]}" 2>"$err_file"); then + log debug " ⏱ aws secretsmanager get-secret-value $(timer_elapsed "$T_AWS")" + rm -f "$err_file" + STORED_VALUE=$(echo "$SECRET_STRING" | jq -r '.value // empty') + jq -n --arg value "$STORED_VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ResourceNotFoundException"; then + log error "❌ Secret '$SECRET_NAME' not found in AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • The secret was manually deleted from AWS" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' was pruned (AWS SM auto-removes oldest unlabeled versions past 100)" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify the secret exists: aws secretsmanager describe-secret --secret-id $SECRET_NAME" + log error " • If genuinely missing, the parameter value needs to be re-stored" + exit 1 + else + log error "❌ Failed to retrieve secret '$SECRET_NAME' from AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:GetSecretValue" + log error " • KMS key permission missing (kms:Decrypt) for CMK-encrypted secrets" + log error " • Region '$AWS_REGION' unreachable" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' does not exist for this secret" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/aws-secrets-manager/setup b/parameters/providers/aws-secrets-manager/setup new file mode 100755 index 00000000..6283ae7d --- /dev/null +++ b/parameters/providers/aws-secrets-manager/setup @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# Validates AWS Secrets Manager connection config. +# +# AWS_REGION is injected by the agent's runtime (IRSA / instance profile / +# service account env), not by provider config. The name prefix is hardcoded +# to `nullplatform/` to guarantee a single invariant namespace for all platform +# secrets — changing it would break retrieval of historical external_ids. +# +# Only KMS key choice is operator-configurable, via PROVIDER_CONFIG.kms_key_id. +# +# Exports: AWS_REGION, SM_NAME_PREFIX, SM_KMS_KEY_ID + +: "${AWS_REGION:?AWS_REGION must be set by the agent runtime}" + +# Resolve and assume the platform-configured IAM role (if any). After this, +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN are set in the +# environment, so every subsequent aws-cli call inherits the assumed identity. +ASSUME_ROLE_SELECTOR="secret_manager" +ASSUME_ROLE_OVERRIDE_ENV="SECRET_MANAGER_ASSUME_ROLE_ARN" +ASSUME_ROLE_DEFAULT_ENV="SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" +ASSUME_ROLE_SESSION_PREFIX="np-secret-manager" +source "$PARAMETERS_ROOT/utils/assume_role_step" + +SM_NAME_PREFIX="nullplatform/" + +SM_KMS_KEY_ID=$(get_config_value \ + --provider '.setup.kms_key_id' \ + --default '') + +export AWS_REGION SM_NAME_PREFIX SM_KMS_KEY_ID diff --git a/parameters/providers/aws-secrets-manager/specs/aws-secrets-manager-configuration.json.tpl b/parameters/providers/aws-secrets-manager/specs/aws-secrets-manager-configuration.json.tpl new file mode 100644 index 00000000..f9abf2eb --- /dev/null +++ b/parameters/providers/aws-secrets-manager/specs/aws-secrets-manager-configuration.json.tpl @@ -0,0 +1,56 @@ +{ + "name": "AWS Secrets Manager", + "description": "Stores nullplatform parameter values in AWS Secrets Manager using native versioning", + "slug": "aws-secrets-manager", + "category": "parameters-storage", + "icon": "mdi:aws", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "allow_dimensions": true, + "schema": { + "type": "object", + "required": ["sensibility", "setup"], + "additionalProperties": false, + "properties": { + "sensibility": { + "type": "object", + "order": 1, + "required": ["applies_to"], + "description": "The sensibility of the parameters stored in this backend.", + "properties": { + "applies_to": { + "type": "array", + "title": "Applies to", + "description": "Which parameters this backend stores — secret, non-secret, or both.", + "order": 1, + "inline": true, + "uniqueItems": true, + "minItems": 1, + "default": ["secret"], + "items": { + "oneOf": [ + { "const": "secret", "title": "Secret parameters" }, + { "const": "non_secret", "title": "Non-secret parameters" } + ] + } + } + } + }, + "setup": { + "type": "object", + "order": 2, + "description": "The setup for the AWS Secrets Manager backend.", + "properties": { + "kms_key_id": { + "type": "string", + "title": "KMS Key ID (optional)", + "description": "Customer-managed KMS key ARN or alias. If empty, the default aws/secretsmanager managed key is used", + "default": "", + "order": 1 + } + } + } + } + } +} diff --git a/parameters/providers/aws-secrets-manager/specs/data.tf b/parameters/providers/aws-secrets-manager/specs/data.tf new file mode 100644 index 00000000..6c9c94ec --- /dev/null +++ b/parameters/providers/aws-secrets-manager/specs/data.tf @@ -0,0 +1,13 @@ +################################################################################ +# Optional: IAM role with least-privilege permissions for this provider. +# Toggle with var.iam_role.enable. Outputs the role ARN so operators can wire +# it into the identity-access-control provider config (selector="secret_manager"). +################################################################################ + +data "aws_caller_identity" "current" { + count = var.iam_role.enable ? 1 : 0 +} + +data "aws_region" "current" { + count = var.iam_role.enable ? 1 : 0 +} diff --git a/parameters/providers/aws-secrets-manager/specs/locals.tf b/parameters/providers/aws-secrets-manager/specs/locals.tf new file mode 100644 index 00000000..eda184b8 --- /dev/null +++ b/parameters/providers/aws-secrets-manager/specs/locals.tf @@ -0,0 +1,85 @@ +locals { + # The configuration template uses gomplate-style `{{ env.Getenv "NRN" }}` for + # `visible_to` because it's also consumed by non-tofu install paths. The only + # token in the file is NRN, so we replace it inline rather than pulling in + # gomplate as a build dependency. + template_path = "${path.module}/aws-secrets-manager-configuration.json.tpl" + template_raw = file(local.template_path) + template_rendered = replace(local.template_raw, "{{ env.Getenv \"NRN\" }}", var.nrn) + config = jsondecode(local.template_rendered) + cmdline_path = "nullplatform/scopes/parameters/entrypoint" + + # The spec must be visible to the anchor NRN and to every NRN where an + # instance lives — otherwise the instance can't reference its own spec. + instance_nrns = distinct([for _, inst in var.instances : inst.nrn]) + spec_visible_to = distinct(concat( + [var.nrn], + local.instance_nrns, + var.extra_visible_to_nrns, + )) + + iam_enabled = var.iam_role.enable + aws_account_id = local.iam_enabled ? data.aws_caller_identity.current[0].account_id : "" + aws_region = local.iam_enabled ? data.aws_region.current[0].name : "" + + # If trusted_principals isn't provided, default to the current account root. + # That allows IAM principals within the account to assume the role (subject + # to their own IAM policies). To lock it down further, pass explicit ARNs. + effective_trusted_principals = local.iam_enabled ? ( + length(var.iam_role.trusted_principals) > 0 + ? var.iam_role.trusted_principals + : ["arn:aws:iam::${local.aws_account_id}:root"] + ) : [] + + base_policy_statement = { + Sid = "ManageNullplatformSecrets" + Effect = "Allow" + Action = [ + "secretsmanager:CreateSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:GetSecretValue", + "secretsmanager:DeleteSecret", + ] + Resource = "arn:aws:secretsmanager:${local.aws_region}:${local.aws_account_id}:secret:nullplatform/*" + } + + kms_policy_statement = { + Sid = "UseCustomerManagedKmsKey" + Effect = "Allow" + Action = [ + "kms:Decrypt", + "kms:GenerateDataKey", + ] + Resource = var.iam_role.kms_key_arn + Condition = { + StringEquals = { + "kms:ViaService" = "secretsmanager.${local.aws_region}.amazonaws.com" + } + } + } + + # Build the policy JSON conditionally at the string level — Terraform's strict + # typing rejects ternaries that return tuples of differently-shaped objects + # (base has 4 keys, kms statement adds Condition for the 5th). + policy_doc = var.iam_role.mode == "with_kms" ? jsonencode({ + Version = "2012-10-17" + Statement = [local.base_policy_statement, local.kms_policy_statement] + }) : jsonencode({ + Version = "2012-10-17" + Statement = [local.base_policy_statement] + }) + + # Instances that get their own agent API key + notification channel. + notification_instances = { + for key, instance in var.instances : key => instance + if instance.notification_channel_enabled + } + + api_key_grants = [ + "controlplane:agent", + "developer", + "ops", + "secops", + "secrets-reader", + ] +} diff --git a/parameters/providers/aws-secrets-manager/specs/main.tf b/parameters/providers/aws-secrets-manager/specs/main.tf new file mode 100644 index 00000000..1a4618ed --- /dev/null +++ b/parameters/providers/aws-secrets-manager/specs/main.tf @@ -0,0 +1,126 @@ +################################################################################ +# AWS Secrets Manager — specs module +# +# Responsibilities: +# +# 1. nullplatform_provider_specification.this +# Created from ./aws-secrets-manager-configuration.json.tpl. The JSON file +# is the canonical declaration of the provider's metadata (name, icon, +# category) and its config schema. +# +# 2. module.scope_configuration (for_each = var.instances) +# One concrete instance per entry in var.instances, each with its own NRN, +# dimensions, and KMS key. Delegates to the upstream +# `nullplatform/scope_configuration` module. +# +# 3. nullplatform_api_key.this + nullplatform_notification_channel.from_template +# Per instance (unless notification_channel_enabled=false): an agent API key +# and its notification channel, anchored at the instance NRN, that handle +# secret storage and retrieval. +# +# 4. aws_iam_role.this (optional, var.iam_role.enable) +# Least-privilege role for the provider. See data.tf / locals.tf. +################################################################################ + +resource "nullplatform_provider_specification" "this" { + name = local.config.name + icon = local.config.icon + description = local.config.description + category = local.config.category + allow_dimensions = local.config.allow_dimensions + visible_to = local.spec_visible_to + schema = jsonencode(local.config.schema) +} + +module "scope_configuration" { + for_each = var.instances + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_configuration?ref=v4.5.1" + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = local.config.slug + dimensions = each.value.dimensions + + attributes = { + sensibility = { + applies_to = each.value.applies_to + } + setup = { + kms_key_id = each.value.kms_key_id + } + } + + depends_on = [nullplatform_provider_specification.this] +} + +resource "nullplatform_api_key" "this" { + for_each = local.notification_instances + + name = "secret-api-key-${each.key}" + dynamic "grants" { + for_each = toset(local.api_key_grants) + content { + nrn = each.value.nrn + role_slug = grants.value + } + } + + tags { + key = "managedBy" + value = "IaC" + } + + lifecycle { + create_before_destroy = true + } +} + +resource "nullplatform_notification_channel" "from_template" { + for_each = local.notification_instances + + nrn = each.value.nrn + type = "agent" + source = ["parameters"] + description = "Notification channel to handle parameter storage and retrieval" + configuration { + agent { + api_key = nullplatform_api_key.this[each.key].api_key + selector = each.value.tags_selectors + command { + data = { + "cmdline" : local.cmdline_path + "environment" : jsonencode({ + NP_ACTION_CONTEXT = "'$${NOTIFICATION_CONTEXT}'" + LOG_LEVEL = "debug" + }) + } + type = "exec" + } + } + } +} + +resource "aws_iam_role" "this" { + count = local.iam_enabled ? 1 : 0 + name = var.iam_role.name + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = local.effective_trusted_principals + } + Action = "sts:AssumeRole" + } + ] + }) +} + +resource "aws_iam_role_policy" "this" { + count = local.iam_enabled ? 1 : 0 + name = "${var.iam_role.name}-policy" + role = aws_iam_role.this[0].name + policy = local.policy_doc +} diff --git a/parameters/providers/aws-secrets-manager/specs/outputs.tf b/parameters/providers/aws-secrets-manager/specs/outputs.tf new file mode 100644 index 00000000..38a397e2 --- /dev/null +++ b/parameters/providers/aws-secrets-manager/specs/outputs.tf @@ -0,0 +1,19 @@ +output "specification_id" { + description = "ID of the nullplatform_provider_specification created from aws-secrets-manager-configuration.json.tpl." + value = nullplatform_provider_specification.this.id +} + +output "iam_role_arn" { + description = "ARN of the IAM role created when iam_role.enable=true. Wire this into the identity-access-control provider's iam_role_arns.arns[] with selector=\"secret_manager\". Empty when iam_role.enable=false." + value = length(aws_iam_role.this) > 0 ? aws_iam_role.this[0].arn : "" +} + +output "notification_channel_ids" { + description = "Map of instance key => ID of its agent notification channel. Only includes instances with notification_channel_enabled=true." + value = { for key, channel in nullplatform_notification_channel.from_template : key => channel.id } +} + +output "notification_channel_statuses" { + description = "Map of instance key => status of its agent notification channel. Only includes instances with notification_channel_enabled=true." + value = { for key, channel in nullplatform_notification_channel.from_template : key => channel.status } +} diff --git a/parameters/providers/aws-secrets-manager/specs/terraform.tfvars.example b/parameters/providers/aws-secrets-manager/specs/terraform.tfvars.example new file mode 100644 index 00000000..0fe49585 --- /dev/null +++ b/parameters/providers/aws-secrets-manager/specs/terraform.tfvars.example @@ -0,0 +1,32 @@ +nrn = "organization=1" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +instances = { + prod-billing = { + nrn = "organization=1:account=2:namespace=3" + dimensions = { environment = "production" } + kms_key_id = "alias/parameters-prod-billing" + applies_to = ["secret"] + + # Each instance gets its own agent API key + notification channel anchored at its nrn. + tags_selectors = { environment = "production" } + } + staging-billing = { + nrn = "organization=1:account=2:namespace=3" + dimensions = { environment = "staging" } + kms_key_id = "" + applies_to = ["secret"] + + # Skip the agent channel + API key for this instance (e.g. managed elsewhere). + notification_channel_enabled = false + } +} + +iam_role = { + enable = true + name = "nullplatform-secrets-manager" + mode = "default" + trusted_principals = ["arn:aws:iam::688720756067:role/nullplatform-agent"] +} diff --git a/parameters/providers/aws-secrets-manager/specs/variables.tf b/parameters/providers/aws-secrets-manager/specs/variables.tf new file mode 100644 index 00000000..9ccea14f --- /dev/null +++ b/parameters/providers/aws-secrets-manager/specs/variables.tf @@ -0,0 +1,78 @@ +variable "nrn" { + description = "NRN where the provider specification is anchored (the top-level scope it belongs to)." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the upstream scope_configuration module to register provider instances." + type = string + sensitive = true +} + +variable "extra_visible_to_nrns" { + description = "Additional NRNs that should see the provider specification besides var.nrn and the per-instance NRNs." + type = list(string) + default = [] +} + +variable "instances" { + description = <<-EOT + Provider instances to create. Map key is a stable identifier (used in for_each). + Each entry carries its own NRN, dimensions, KMS key, and the parameter sensibility + set this instance handles (secret / non_secret / both). + Each instance also gets its own agent API key + notification channel (anchored at the + instance NRN) unless notification_channel_enabled=false. Fields: + notification_channel_enabled — create the agent channel + its API key for this instance (default true). + tags_selectors — tag key/value pairs the agent uses to match this instance's channel + against scope tags (e.g. { environment = "development" }). + EOT + type = map(object({ + nrn = string + dimensions = map(string) + kms_key_id = string + applies_to = list(string) + notification_channel_enabled = optional(bool, true) + tags_selectors = optional(map(string), {}) + })) + default = {} +} + +variable "iam_role" { + description = <<-EOT + Optionally create the AWS IAM role with least-privilege permissions this provider needs. + Fields: + enable — set true to create the role + inline policy. + name — role name (required when enable=true). + mode — "default" (secretsmanager + default KMS) or "with_kms" (adds customer-managed KMS perms). + trusted_principals — list of ARNs allowed to assume the role. Defaults to the current account root. + kms_key_arn — required when mode="with_kms". + The role's ARN is exposed via the `iam_role_arn` output so operators can plug it into the + identity-access-control provider's iam_role_arns.arns[].arn field with selector="secret_manager". + EOT + type = object({ + enable = bool + name = string + mode = optional(string, "default") + trusted_principals = optional(list(string), []) + kms_key_arn = optional(string, "") + }) + default = { + enable = false + name = "" + } + + validation { + condition = !var.iam_role.enable || var.iam_role.name != "" + error_message = "iam_role.name is required when iam_role.enable=true." + } + + validation { + condition = !var.iam_role.enable || contains(["default", "with_kms"], var.iam_role.mode) + error_message = "iam_role.mode must be \"default\" or \"with_kms\"." + } + + validation { + condition = !var.iam_role.enable || var.iam_role.mode != "with_kms" || var.iam_role.kms_key_arn != "" + error_message = "iam_role.kms_key_arn is required when iam_role.enable=true and iam_role.mode=\"with_kms\"." + } +} diff --git a/parameters/providers/aws-secrets-manager/specs/versions.tf b/parameters/providers/aws-secrets-manager/specs/versions.tf new file mode 100644 index 00000000..05df113c --- /dev/null +++ b/parameters/providers/aws-secrets-manager/specs/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.95" + } + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} diff --git a/parameters/providers/aws-secrets-manager/store b/parameters/providers/aws-secrets-manager/store new file mode 100755 index 00000000..b24da65c --- /dev/null +++ b/parameters/providers/aws-secrets-manager/store @@ -0,0 +1,104 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter value in AWS Secrets Manager. +# +# Versioning model: +# nullplatform parameter values are immutable. Each update of the same +# (parameter_id, nrn, dimensions) tuple is a new VERSION of the same value. +# AWS Secrets Manager has native versioning — every PutSecretValue creates a +# new version, all retained inside the same secret, and AWSCURRENT moves to +# the latest. We exploit this: +# - First store for this external_id → CreateSecret +# - Subsequent stores → PutSecretValue (new version, same secret) +# This keeps the version history without paying for a separate secret per +# version (each AWS SM secret costs $0.40/month regardless of version count). +# +# The VersionId returned by AWS is included in the result metadata so the +# platform can persist it per value version. Retrieve uses it to fetch a +# specific historical version when the platform passes it back. +# +# Required env: PARAMETER_ID, PARAMETER_VALUE, AWS_REGION, SM_NAME_PREFIX +# Optional env: SM_KMS_KEY_ID + +source "$PARAMETERS_ROOT/utils/build_external_id" +build_external_id + +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" + +SECRET_PAYLOAD=$(jq -nc \ + --argjson parameter_id "${PARAMETER_ID:-null}" \ + --arg value "$PARAMETER_VALUE" \ + --arg stored_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + --arg external_id "$EXTERNAL_ID" \ + '{parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id}') + +create_args=( + --region "$AWS_REGION" + --name "$SECRET_NAME" + --secret-string "$SECRET_PAYLOAD" + --output json +) +if [ -n "${SM_KMS_KEY_ID:-}" ]; then + create_args+=(--kms-key-id "$SM_KMS_KEY_ID") +fi + +err_file=$(mktemp) +T_AWS=$(timer_now) +if RESULT=$(aws secretsmanager create-secret "${create_args[@]}" 2>"$err_file"); then + log debug " ⏱ aws secretsmanager create-secret $(timer_elapsed "$T_AWS")" + rm -f "$err_file" +elif grep -q "ResourceExistsException" "$err_file"; then + log debug " ⏱ aws secretsmanager create-secret (exists) $(timer_elapsed "$T_AWS")" + rm -f "$err_file" + log debug "Secret '$SECRET_NAME' exists; adding new version (history preserved by AWS SM)" + err_file=$(mktemp) + T_AWS=$(timer_now) + if ! RESULT=$(aws secretsmanager put-secret-value \ + --region "$AWS_REGION" \ + --secret-id "$SECRET_NAME" \ + --secret-string "$SECRET_PAYLOAD" \ + --output json 2>"$err_file"); then + err=$(cat "$err_file") + rm -f "$err_file" + log error "❌ Failed to add new version to secret '$SECRET_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:PutSecretValue on this resource" + log error " • Secret is in a deletion-pending state" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi + log debug " ⏱ aws secretsmanager put-secret-value $(timer_elapsed "$T_AWS")" + rm -f "$err_file" +else + err=$(cat "$err_file") + rm -f "$err_file" + log error "❌ Failed to store parameter in AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:CreateSecret on this resource" + log error " • Region '$AWS_REGION' unreachable or wrong" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 +fi + +SECRET_ARN=$(echo "$RESULT" | jq -r '.ARN') +VERSION_ID=$(echo "$RESULT" | jq -r '.VersionId') + +# Encode version into external_id (the canonical handle nullplatform persists). +# Retrieve will parse this to fetch the specific version; delete ignores the suffix. +[ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg secret_arn "$SECRET_ARN" \ + --arg secret_name "$SECRET_NAME" \ + --arg region "$AWS_REGION" \ + '{external_id: $external_id, metadata: {secret_arn: $secret_arn, secret_name: $secret_name, region: $region}}' diff --git a/parameters/providers/azure-key-vault/delete b/parameters/providers/azure-key-vault/delete new file mode 100755 index 00000000..ad9361b4 --- /dev/null +++ b/parameters/providers/azure-key-vault/delete @@ -0,0 +1,61 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from Azure Key Vault. Idempotent. +# Soft-delete + purge (purge is best-effort, downgraded to warning on failure). +# +# Required env: EXTERNAL_ID (canonical slash form), AZ_VAULT_NAME, AZ_SECRET_PREFIX + +# AKV stores with dashes (no `/` or `=`); transform from canonical form. +AKV_SUFFIX=$(echo "$EXTERNAL_ID_PATH" | tr '/=' '--') +SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" + +err_file=$(mktemp) +T_AZ=$(timer_now) +if az keyvault secret delete \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" >/dev/null 2>"$err_file"; then + log debug " ⏱ az keyvault secret delete $(timer_elapsed "$T_AZ")" + rm -f "$err_file" +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -qE "(SecretNotFound|secret with .* was not found)"; then + log debug "Secret '$SECRET_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + return 0 2>/dev/null || exit 0 + else + log error "❌ Failed to delete secret '$SECRET_NAME' from Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks 'Delete' permission on vault $AZ_VAULT_NAME" + log error " • Vault firewall blocks the caller" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error "Underlying error: $err" + exit 1 + fi +fi + +# Best-effort purge to release name and stop retention billing. +purge_err=$(mktemp) +T_AZ_PURGE=$(timer_now) +if ! az keyvault secret purge \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" >/dev/null 2>"$purge_err"; then + pe=$(cat "$purge_err") + if echo "$pe" | grep -qiE "(Forbidden|not authorized|purge permission)"; then + log warn "⚠️ Purge permission missing on '$AZ_VAULT_NAME' — secret remains in soft-delete window" + else + log warn "⚠️ Purge failed (secret remains in soft-delete window): $pe" + fi +fi +log debug " ⏱ az keyvault secret purge $(timer_elapsed "$T_AZ_PURGE")" +rm -f "$purge_err" + +echo '{ + "success": true +}' diff --git a/parameters/providers/azure-key-vault/docs/architecture.md b/parameters/providers/azure-key-vault/docs/architecture.md new file mode 100644 index 00000000..bd9f70ab --- /dev/null +++ b/parameters/providers/azure-key-vault/docs/architecture.md @@ -0,0 +1,86 @@ +# Azure Key Vault — Provider Architecture + +This document describes the `parameters/providers/azure-key-vault/` implementation. It stores nullplatform parameters as Azure Key Vault (AKV) secrets, exploiting AKV's native versioning. + +--- + +## Lifecycle + +| Step | What happens | +|------|-------------------------------------------------------------------------------| +| `setup` | Reads `AZ_VAULT_NAME`, `AZ_SECRET_PREFIX` (default `nullplatform-`). Validates prefix matches `[A-Za-z0-9-]*`. | +| `store` | Composes canonical path via `build_external_id`. Transforms (slash → dash, equals → dash) for AKV naming. Calls `az keyvault secret set` with `--tags managed_by=nullplatform`. Extracts version from the returned id URL. Returns `external_id = #`. | +| `retrieve` | Parses canonical path + version. Re-transforms path to AKV name. Calls `az keyvault secret show` with `--version ` if a version is present. | +| `delete` | Calls `az keyvault secret delete` + best-effort `purge`. Idempotent. | +| `notify` | Not implemented — dispatcher returns default `{success: true}`. | + +--- + +## Storage layout + +AKV secret names allow only alphanumerics and dashes (no slashes, no equals, no underscores). The canonical path from `build_external_id` contains slashes and equals, so we transform it: + +``` +canonical: organization=acme-1255165411/account=prod-95118862/.../DB_PASSWORD-42 +AKV name: nullplatform-organization-acme-1255165411-account-prod-95118862-...-DB_PASSWORD-42 +``` + +The transformation is `/=` → `-`, deterministic. The canonical form (with `/` and `=`) is what nullplatform sees in `external_id`; the AKV-safe form is only used internally to address the secret. + +The canonical path follows the standard convention: required entities `organization`, `account`, `namespace`, `application`, plus the optional `scope` entity (when the parameter is bound to a deployment scope), plus optional dimensions (zero or more, sorted alphabetically). See `parameters/docs/architecture.md` for the complete naming convention. + +Max secret name length in AKV is 127 characters. The provider checks this and surfaces a helpful error if exceeded. + +--- + +## Versioning + +AKV has native versioning. Every `az keyvault secret set` creates a new version, all retained inside the same secret. The version identifier is the last segment of the returned `id` URL. + +### Version identity in external_id + +The `external_id` returned by `store` encodes both the path and the version: + +``` +# +``` + +For Azure Key Vault, `version_id` is **the literal hex string version returned by AKV** — we do not invent or normalize it. AKV returns the secret's id as a URL like `https://my-vault.vault.azure.net/secrets/my-secret/93a0b2eb12a64fa7b3acb18900a8d33d`; we extract the last path segment. Real example: + +``` +organization=acme-1255165411/.../DB_PASSWORD-42#93a0b2eb12a64fa7b3acb18900a8d33d +``` + +That 32-char hex string is the AKV version identifier. It can be used as-is with `az keyvault secret show --version 93a0b2eb12a64fa7b3acb18900a8d33d` to fetch that specific historical version. + +On `retrieve`: +- With `#` → fetch that version via `--version `. +- Without → fetch the latest. + +On `delete`, the version suffix is ignored — `secret delete` + `secret purge` remove all versions. + +--- + +## Soft-delete + purge + +AKV uses soft-delete by default (90-day retention). The provider does both: + +1. `az keyvault secret delete` — moves to soft-deleted state. +2. `az keyvault secret purge` — hard-deletes from the soft-delete bin, freeing the name immediately. + +If the identity lacks `Purge` permission, purge fails with a warning but delete still succeeds. The secret stays in the soft-delete window and is auto-cleaned by Azure at retention expiry. + +--- + +## Configuration + +`PROVIDER_CONFIG` shape: + +```json +{ + "vault_name": "my-keyvault", + "secret_prefix": "nullplatform-" +} +``` + +Authentication comes from the Azure CLI's default credential chain (managed identity, az login, service principal env vars). diff --git a/parameters/providers/azure-key-vault/retrieve b/parameters/providers/azure-key-vault/retrieve new file mode 100755 index 00000000..66ce3724 --- /dev/null +++ b/parameters/providers/azure-key-vault/retrieve @@ -0,0 +1,56 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a secret from Azure Key Vault by external_id. +# +# Required env: EXTERNAL_ID (canonical slash form), AZ_VAULT_NAME, AZ_SECRET_PREFIX + +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context. +# AKV stores with dashes (no `/` or `=`); transform from canonical path. +AKV_SUFFIX=$(echo "$EXTERNAL_ID_PATH" | tr '/=' '--') +SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" + +show_args=( + --vault-name "$AZ_VAULT_NAME" + --name "$SECRET_NAME" + --query value + --output tsv +) +[ -n "${EXTERNAL_ID_VERSION:-}" ] && show_args+=(--version "$EXTERNAL_ID_VERSION") + +err_file=$(mktemp) +T_AZ=$(timer_now) +if VALUE=$(az keyvault secret show "${show_args[@]}" 2>"$err_file"); then + log debug " ⏱ az keyvault secret show $(timer_elapsed "$T_AZ")" + rm -f "$err_file" + jq -n --arg value "$VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -qE "(SecretNotFound|secret with .* was not found)"; then + log error "❌ Secret '$SECRET_NAME' not found in Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • The secret was manually deleted (and possibly purged) from AKV" + log error " • The external_id is stale" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' was purged from AKV" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify: az keyvault secret show --vault-name $AZ_VAULT_NAME --name $SECRET_NAME" + log error " • If genuinely missing, the parameter value needs to be re-stored" + exit 1 + else + log error "❌ Failed to retrieve secret '$SECRET_NAME' from Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks 'Get' permission on vault $AZ_VAULT_NAME" + log error " • Vault firewall blocks the caller" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/azure-key-vault/setup b/parameters/providers/azure-key-vault/setup new file mode 100755 index 00000000..a89e35c6 --- /dev/null +++ b/parameters/providers/azure-key-vault/setup @@ -0,0 +1,34 @@ +#!/bin/bash +set -euo pipefail + +# Validates Azure Key Vault connection config. +# +# The secret prefix is hardcoded to `nullplatform-` — invariant namespace for +# all platform secrets in this vault. Only the vault name is operator-configurable. +# +# Auth comes from the Azure CLI's default credential chain (managed identity, +# az login, service principal env vars). Not validated here — az will surface +# auth errors on the first call. +# +# Exports: AZ_VAULT_NAME, AZ_SECRET_PREFIX + +AZ_VAULT_NAME=$(get_config_value \ + --env AZURE_KEY_VAULT_NAME \ + --provider '.setup.vault_name') + +AZ_SECRET_PREFIX="nullplatform-" + +if [ -z "$AZ_VAULT_NAME" ]; then + log error "❌ Azure Key Vault name not configured" + log error "" + log error "💡 Possible causes:" + log error " • AZURE_KEY_VAULT_NAME env var is not set" + log error " • .vault_name is missing in the azure-key-vault provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set AZURE_KEY_VAULT_NAME=" + log error " • Or set .vault_name in the provider config attributes" + exit 1 +fi + +export AZ_VAULT_NAME AZ_SECRET_PREFIX diff --git a/parameters/providers/azure-key-vault/specs/azure-key-vault-configuration.json.tpl b/parameters/providers/azure-key-vault/specs/azure-key-vault-configuration.json.tpl new file mode 100644 index 00000000..dcea48a7 --- /dev/null +++ b/parameters/providers/azure-key-vault/specs/azure-key-vault-configuration.json.tpl @@ -0,0 +1,56 @@ +{ + "name": "Azure Key Vault", + "description": "Stores nullplatform parameter values in Azure Key Vault with native versioning", + "slug": "azure-key-vault", + "category": "parameters-storage", + "icon": "mdi:microsoft-azure", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "allow_dimensions": true, + "schema": { + "type": "object", + "required": ["sensibility", "setup"], + "additionalProperties": false, + "properties": { + "sensibility": { + "type": "object", + "order": 1, + "required": ["applies_to"], + "description": "The sensibility of the parameters stored in this backend.", + "properties": { + "applies_to": { + "type": "array", + "title": "Applies to", + "description": "Which parameters this backend stores — secret, non-secret, or both.", + "order": 1, + "inline": true, + "uniqueItems": true, + "minItems": 1, + "default": ["secret"], + "items": { + "oneOf": [ + { "const": "secret", "title": "Secret parameters" }, + { "const": "non_secret", "title": "Non-secret parameters" } + ] + } + } + } + }, + "setup": { + "type": "object", + "order": 2, + "required": ["vault_name"], + "description": "The setup for the Azure Key Vault backend.", + "properties": { + "vault_name": { + "type": "string", + "title": "Vault Name", + "description": "Azure Key Vault name (without https:// or .vault.azure.net suffix)", + "order": 1 + } + } + } + } + } +} diff --git a/parameters/providers/azure-key-vault/specs/locals.tf b/parameters/providers/azure-key-vault/specs/locals.tf new file mode 100644 index 00000000..be751e0a --- /dev/null +++ b/parameters/providers/azure-key-vault/specs/locals.tf @@ -0,0 +1,34 @@ +locals { + # The configuration template uses gomplate-style `{{ env.Getenv "NRN" }}` for + # `visible_to` because it's also consumed by non-tofu install paths. The only + # token in the file is NRN, so we replace it inline rather than pulling in + # gomplate as a build dependency. + template_path = "${path.module}/azure-key-vault-configuration.json.tpl" + template_raw = file(local.template_path) + template_rendered = replace(local.template_raw, "{{ env.Getenv \"NRN\" }}", var.nrn) + config = jsondecode(local.template_rendered) + cmdline_path = "nullplatform/scopes/parameters/entrypoint" + + # The spec must be visible to the anchor NRN and to every NRN where an + # instance lives — otherwise the instance can't reference its own spec. + instance_nrns = distinct([for _, inst in var.instances : inst.nrn]) + spec_visible_to = distinct(concat( + [var.nrn], + local.instance_nrns, + var.extra_visible_to_nrns, + )) + + # Instances that get their own agent API key + notification channel. + notification_instances = { + for key, instance in var.instances : key => instance + if instance.notification_channel_enabled + } + + api_key_grants = [ + "controlplane:agent", + "developer", + "ops", + "secops", + "secrets-reader", + ] +} diff --git a/parameters/providers/azure-key-vault/specs/main.tf b/parameters/providers/azure-key-vault/specs/main.tf new file mode 100644 index 00000000..75f186f4 --- /dev/null +++ b/parameters/providers/azure-key-vault/specs/main.tf @@ -0,0 +1,98 @@ +################################################################################ +# Azure Key Vault — specs module +# +# Responsibilities: +# +# 1. nullplatform_provider_specification.this +# Created from ./azure-key-vault-configuration.json.tpl. The JSON file is +# the canonical declaration of the provider's metadata and its config +# schema (vault_name). +# +# 2. module.scope_configuration (for_each = var.instances) +# One concrete instance per entry in var.instances, each with its own NRN, +# dimensions, and Key Vault name — so operators can route different +# accounts/environments to different Key Vaults. +# +# 3. nullplatform_api_key.this + nullplatform_notification_channel.from_template +# Per instance (unless notification_channel_enabled=false): an agent API key +# and its notification channel, anchored at the instance NRN, that handle +# secret storage and retrieval. +################################################################################ + +resource "nullplatform_provider_specification" "this" { + name = local.config.name + icon = local.config.icon + description = local.config.description + category = local.config.category + allow_dimensions = local.config.allow_dimensions + visible_to = local.spec_visible_to + schema = jsonencode(local.config.schema) +} + +module "scope_configuration" { + for_each = var.instances + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_configuration?ref=v4.5.1" + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = local.config.slug + dimensions = each.value.dimensions + + attributes = { + sensibility = { + applies_to = each.value.applies_to + } + setup = { + vault_name = each.value.vault_name + } + } + + depends_on = [nullplatform_provider_specification.this] +} + +resource "nullplatform_api_key" "this" { + for_each = local.notification_instances + + name = "azure-key-vault-api-key-${each.key}" + dynamic "grants" { + for_each = toset(local.api_key_grants) + content { + nrn = each.value.nrn + role_slug = grants.value + } + } + + tags { + key = "managedBy" + value = "IaC" + } + + lifecycle { + create_before_destroy = true + } +} + +resource "nullplatform_notification_channel" "from_template" { + for_each = local.notification_instances + + nrn = each.value.nrn + type = "agent" + source = ["parameters"] + description = "Notification channel to handle parameter storage and retrieval" + configuration { + agent { + api_key = nullplatform_api_key.this[each.key].api_key + selector = each.value.tags_selectors + command { + data = { + "cmdline" : local.cmdline_path + "environment" : jsonencode({ + NP_ACTION_CONTEXT = "'$${NOTIFICATION_CONTEXT}'" + LOG_LEVEL = "debug" + }) + } + type = "exec" + } + } + } +} diff --git a/parameters/providers/azure-key-vault/specs/outputs.tf b/parameters/providers/azure-key-vault/specs/outputs.tf new file mode 100644 index 00000000..711f3b74 --- /dev/null +++ b/parameters/providers/azure-key-vault/specs/outputs.tf @@ -0,0 +1,14 @@ +output "specification_id" { + description = "ID of the nullplatform_provider_specification created from azure-key-vault-configuration.json.tpl." + value = nullplatform_provider_specification.this.id +} + +output "notification_channel_ids" { + description = "Map of instance key => ID of its agent notification channel. Only includes instances with notification_channel_enabled=true." + value = { for key, channel in nullplatform_notification_channel.from_template : key => channel.id } +} + +output "notification_channel_statuses" { + description = "Map of instance key => status of its agent notification channel. Only includes instances with notification_channel_enabled=true." + value = { for key, channel in nullplatform_notification_channel.from_template : key => channel.status } +} diff --git a/parameters/providers/azure-key-vault/specs/terraform.tfvars.example b/parameters/providers/azure-key-vault/specs/terraform.tfvars.example new file mode 100644 index 00000000..44a8953c --- /dev/null +++ b/parameters/providers/azure-key-vault/specs/terraform.tfvars.example @@ -0,0 +1,25 @@ +nrn = "organization=acme-1255165411:account=prod-95118862" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +instances = { + prod-billing = { + nrn = "organization=acme-1255165411:account=prod-95118862:namespace=billing-37094320" + dimensions = { environment = "production" } + vault_name = "acme-prod-billing-kv" + applies_to = ["secret"] + + # Each instance gets its own agent API key + notification channel anchored at its nrn. + tags_selectors = { environment = "production" } + } + staging-billing = { + nrn = "organization=acme-1255165411:account=staging-95118863:namespace=billing-37094320" + dimensions = { environment = "staging" } + vault_name = "acme-staging-billing-kv" + applies_to = ["secret"] + + # Skip the agent channel + API key for this instance (e.g. managed elsewhere). + notification_channel_enabled = false + } +} diff --git a/parameters/providers/azure-key-vault/specs/variables.tf b/parameters/providers/azure-key-vault/specs/variables.tf new file mode 100644 index 00000000..9487fb8a --- /dev/null +++ b/parameters/providers/azure-key-vault/specs/variables.tf @@ -0,0 +1,38 @@ +variable "nrn" { + description = "NRN where the provider specification is anchored (the top-level scope it belongs to)." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the upstream scope_configuration module to register provider instances." + type = string + sensitive = true +} + +variable "extra_visible_to_nrns" { + description = "Additional NRNs that should see the provider specification besides var.nrn and the per-instance NRNs." + type = list(string) + default = [] +} + +variable "instances" { + description = <<-EOT + Provider instances to create. Map key is a stable identifier (used in for_each). + Each entry carries its own NRN, dimensions, Azure Key Vault name, and the parameter + sensibility set this instance handles (secret / non_secret / both). + Each instance also gets its own agent API key + notification channel (anchored at the + instance NRN) unless notification_channel_enabled=false. Fields: + notification_channel_enabled — create the agent channel + its API key for this instance (default true). + tags_selectors — tag key/value pairs the agent uses to match this instance's channel + against scope tags (e.g. { environment = "development" }). + EOT + type = map(object({ + nrn = string + dimensions = map(string) + vault_name = string + applies_to = list(string) + notification_channel_enabled = optional(bool, true) + tags_selectors = optional(map(string), {}) + })) + default = {} +} diff --git a/parameters/providers/azure-key-vault/specs/versions.tf b/parameters/providers/azure-key-vault/specs/versions.tf new file mode 100644 index 00000000..5a677bcb --- /dev/null +++ b/parameters/providers/azure-key-vault/specs/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.95" + } + } +} diff --git a/parameters/providers/azure-key-vault/store b/parameters/providers/azure-key-vault/store new file mode 100755 index 00000000..960208f0 --- /dev/null +++ b/parameters/providers/azure-key-vault/store @@ -0,0 +1,58 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter as an Azure Key Vault secret. +# +# Versioning model: +# nullplatform parameter values are immutable. Each update is a new VERSION. +# Azure Key Vault has native versioning — every `secret set` creates a new +# version, all retained inside the same secret. No special logic needed. +# +# AKV doesn't allow `/` or `=` in secret names, so the canonical EXTERNAL_ID is +# transformed (slash → dash, equals → dash) for the secret name. The external_id +# returned to nullplatform keeps the canonical slash form. +# +# Required env: PARAMETER_VALUE, AZ_VAULT_NAME, AZ_SECRET_PREFIX + +source "$PARAMETERS_ROOT/utils/build_external_id" +build_external_id + +# AKV-safe name: replace / and = with - +AKV_SUFFIX=$(echo "$EXTERNAL_ID" | tr '/=' '--') +SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" + +T_AZ=$(timer_now) +if ! AKV_ID=$(az keyvault secret set \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" \ + --value "$PARAMETER_VALUE" \ + --tags "managed_by=nullplatform" \ + --query id \ + --output tsv 2>/dev/null); then + log error "❌ Failed to store secret in Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks Set permission on $AZ_VAULT_NAME" + log error " • Vault is in soft-deleted state or firewall blocks the caller" + log error " • Secret name length exceeds AKV limit of 127 chars (current: ${#SECRET_NAME})" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" + exit 1 +fi +log debug " ⏱ az keyvault secret set $(timer_elapsed "$T_AZ")" + +# AKV id URL format: https://.vault.azure.net/secrets// +# The last path component is the version id (hex string). +VERSION_ID="${AKV_ID##*/}" + +# Encode version into external_id (the canonical handle nullplatform persists). +[ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg secret_id "$AKV_ID" \ + --arg secret_name "$SECRET_NAME" \ + --arg vault_name "$AZ_VAULT_NAME" \ + '{external_id: $external_id, metadata: {azure_secret_id: $secret_id, secret_name: $secret_name, vault_name: $vault_name}}' diff --git a/parameters/providers/hashicorp-vault/delete b/parameters/providers/hashicorp-vault/delete new file mode 100755 index 00000000..515f9aa4 --- /dev/null +++ b/parameters/providers/hashicorp-vault/delete @@ -0,0 +1,55 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from Vault by external_id. +# +# Idempotency semantics: +# - HTTP 2xx → success (deleted) +# - HTTP 404 → success (already gone; treated as idempotent) +# - Any other HTTP status or network error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID_PATH" + +T_CURL=$(timer_now) +if ! RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_PATH" 2>/dev/null); then + log error "❌ Network error calling Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Vault host unreachable (DNS / network / firewall)" + log error " • TLS handshake failure" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + exit 1 +fi +log debug " ⏱ vault DELETE $VAULT_PATH $(timer_elapsed "$T_CURL")" + +HTTP_STATUS="${RESPONSE##*$'\n'}" + +case "$HTTP_STATUS" in + 2*) ;; + 404) + log debug "Secret at $VAULT_PATH does not exist, treating delete as success" + ;; + *) + HTTP_BODY="${RESPONSE%$'\n'*}" + log error "❌ Vault DELETE failed with HTTP $HTTP_STATUS at $VAULT_PATH" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN lacks delete permission at this path (403)" + log error " • Server-side error (5xx) — check Vault logs" + log error "" + log error "🔧 How to fix:" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + log error "Vault response: $HTTP_BODY" + exit 1 + ;; +esac + +echo '{ + "success": true +}' diff --git a/parameters/providers/hashicorp-vault/docs/architecture.md b/parameters/providers/hashicorp-vault/docs/architecture.md new file mode 100644 index 00000000..40b8c060 --- /dev/null +++ b/parameters/providers/hashicorp-vault/docs/architecture.md @@ -0,0 +1,92 @@ +# HashiCorp Vault — Provider Architecture + +This document describes the `parameters/providers/hashicorp-vault/` implementation. It stores nullplatform parameters as Vault KV v2 secrets, exploiting Vault's native versioning. + +--- + +## Lifecycle + +| Step | What happens | +|------|---------------------------------------------------------------------------------------| +| `setup` | Reads `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX` (default `secret/data/nullplatform`). Fails fast if address or token is missing. | +| `store` | Composes the canonical path via `build_external_id`. POSTs to `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/` with a JSON payload. Captures the new version number from Vault's response. Returns `external_id = #`. | +| `retrieve` | Parses `EXTERNAL_ID` into path + version. GETs `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/?version=` if a version is present; otherwise fetches the latest. Returns `{value}` or `{value: "value not found"}`. | +| `delete` | Parses path from external_id. DELETEs the metadata endpoint (KV v2) — removes all versions. Idempotent. | +| `notify` | Not implemented — dispatcher returns default `{success: true}`. | + +--- + +## Storage layout + +Every secret path is composed by `parameters/utils/build_external_id`: + +``` +/organization=-/account=-/namespace=-/application=-[/scope=-][/=...]/- +``` + +The `scope` entity is optional (only present when the parameter is bound to a deployment scope). Dimensions are also optional — a parameter may have zero of them. See `parameters/docs/architecture.md` for the complete naming convention. + +Default `VAULT_PATH_PREFIX` is `secret/data/nullplatform` (KV v2 — note the `data/` segment is required by the v2 API). + +Example full path: + +``` +secret/data/nullplatform/organization=acme-1255165411/account=prod-95118862/.../DB_PASSWORD-42 +``` + +The path is human-friendly: navigating the Vault UI, an operator can find any secret by knowing the parameter's NRN + dimensions + name. + +--- + +## Versioning + +Vault KV v2 has native versioning. Every `POST /v1/secret/data/` creates a new version, all retained inside the same path. Old versions can be fetched with `?version=`. + +### Version identity in external_id + +The `external_id` returned by `store` encodes both the path and the version: + +``` +# +``` + +For Vault KV v2, `version_id` is **the literal integer version number returned by Vault** in `.data.version` — we do not invent or normalize it. Real example: + +``` +organization=acme-1255165411/.../DB_PASSWORD-42#3 +``` + +Here `3` means "Vault version 3 of this secret". It can be used as-is with `?version=3` to fetch that specific version. + +On `retrieve`: +- If `external_id` carries `#` → fetch that historical version via `?version=N`. +- If no `#` suffix → fetch the latest (default Vault behavior). + +On `delete`, the version suffix is ignored. KV v2's data DELETE removes the latest version label; for full purging across all versions you'd use `metadata` endpoint — see Vault docs for the soft/hard delete distinction. + +--- + +## Secret payload + +The body stored in Vault is a JSON envelope: + +```json +{ + "data": { + "parameter_id": 42, + "value": "the-actual-value", + "stored_at": "2026-06-23T12:34:56Z", + "external_id": "organization=acme-1255165411/.../DB_PASSWORD-42" + } +} +``` + +The `data` wrapper is KV v2's API requirement; the inner object is our envelope. + +--- + +## Authentication + +Token-based via `X-Vault-Token` header. The token must have read/write permissions on the configured `VAULT_PATH_PREFIX` namespace. + +For production: use short-lived tokens (issued by AppRole, Kubernetes auth, etc.) refreshed by the operator outside this package. The agent only reads `VAULT_TOKEN` — credential lifecycle management is the operator's responsibility. diff --git a/parameters/providers/hashicorp-vault/retrieve b/parameters/providers/hashicorp-vault/retrieve new file mode 100755 index 00000000..f5f80430 --- /dev/null +++ b/parameters/providers/hashicorp-vault/retrieve @@ -0,0 +1,73 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a value from Vault by external_id. +# +# Semantics: +# - HTTP 2xx → return {value: ""} +# - HTTP 404 → return {value: "value not found"} (legitimate miss) +# - Any other HTTP status or network error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context. +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID_PATH" + +URL="$VAULT_ADDR/v1/$VAULT_PATH" +if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + URL="${URL}?version=${EXTERNAL_ID_VERSION}" +fi + +T_CURL=$(timer_now) +if ! RESPONSE=$(curl -s -w "\n%{http_code}" \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$URL" 2>/dev/null); then + log error "❌ Network error calling Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Vault host unreachable (DNS / network / firewall)" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + exit 1 +fi +log debug " ⏱ vault GET $URL $(timer_elapsed "$T_CURL")" + +HTTP_STATUS="${RESPONSE##*$'\n'}" +HTTP_BODY="${RESPONSE%$'\n'*}" + +case "$HTTP_STATUS" in + 2*) + STORED_VALUE=$(echo "$HTTP_BODY" | jq -r '.data.data.value // empty') + echo '{ + "value": "'$STORED_VALUE'" + }' + ;; + 404) + log error "❌ Secret at $VAULT_PATH not found in Vault" + log error "" + log error "💡 Possible causes:" + log error " • The secret was manually deleted from Vault" + log error " • The external_id is stale" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' was destroyed via Vault's metadata API" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/$VAULT_PATH" + log error " • If genuinely missing, the parameter value needs to be re-stored" + exit 1 + ;; + *) + log error "❌ Vault GET failed with HTTP $HTTP_STATUS at $VAULT_PATH" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN lacks read permission (403)" + log error " • Server-side error (5xx)" + log error "" + log error "🔧 How to fix:" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + log error "Vault response: $HTTP_BODY" + exit 1 + ;; +esac diff --git a/parameters/providers/hashicorp-vault/setup b/parameters/providers/hashicorp-vault/setup new file mode 100755 index 00000000..0d40c766 --- /dev/null +++ b/parameters/providers/hashicorp-vault/setup @@ -0,0 +1,41 @@ +#!/bin/bash +set -euo pipefail + +# Validates HashiCorp Vault connection config. +# +# The KV path prefix is hardcoded to `secret/data/nullplatform` — invariant +# namespace for all platform secrets. VAULT_ADDR is operator-configurable +# (per Vault instance). VAULT_TOKEN is sensitive and should come from env only. +# +# Exports: VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.setup.address') +VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN) +VAULT_PATH_PREFIX="secret/data/nullplatform" + +if [ -z "$VAULT_ADDR" ]; then + log error "❌ Vault address not configured" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_ADDR env var is not set in the workflow runtime" + log error " • .address is missing in the hashicorp-vault provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set VAULT_ADDR=https://your-vault-host" + log error " • Or set .address in the provider config attributes" + exit 1 +fi + +if [ -z "$VAULT_TOKEN" ]; then + log error "❌ Vault token not configured" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN env var is not set in the agent runtime" + log error "" + log error "🔧 How to fix:" + log error " • Set VAULT_TOKEN= in the agent environment" + log error " • Tokens are sensitive — set via env, never via provider config" + exit 1 +fi + +export VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX diff --git a/parameters/providers/hashicorp-vault/specs/hashicorp-vault-configuration.json.tpl b/parameters/providers/hashicorp-vault/specs/hashicorp-vault-configuration.json.tpl new file mode 100644 index 00000000..c6f54f76 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/hashicorp-vault-configuration.json.tpl @@ -0,0 +1,56 @@ +{ + "name": "HashiCorp Vault", + "description": "Stores nullplatform parameter values in HashiCorp Vault KV v2 with native versioning", + "slug": "hashicorp-vault", + "category": "parameters-storage", + "icon": "mdi:vault", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "allow_dimensions": true, + "schema": { + "type": "object", + "required": ["sensibility", "setup"], + "additionalProperties": false, + "properties": { + "sensibility": { + "type": "object", + "order": 1, + "required": ["applies_to"], + "description": "The sensibility of the parameters stored in this backend.", + "properties": { + "applies_to": { + "type": "array", + "title": "Applies to", + "description": "Which parameters this backend stores — secret, non-secret, or both.", + "order": 1, + "inline": true, + "uniqueItems": true, + "minItems": 1, + "default": ["secret", "non_secret"], + "items": { + "oneOf": [ + { "const": "secret", "title": "Secret parameters" }, + { "const": "non_secret", "title": "Non-secret parameters" } + ] + } + } + } + }, + "setup": { + "type": "object", + "order": 2, + "required": ["address"], + "description": "The setup for the HashiCorp Vault backend.", + "properties": { + "address": { + "type": "string", + "title": "Vault Address", + "description": "Vault HTTP(S) endpoint (e.g. https://vault.example.com:8200)", + "order": 1 + } + } + } + } + } +} diff --git a/parameters/providers/hashicorp-vault/specs/locals.tf b/parameters/providers/hashicorp-vault/specs/locals.tf new file mode 100644 index 00000000..3d4e5f57 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/locals.tf @@ -0,0 +1,34 @@ +locals { + # The configuration template uses gomplate-style `{{ env.Getenv "NRN" }}` for + # `visible_to` because it's also consumed by non-tofu install paths. The only + # token in the file is NRN, so we replace it inline rather than pulling in + # gomplate as a build dependency. + template_path = "${path.module}/hashicorp-vault-configuration.json.tpl" + template_raw = file(local.template_path) + template_rendered = replace(local.template_raw, "{{ env.Getenv \"NRN\" }}", var.nrn) + config = jsondecode(local.template_rendered) + cmdline_path = "nullplatform/scopes/parameters/entrypoint" + + # The spec must be visible to the anchor NRN and to every NRN where an + # instance lives — otherwise the instance can't reference its own spec. + instance_nrns = distinct([for _, inst in var.instances : inst.nrn]) + spec_visible_to = distinct(concat( + [var.nrn], + local.instance_nrns, + var.extra_visible_to_nrns, + )) + + # Instances that get their own agent API key + notification channel. + notification_instances = { + for key, instance in var.instances : key => instance + if instance.notification_channel_enabled + } + + api_key_grants = [ + "controlplane:agent", + "developer", + "ops", + "secops", + "secrets-reader", + ] +} diff --git a/parameters/providers/hashicorp-vault/specs/main.tf b/parameters/providers/hashicorp-vault/specs/main.tf new file mode 100644 index 00000000..4207cedb --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/main.tf @@ -0,0 +1,98 @@ +################################################################################ +# HashiCorp Vault — specs module +# +# Responsibilities: +# +# 1. nullplatform_provider_specification.this +# Created from ./hashicorp-vault-configuration.json.tpl. The JSON file is +# the canonical declaration of the provider's metadata and its config +# schema (address). +# +# 2. module.scope_configuration (for_each = var.instances) +# One concrete instance per entry in var.instances, each with its own NRN, +# dimensions, and Vault address — so operators can point different +# accounts/environments at different Vault clusters. +# +# 3. nullplatform_api_key.this + nullplatform_notification_channel.from_template +# Per instance (unless notification_channel_enabled=false): an agent API key +# and its notification channel, anchored at the instance NRN, that handle +# secret storage and retrieval. +################################################################################ + +resource "nullplatform_provider_specification" "this" { + name = local.config.name + icon = local.config.icon + description = local.config.description + category = local.config.category + allow_dimensions = local.config.allow_dimensions + visible_to = local.spec_visible_to + schema = jsonencode(local.config.schema) +} + +module "scope_configuration" { + for_each = var.instances + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_configuration?ref=v4.5.1" + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = local.config.slug + dimensions = each.value.dimensions + + attributes = { + sensibility = { + applies_to = each.value.applies_to + } + setup = { + address = each.value.address + } + } + + depends_on = [nullplatform_provider_specification.this] +} + +resource "nullplatform_api_key" "this" { + for_each = local.notification_instances + + name = "hashicorp-vault-api-key-${each.key}" + dynamic "grants" { + for_each = toset(local.api_key_grants) + content { + nrn = each.value.nrn + role_slug = grants.value + } + } + + tags { + key = "managedBy" + value = "IaC" + } + + lifecycle { + create_before_destroy = true + } +} + +resource "nullplatform_notification_channel" "from_template" { + for_each = local.notification_instances + + nrn = each.value.nrn + type = "agent" + source = ["parameters"] + description = "Notification channel to handle parameter storage and retrieval" + configuration { + agent { + api_key = nullplatform_api_key.this[each.key].api_key + selector = each.value.tags_selectors + command { + data = { + "cmdline" : local.cmdline_path + "environment" : jsonencode({ + NP_ACTION_CONTEXT = "'$${NOTIFICATION_CONTEXT}'" + LOG_LEVEL = "debug" + }) + } + type = "exec" + } + } + } +} diff --git a/parameters/providers/hashicorp-vault/specs/outputs.tf b/parameters/providers/hashicorp-vault/specs/outputs.tf new file mode 100644 index 00000000..bbf3e0c0 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/outputs.tf @@ -0,0 +1,14 @@ +output "specification_id" { + description = "ID of the nullplatform_provider_specification created from hashicorp-vault-configuration.json.tpl." + value = nullplatform_provider_specification.this.id +} + +output "notification_channel_ids" { + description = "Map of instance key => ID of its agent notification channel. Only includes instances with notification_channel_enabled=true." + value = { for key, channel in nullplatform_notification_channel.from_template : key => channel.id } +} + +output "notification_channel_statuses" { + description = "Map of instance key => status of its agent notification channel. Only includes instances with notification_channel_enabled=true." + value = { for key, channel in nullplatform_notification_channel.from_template : key => channel.status } +} diff --git a/parameters/providers/hashicorp-vault/specs/terraform.tfvars.example b/parameters/providers/hashicorp-vault/specs/terraform.tfvars.example new file mode 100644 index 00000000..52ce3f95 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/terraform.tfvars.example @@ -0,0 +1,25 @@ +nrn = "organization=acme-1255165411:account=prod-95118862" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +instances = { + prod-billing = { + nrn = "organization=acme-1255165411:account=prod-95118862:namespace=billing-37094320" + dimensions = { environment = "production" } + address = "https://vault.prod.example.com:8200" + applies_to = ["secret", "non_secret"] + + # Each instance gets its own agent API key + notification channel anchored at its nrn. + tags_selectors = { environment = "production" } + } + staging-billing = { + nrn = "organization=acme-1255165411:account=staging-95118863:namespace=billing-37094320" + dimensions = { environment = "staging" } + address = "https://vault.staging.example.com:8200" + applies_to = ["secret", "non_secret"] + + # Skip the agent channel + API key for this instance (e.g. managed elsewhere). + notification_channel_enabled = false + } +} diff --git a/parameters/providers/hashicorp-vault/specs/variables.tf b/parameters/providers/hashicorp-vault/specs/variables.tf new file mode 100644 index 00000000..66a4d59a --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/variables.tf @@ -0,0 +1,38 @@ +variable "nrn" { + description = "NRN where the provider specification is anchored (the top-level scope it belongs to)." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the upstream scope_configuration module to register provider instances." + type = string + sensitive = true +} + +variable "extra_visible_to_nrns" { + description = "Additional NRNs that should see the provider specification besides var.nrn and the per-instance NRNs." + type = list(string) + default = [] +} + +variable "instances" { + description = <<-EOT + Provider instances to create. Map key is a stable identifier (used in for_each). + Each entry carries its own NRN, dimensions, Vault HTTP(S) endpoint, and the parameter + sensibility set this instance handles (secret / non_secret / both). + Each instance also gets its own agent API key + notification channel (anchored at the + instance NRN) unless notification_channel_enabled=false. Fields: + notification_channel_enabled — create the agent channel + its API key for this instance (default true). + tags_selectors — tag key/value pairs the agent uses to match this instance's channel + against scope tags (e.g. { environment = "development" }). + EOT + type = map(object({ + nrn = string + dimensions = map(string) + address = string + applies_to = list(string) + notification_channel_enabled = optional(bool, true) + tags_selectors = optional(map(string), {}) + })) + default = {} +} diff --git a/parameters/providers/hashicorp-vault/specs/versions.tf b/parameters/providers/hashicorp-vault/specs/versions.tf new file mode 100644 index 00000000..5a677bcb --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.95" + } + } +} diff --git a/parameters/providers/hashicorp-vault/store b/parameters/providers/hashicorp-vault/store new file mode 100755 index 00000000..0d8a9fe6 --- /dev/null +++ b/parameters/providers/hashicorp-vault/store @@ -0,0 +1,57 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter value in HashiCorp Vault KV v2. +# +# Versioning model: +# nullplatform parameter values are immutable. Each update is a new VERSION. +# Vault KV v2 has native versioning — every POST to the data endpoint creates +# a new version, all retained inside the same path, and the latest is returned +# on read by default. No special logic needed; the POST IS the versioning. +# +# Vault returns the new version number in the response body +# (.data.version). We include it in metadata so the platform can persist it +# and pass it back to retrieve when fetching a specific historical version. +# +# Required env: PARAMETER_ID, PARAMETER_VALUE, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +source "$PARAMETERS_ROOT/utils/build_external_id" +build_external_id + +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" + +PAYLOAD=$(jq -nc \ + --argjson parameter_id "${PARAMETER_ID:-null}" \ + --arg value "$PARAMETER_VALUE" \ + --arg stored_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + --arg external_id "$EXTERNAL_ID" \ + '{data: {parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id}}') + +T_CURL=$(timer_now) +if ! RESPONSE=$(curl -s -X POST \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_PATH" \ + -d "$PAYLOAD"); then + log error "❌ Failed to store parameter in Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Network unreachable to $VAULT_ADDR" + log error " • VAULT_TOKEN expired or lacks write permission on $VAULT_PATH" + log error " • KV mount at $VAULT_PATH_PREFIX does not exist" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + exit 1 +fi +log debug " ⏱ vault POST $VAULT_PATH $(timer_elapsed "$T_CURL")" + +VERSION_ID=$(echo "$RESPONSE" | jq -r '.data.version // empty') + +# Encode version into external_id (the canonical handle nullplatform persists). +[ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg vault_path "$VAULT_PATH" \ + '{external_id: $external_id, metadata: {vault_path: $vault_path}}' diff --git a/parameters/tests/entrypoint.bats b/parameters/tests/entrypoint.bats new file mode 100644 index 00000000..fbf3010e --- /dev/null +++ b/parameters/tests/entrypoint.bats @@ -0,0 +1,153 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/entrypoint — the action router. +# Action comes from $CONTEXT.action (i.e. NP_ACTION_CONTEXT.notification.action), +# NOT from a separate env var. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/entrypoint" + export SERVICE_PATH="$BATS_TEST_TMPDIR/service" + + # Stage a fake parameters/workflows/ with the expected action workflows. + mkdir -p "$SERVICE_PATH/workflows" + for action in store retrieve delete notify; do + : > "$SERVICE_PATH/workflows/$action.yaml" + done + + # np mock — echo args so we can assert what entrypoint invoked. + cat > "$BATS_TEST_TMPDIR/np" << 'EOF' +#!/bin/bash +echo "$@" +EOF + chmod +x "$BATS_TEST_TMPDIR/np" + export PATH="$BATS_TEST_TMPDIR:$PATH" +} + +teardown() { + unset NP_ACTION_CONTEXT OVERRIDES_PATH SERVICE_PATH +} + +@test "entrypoint: fails when NP_ACTION_CONTEXT is empty" { + unset NP_ACTION_CONTEXT + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ NP_ACTION_CONTEXT is not set" +} + +@test "entrypoint: fails when CONTEXT.action has no action part" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter","secret":true}}' + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ CONTEXT.action is missing the action part" +} + +@test "entrypoint: fails when CONTEXT.action is absent" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ CONTEXT.action is missing the action part" +} + +@test "entrypoint: store action routes to store.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":true}}' + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "store.yaml" +} + +@test "entrypoint: retrieve action routes to retrieve.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:retrieve","secret":true,"external_id":"abc"}}' + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "retrieve.yaml" +} + +@test "entrypoint: delete action routes to delete.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:delete","secret":false,"external_id":"abc"}}' + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "delete.yaml" +} + +@test "entrypoint: notify action routes to notify.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:notify","secret":true,"external_id":"abc"}}' + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "notify.yaml" +} + +@test "entrypoint: payload's .secret value does not affect routing" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":true}}' + run bash "$SCRIPT" + assert_equal "$status" "0" + output_true="$output" + + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":false}}' + run bash "$SCRIPT" + assert_equal "$status" "0" + + assert_equal "$output" "$output_true" +} + +@test "entrypoint: fails when no matching workflow exists" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:nonexistent","secret":true}}' + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ No workflow found at" +} + +@test "entrypoint: strips surrounding single quotes from NP_ACTION_CONTEXT" { + export NP_ACTION_CONTEXT="'{\"notification\":{\"action\":\"parameter:store\",\"secret\":true}}'" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "store.yaml" +} + +@test "entrypoint: OVERRIDES_PATH appends --overrides for matching path" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":true}}' + + mkdir -p "$BATS_TEST_TMPDIR/override1/parameters/workflows" + : > "$BATS_TEST_TMPDIR/override1/parameters/workflows/store.yaml" + export OVERRIDES_PATH="$BATS_TEST_TMPDIR/override1" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "--overrides $BATS_TEST_TMPDIR/override1/parameters/workflows/store.yaml" +} + +@test "entrypoint: OVERRIDES_PATH skips paths without the workflow file" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":true}}' + + mkdir -p "$BATS_TEST_TMPDIR/empty_override" + export OVERRIDES_PATH="$BATS_TEST_TMPDIR/empty_override" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + [[ "$output" != *"--overrides $BATS_TEST_TMPDIR/empty_override"* ]] +} diff --git a/parameters/tests/providers/aws-parameter-store/delete.bats b/parameters/tests/providers/aws-parameter-store/delete.bats new file mode 100644 index 00000000..4af340c3 --- /dev/null +++ b/parameters/tests/providers/aws-parameter-store/delete.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/aws-parameter-store/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/aws-parameter-store/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) ;; + not_found) + echo "An error occurred (ParameterNotFound) when calling the DeleteParameter operation: Parameter not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the DeleteParameter operation." >&2 + exit 254 + ;; + *) + echo "An error occurred (InternalServerError)." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/" + export EXTERNAL_ID="abc-123" + + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "aws-parameter-store delete: success → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "aws-parameter-store delete: ParameterNotFound is idempotent → success" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "aws-parameter-store delete: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete parameter" + assert_contains "$output" "lacks ssm:DeleteParameter" +} + +@test "aws-parameter-store delete: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete parameter" +} + +@test "aws-parameter-store delete: calls aws ssm delete-parameter with name" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "ssm delete-parameter" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name /nullplatform/abc-123" +} diff --git a/parameters/tests/providers/aws-parameter-store/retrieve.bats b/parameters/tests/providers/aws-parameter-store/retrieve.bats new file mode 100644 index 00000000..2b1ed1f4 --- /dev/null +++ b/parameters/tests/providers/aws-parameter-store/retrieve.bats @@ -0,0 +1,90 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/aws-parameter-store/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/aws-parameter-store/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) + echo "the-real-value" + ;; + not_found) + echo "An error occurred (ParameterNotFound) when calling the GetParameter operation." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the GetParameter operation." >&2 + exit 254 + ;; + *) + echo "An error occurred (InternalServerError)." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/" + export EXTERNAL_ID="abc-123" + + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" + export CONTEXT='{}' + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "aws-parameter-store retrieve: success → returns value" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-value" +} + +@test "aws-parameter-store retrieve: ParameterNotFound fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "not found in AWS Parameter Store" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "aws-parameter-store retrieve: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve parameter" + assert_contains "$output" "lacks ssm:GetParameter" +} + +@test "aws-parameter-store retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve parameter" +} + +@test "aws-parameter-store retrieve: calls aws with --with-decryption" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "ssm get-parameter" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name /nullplatform/abc-123" + assert_contains "$captured" "--with-decryption" +} diff --git a/parameters/tests/providers/aws-parameter-store/setup.bats b/parameters/tests/providers/aws-parameter-store/setup.bats new file mode 100644 index 00000000..4059bc71 --- /dev/null +++ b/parameters/tests/providers/aws-parameter-store/setup.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/aws-parameter-store/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export PARAMETERS_ROOT="$PARAMETERS_DIR" + export SCRIPT="$PARAMETERS_DIR/providers/aws-parameter-store/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AWS_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER PROVIDER_CONFIG +} + +@test "aws-parameter-store setup: fails fast when AWS_REGION is missing" { + unset AWS_REGION + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "AWS_REGION" +} + +@test "aws-parameter-store setup: name_prefix is hardcoded to '/nullplatform/'" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"name_prefix":"/custom/"}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=/nullplatform/" +} + +@test "aws-parameter-store setup: default tier is Standard" { + export AWS_REGION="us-east-1" + + run bash -c "$DEPS; source $SCRIPT && echo TIER=\$PS_TIER" + + assert_equal "$status" "0" + assert_contains "$output" "TIER=Standard" +} + +@test "aws-parameter-store setup: accepts Advanced tier from PROVIDER_CONFIG" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"setup":{"tier":"Advanced"}}' + + run bash -c "$DEPS; source $SCRIPT && echo TIER=\$PS_TIER" + + assert_equal "$status" "0" + assert_contains "$output" "TIER=Advanced" +} + +@test "aws-parameter-store setup: rejects invalid tier" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"setup":{"tier":"Bogus"}}' + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Invalid PS_TIER 'Bogus'" + assert_contains "$output" "Standard, Advanced, Intelligent-Tiering" +} + +@test "aws-parameter-store setup: kms_key_id from PROVIDER_CONFIG" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"setup":{"kms_key_id":"alias/cfg"}}' + + run bash -c "$DEPS; source $SCRIPT && echo KMS=\$PS_KMS_KEY_ID" + + assert_equal "$status" "0" + assert_contains "$output" "KMS=alias/cfg" +} diff --git a/parameters/tests/providers/aws-parameter-store/store.bats b/parameters/tests/providers/aws-parameter-store/store.bats new file mode 100644 index 00000000..9b28d318 --- /dev/null +++ b/parameters/tests/providers/aws-parameter-store/store.bats @@ -0,0 +1,134 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/aws-parameter-store/store +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_ROOT="$PARAMETERS_DIR" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/aws-parameter-store/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + + # Pre-populate the np cache that utils/prefetch_np would normally produce. + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + echo '{"slug":"acme"}' > "$NP_CACHE_DIR/organization.json" + echo '{"slug":"prod"}' > "$NP_CACHE_DIR/account.json" + echo '{"slug":"billing"}' > "$NP_CACHE_DIR/namespace.json" + echo '{"slug":"api"}' > "$NP_CACHE_DIR/application.json" + + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$AWS_LOG" +if [ "\${MOCK_AWS_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AWS_EXIT; fi +# put-parameter --output json returns { "Version": N, "Tier": "..." } +if [[ "\$*" == *"put-parameter"* ]]; then + echo '{"Version":7,"Tier":"Standard"}' +fi +exit 0 +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/" + export PS_KMS_KEY_ID="" + export PS_TIER="Standard" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-value" + export CONTEXT='{ + "parameter_id": 42, + "value": "my-value", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": {} + }' + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "aws-parameter-store store: external_id composed from entities + parameter_id + version" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + # Mock returns .Version=7 + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#7" + assert_equal "$external_id" "$expected" +} + +@test "aws-parameter-store store: kind=secret uses SecureString" { + export PARAMETER_KIND="secret" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + type=$(echo "$output" | jq -r '.metadata.type') + assert_equal "$type" "SecureString" + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--type SecureString" +} + +@test "aws-parameter-store store: kind=parameter uses String" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--type String" + [[ "$captured" != *"SecureString"* ]] +} + +@test "aws-parameter-store store: includes --key-id for SecureString when PS_KMS_KEY_ID set" { + export PARAMETER_KIND="secret" + export PS_KMS_KEY_ID="alias/parameters-secure" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--key-id alias/parameters-secure" +} + +@test "aws-parameter-store store: parameter_name has PS_NAME_PREFIX + composite (= sanitized to _)" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + param_name=$(echo "$output" | jq -r '.metadata.parameter_name') + # SSM doesn't allow `=` in path components, so we replace it with `_`. + assert_contains "$param_name" "/nullplatform/organization_acme-1255165411" + # No `=` should remain anywhere in the parameter_name. + case "$param_name" in *"="*) return 1 ;; esac +} + +@test "aws-parameter-store store: passes tier flag" { + export PARAMETER_KIND="parameter" + export PS_TIER="Advanced" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--tier Advanced" +} + +@test "aws-parameter-store store: fails with troubleshooting on aws error" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in AWS Parameter Store" +} diff --git a/parameters/tests/providers/aws-secrets-manager/delete.bats b/parameters/tests/providers/aws-secrets-manager/delete.bats new file mode 100644 index 00000000..e1916561 --- /dev/null +++ b/parameters/tests/providers/aws-secrets-manager/delete.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/aws-secrets-manager/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/aws-secrets-manager/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) ;; + not_found) + echo "An error occurred (ResourceNotFoundException) when calling the DeleteSecret operation: Secret not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the DeleteSecret operation: User not authorized." >&2 + exit 254 + ;; + *) + echo "An error occurred (UnknownError) when calling." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="nullplatform/" + export EXTERNAL_ID="abc-123" + + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "aws-secrets-manager delete: success → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "aws-secrets-manager delete: ResourceNotFoundException is idempotent → success" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "aws-secrets-manager delete: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "lacks secretsmanager:DeleteSecret" + assert_contains "$output" "AccessDeniedException" +} + +@test "aws-secrets-manager delete: unknown errors fail with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "🔧 How to fix:" +} + +@test "aws-secrets-manager delete: calls aws with force-delete flag" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager delete-secret" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--secret-id nullplatform/abc-123" + assert_contains "$captured" "--force-delete-without-recovery" +} diff --git a/parameters/tests/providers/aws-secrets-manager/retrieve.bats b/parameters/tests/providers/aws-secrets-manager/retrieve.bats new file mode 100644 index 00000000..058f9532 --- /dev/null +++ b/parameters/tests/providers/aws-secrets-manager/retrieve.bats @@ -0,0 +1,90 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/aws-secrets-manager/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/aws-secrets-manager/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) + echo '{"parameter_id":42,"value":"the-real-value","stored_at":"2026-01-01T00:00:00Z","external_id":"abc-123"}' + ;; + not_found) + echo "An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secret not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the GetSecretValue operation: User not authorized." >&2 + exit 254 + ;; + *) + echo "An error occurred (UnknownError) when calling." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="nullplatform/" + export EXTERNAL_ID="abc-123" + + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" + export CONTEXT='{}' + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "aws-secrets-manager retrieve: success → extracts .value from envelope" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-value" +} + +@test "aws-secrets-manager retrieve: ResourceNotFoundException fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Secret" + assert_contains "$output" "not found in AWS Secrets Manager" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "aws-secrets-manager retrieve: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" + assert_contains "$output" "lacks secretsmanager:GetSecretValue" +} + +@test "aws-secrets-manager retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" +} + +@test "aws-secrets-manager retrieve: calls aws with correct args" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager get-secret-value" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--secret-id nullplatform/abc-123" +} diff --git a/parameters/tests/providers/aws-secrets-manager/setup.bats b/parameters/tests/providers/aws-secrets-manager/setup.bats new file mode 100644 index 00000000..d6ae9cfd --- /dev/null +++ b/parameters/tests/providers/aws-secrets-manager/setup.bats @@ -0,0 +1,67 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/aws-secrets-manager/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export PARAMETERS_ROOT="$PARAMETERS_DIR" + export SCRIPT="$PARAMETERS_DIR/providers/aws-secrets-manager/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AWS_REGION SM_NAME_PREFIX SM_KMS_KEY_ID PROVIDER_CONFIG +} + +@test "aws-secrets-manager setup: fails fast when AWS_REGION is missing" { + unset AWS_REGION + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "AWS_REGION" +} + +@test "aws-secrets-manager setup: name_prefix is hardcoded to 'nullplatform/'" { + export AWS_REGION="us-east-1" + # Even if PROVIDER_CONFIG tries to set name_prefix, it's ignored (hardcoded invariant) + export PROVIDER_CONFIG='{"name_prefix":"custom/"}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$SM_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=nullplatform/" +} + +@test "aws-secrets-manager setup: AWS_REGION is taken from env (runtime-injected)" { + export AWS_REGION="eu-west-1" + + run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION" + + assert_equal "$status" "0" + assert_contains "$output" "REGION=eu-west-1" +} + +@test "aws-secrets-manager setup: kms_key_id from PROVIDER_CONFIG" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"setup":{"kms_key_id":"alias/mykey"}}' + + run bash -c "$DEPS; source $SCRIPT && echo KMS=\$SM_KMS_KEY_ID" + + assert_equal "$status" "0" + assert_contains "$output" "KMS=alias/mykey" +} + +@test "aws-secrets-manager setup: kms_key_id is empty by default" { + export AWS_REGION="us-east-1" + + run bash -c "$DEPS; source $SCRIPT && echo KMS=[\$SM_KMS_KEY_ID]" + + assert_equal "$status" "0" + assert_contains "$output" "KMS=[]" +} diff --git a/parameters/tests/providers/aws-secrets-manager/store.bats b/parameters/tests/providers/aws-secrets-manager/store.bats new file mode 100644 index 00000000..0fca6051 --- /dev/null +++ b/parameters/tests/providers/aws-secrets-manager/store.bats @@ -0,0 +1,216 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/aws-secrets-manager/store +# +# Verifies: +# - external_id composed from entities + dimensions + parameter_name-id +# - Path prefix is `nullplatform/` +# - First store uses CreateSecret +# - Subsequent stores (ResourceExistsException) fall through to PutSecretValue +# - Payload envelope shape (parameter_id, value, stored_at, external_id) +# - Real errors propagate with troubleshooting +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_ROOT="$PARAMETERS_DIR" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/aws-secrets-manager/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + + # Pre-populate the np cache that utils/prefetch_np would normally produce. + # The store path uses utils/build_external_id which reads .json from + # this cache instead of calling np. + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + echo '{"slug":"acme"}' > "$NP_CACHE_DIR/organization.json" + echo '{"slug":"prod"}' > "$NP_CACHE_DIR/account.json" + echo '{"slug":"billing"}' > "$NP_CACHE_DIR/namespace.json" + echo '{"slug":"api"}' > "$NP_CACHE_DIR/application.json" + echo '{"slug":"staging"}' > "$NP_CACHE_DIR/scope.json" + + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +mode="${MOCK_AWS_MODE:-success}" + +if [[ "$*" == *"create-secret"* ]]; then + case "$mode" in + success) + echo '{"ARN":"arn:aws:secretsmanager:us-east-1:111122223333:secret:nullplatform/test-AbCdEf","VersionId":"v1-create-uuid","Name":"nullplatform/test"}' + exit 0 + ;; + exists|put_error) + echo "An error occurred (ResourceExistsException) when calling the CreateSecret operation: resource already exists." >&2 + exit 254 + ;; + create_error) + echo "An error occurred (AccessDeniedException) when calling the CreateSecret operation: not authorized." >&2 + exit 254 + ;; + esac +elif [[ "$*" == *"put-secret-value"* ]]; then + case "$mode" in + exists) + echo '{"ARN":"arn:aws:secretsmanager:us-east-1:111122223333:secret:nullplatform/test-AbCdEf","VersionId":"v2-put-uuid"}' + exit 0 + ;; + put_error) + echo "An error occurred (AccessDeniedException) when calling the PutSecretValue operation: not authorized." >&2 + exit 254 + ;; + esac +fi +exit 1 +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="nullplatform/" + export SM_KMS_KEY_ID="" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-secret" + export CONTEXT='{ + "parameter_id": 42, + "parameter_name": "DB_PASSWORD", + "value": "my-secret", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": {} + }' + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "aws-secrets-manager store: external_id includes entities + parameter_name-id + version" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/DB_PASSWORD-42#v1-create-uuid" + assert_equal "$external_id" "$expected" +} + +@test "aws-secrets-manager store: scope-level value uses value_entities and includes scope segment" { + # Scope-level payload: value_entities carries the scope id; .entities is the + # app-level shape and must be IGNORED when value_entities is present. + export CONTEXT='{ + "parameter_id": 42, + "parameter_name": "DB_PASSWORD", + "value": "my-secret", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "value_entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625", + "scope": "601620319" + } + }' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/scope=staging-601620319/DB_PASSWORD-42#v1-create-uuid" + assert_equal "$external_id" "$expected" +} + +@test "aws-secrets-manager store: secret_name uses nullplatform/ prefix" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + secret_name=$(echo "$output" | jq -r '.metadata.secret_name') + assert_contains "$secret_name" "nullplatform/organization=acme-1255165411" + assert_contains "$secret_name" "DB_PASSWORD-42" +} + +@test "aws-secrets-manager store: first store uses CreateSecret (no put-secret-value)" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager create-secret" + [[ "$captured" != *"put-secret-value"* ]] + # No tagging API call/flag + [[ "$captured" != *"--tags"* ]] +} + +@test "aws-secrets-manager store: version_id is encoded as #suffix in external_id" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + version="${external_id##*#}" + assert_equal "$version" "v1-create-uuid" +} + +@test "aws-secrets-manager store: PutSecretValue's version_id replaces create's in external_id" { + run bash -c "$DEPS; MOCK_AWS_MODE=exists source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + version="${external_id##*#}" + assert_equal "$version" "v2-put-uuid" +} + +@test "aws-secrets-manager store: ResourceExistsException falls through to PutSecretValue" { + run bash -c "$DEPS; MOCK_AWS_MODE=exists source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager create-secret" + assert_contains "$captured" "secretsmanager put-secret-value" + assert_contains "$captured" "--secret-id nullplatform/organization=acme-1255165411" +} + +@test "aws-secrets-manager store: PutSecretValue failure propagates with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=put_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to add new version" + assert_contains "$output" "secretsmanager:PutSecretValue" + assert_contains "$output" "🔧 How to fix:" +} + +@test "aws-secrets-manager store: non-exists create errors propagate with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=create_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in AWS Secrets Manager" + assert_contains "$output" "secretsmanager:CreateSecret" +} + +@test "aws-secrets-manager store: includes --kms-key-id when SM_KMS_KEY_ID set" { + export SM_KMS_KEY_ID="alias/my-key" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--kms-key-id alias/my-key" +} + +@test "aws-secrets-manager store: dimensions sort alphabetically in external_id" { + export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + assert_contains "$external_id" "country=arg/environment=prod/DB_PASSWORD-42" +} diff --git a/parameters/tests/providers/azure-key-vault/delete.bats b/parameters/tests/providers/azure-key-vault/delete.bats new file mode 100644 index 00000000..f079ce32 --- /dev/null +++ b/parameters/tests/providers/azure-key-vault/delete.bats @@ -0,0 +1,130 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure-key-vault/delete +# Two-step: soft-delete + purge. Purge failures are warnings, not errors. +# ============================================================================= + +bats_require_minimum_version 1.5.0 + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure-key-vault/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + # The mock checks args to determine if this is `delete` or `purge`, and + # picks MOCK_DELETE_MODE / MOCK_PURGE_MODE accordingly. + cat > "$BATS_TEST_TMPDIR/bin/az" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AZ_LOG" + +# Identify which sub-command was called +sub_action="" +for arg in "$@"; do + case "$arg" in + delete) sub_action="delete" ;; + purge) sub_action="purge" ;; + esac +done + +if [ "$sub_action" = "delete" ]; then mode="${MOCK_DELETE_MODE:-success}" +elif [ "$sub_action" = "purge" ]; then mode="${MOCK_PURGE_MODE:-success}" +else mode="success"; fi + +case "$mode" in + success) ;; + not_found) + echo "(SecretNotFound) A secret with (name/id) X was not found in this key vault." >&2 + exit 3 + ;; + auth_error) + echo "(Forbidden) The user is not authorized to perform this action." >&2 + exit 1 + ;; + purge_forbidden) + echo "(Forbidden) Purge permission missing." >&2 + exit 1 + ;; + *) + echo "(InternalServerError) something went wrong." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export EXTERNAL_ID="abc-123" + + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure-key-vault delete: both delete + purge succeed → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "azure-key-vault delete: SecretNotFound on delete is idempotent → success" { + run bash -c "$DEPS; MOCK_DELETE_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "azure-key-vault delete: delete auth_error fails with troubleshooting" { + run bash -c "$DEPS; MOCK_DELETE_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "lacks 'Delete' permission" +} + +@test "azure-key-vault delete: purge forbidden is downgraded to warning, still returns success" { + run --separate-stderr bash -c "$DEPS; MOCK_PURGE_MODE=purge_forbidden source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" + assert_contains "$stderr" "⚠️" + assert_contains "$stderr" "Purge permission missing" +} + +@test "azure-key-vault delete: purge other failure is warning, still success" { + run --separate-stderr bash -c "$DEPS; MOCK_PURGE_MODE=other source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" + assert_contains "$stderr" "⚠️ Purge failed" +} + +@test "azure-key-vault delete: calls both delete and purge sub-commands" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret delete" + assert_contains "$captured" "keyvault secret purge" + assert_contains "$captured" "--name parameters-abc-123" +} + +@test "azure-key-vault delete: skips purge if delete returned not_found" { + run bash -c "$DEPS; MOCK_DELETE_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret delete" + # Purge should NOT have been called since delete already said "not found" + [[ "$captured" != *"keyvault secret purge"* ]] +} diff --git a/parameters/tests/providers/azure-key-vault/retrieve.bats b/parameters/tests/providers/azure-key-vault/retrieve.bats new file mode 100644 index 00000000..3c77fce1 --- /dev/null +++ b/parameters/tests/providers/azure-key-vault/retrieve.bats @@ -0,0 +1,90 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure-key-vault/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure-key-vault/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + cat > "$BATS_TEST_TMPDIR/bin/az" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AZ_LOG" +case "${MOCK_AZ_MODE:-success}" in + success) + echo "the-stored-value" + ;; + not_found) + echo "(SecretNotFound) A secret with (name/id) X was not found in this key vault." >&2 + exit 3 + ;; + auth_error) + echo "(Forbidden) The user is not authorized to perform this action." >&2 + exit 1 + ;; + *) + echo "(InternalServerError) something went wrong." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export EXTERNAL_ID="abc-123" + + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" + export CONTEXT='{}' + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure-key-vault retrieve: success → returns value" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-stored-value" +} + +@test "azure-key-vault retrieve: SecretNotFound fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AZ_MODE=not_found source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "not found in Azure Key Vault" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "azure-key-vault retrieve: auth_error fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AZ_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" + assert_contains "$output" "lacks 'Get' permission" +} + +@test "azure-key-vault retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AZ_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" +} + +@test "azure-key-vault retrieve: calls az keyvault secret show" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret show" + assert_contains "$captured" "--vault-name my-vault" + assert_contains "$captured" "--name parameters-abc-123" + assert_contains "$captured" "--query value" +} diff --git a/parameters/tests/providers/azure-key-vault/setup.bats b/parameters/tests/providers/azure-key-vault/setup.bats new file mode 100644 index 00000000..6daa02f8 --- /dev/null +++ b/parameters/tests/providers/azure-key-vault/setup.bats @@ -0,0 +1,56 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure-key-vault/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure-key-vault/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AZURE_KEY_VAULT_NAME AZ_VAULT_NAME AZ_SECRET_PREFIX PROVIDER_CONFIG +} + +@test "azure-key-vault setup: fails when vault name is missing" { + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Azure Key Vault name not configured" + assert_contains "$output" "🔧 How to fix:" +} + +@test "azure-key-vault setup: vault name from env" { + export AZURE_KEY_VAULT_NAME="my-vault" + + run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME PREFIX=\$AZ_SECRET_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "VAULT=my-vault" + assert_contains "$output" "PREFIX=nullplatform-" +} + +@test "azure-key-vault setup: secret_prefix is hardcoded to nullplatform-" { + export AZURE_KEY_VAULT_NAME="my-vault" + # PROVIDER_CONFIG tries to override; ignored + export PROVIDER_CONFIG='{"secret_prefix":"app-secret-"}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$AZ_SECRET_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=nullplatform-" +} + +@test "azure-key-vault setup: vault_name from PROVIDER_CONFIG" { + export PROVIDER_CONFIG='{"setup":{"vault_name":"cfg-vault"}}' + + run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME" + + assert_equal "$status" "0" + assert_contains "$output" "VAULT=cfg-vault" +} diff --git a/parameters/tests/providers/azure-key-vault/store.bats b/parameters/tests/providers/azure-key-vault/store.bats new file mode 100644 index 00000000..b136505a --- /dev/null +++ b/parameters/tests/providers/azure-key-vault/store.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure-key-vault/store +# AKV transforms / and = to - in the secret name (canonical form has slashes). +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_ROOT="$PARAMETERS_DIR" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure-key-vault/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + + # Pre-populate the np cache that utils/prefetch_np would normally produce. + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + echo '{"slug":"acme"}' > "$NP_CACHE_DIR/organization.json" + echo '{"slug":"prod"}' > "$NP_CACHE_DIR/account.json" + echo '{"slug":"billing"}' > "$NP_CACHE_DIR/namespace.json" + echo '{"slug":"api"}' > "$NP_CACHE_DIR/application.json" + + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + cat > "$BATS_TEST_TMPDIR/bin/az" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$AZ_LOG" +if [ "\${MOCK_AZ_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AZ_EXIT; fi +echo "https://my-vault.vault.azure.net/secrets/some-name/abc123" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export PARAMETER_VALUE="my-secret" + export CONTEXT='{ + "parameter_id": 42, + "value": "my-secret", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": {} + }' + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure-key-vault store: external_id is canonical slash form + version suffix" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + # Mock URL ends in /abc123 — that's the version + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#abc123" + assert_equal "$external_id" "$expected" +} + +@test "azure-key-vault store: secret_name uses dashes (AKV-safe)" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + secret_name=$(echo "$output" | jq -r '.metadata.secret_name') + # AKV: / and = both become - + assert_contains "$secret_name" "parameters-organization-acme-1255165411-account-prod-95118862" + assert_contains "$secret_name" "-42" + # Must not contain / or = + [[ "$secret_name" != *"/"* ]] + [[ "$secret_name" != *"="* ]] +} + +@test "azure-key-vault store: calls az with AKV-safe name" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret set" + assert_contains "$captured" "--vault-name my-vault" + assert_contains "$captured" "--name parameters-organization-acme-1255165411" + assert_contains "$captured" "--value my-secret" +} + +@test "azure-key-vault store: dimensions sorted alphabetically in external_id" { + export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') + + run bash -c "$DEPS; source $SCRIPT" + + external_id=$(echo "$output" | jq -r '.external_id') + assert_contains "$external_id" "country=arg/environment=prod/42" + + # AKV transformed name should have dashes + secret_name=$(echo "$output" | jq -r '.metadata.secret_name') + assert_contains "$secret_name" "country-arg-environment-prod-42" +} + +@test "azure-key-vault store: fails with troubleshooting on az error" { + run bash -c "$DEPS; MOCK_AZ_EXIT=1 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store secret in Azure Key Vault" +} diff --git a/parameters/tests/providers/hashicorp-vault/delete.bats b/parameters/tests/providers/hashicorp-vault/delete.bats new file mode 100644 index 00000000..5853b874 --- /dev/null +++ b/parameters/tests/providers/hashicorp-vault/delete.bats @@ -0,0 +1,105 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp-vault/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp-vault/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$CURL_LOG" +if [ "${MOCK_CURL_MODE:-success}" = "network_error" ]; then exit 6; fi +want_status=0 +for arg in "$@"; do + if [ "$arg" = "-w" ]; then want_status=1; break; fi +done +if [ -n "${MOCK_HTTP_BODY:-}" ]; then printf "%s" "$MOCK_HTTP_BODY"; fi +if [ "$want_status" = "1" ]; then printf "\n%s" "${MOCK_HTTP_STATUS:-204}"; fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/nullplatform" + export EXTERNAL_ID="abc-123" + + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault delete: 204 returns {success: true}" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 200 returns {success: true}" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 404 is idempotent — returns success" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=404 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 403 fails with auth troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=403 MOCK_HTTP_BODY='{\"errors\":[\"permission denied\"]}' source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault DELETE failed with HTTP 403" + assert_contains "$output" "lacks delete permission" + assert_contains "$output" "🔧 How to fix:" +} + +@test "vault delete: 500 fails with server troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=500 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault DELETE failed with HTTP 500" + assert_contains "$output" "Server-side error" +} + +@test "vault delete: network error fails with connectivity troubleshooting" { + run bash -c "$DEPS; MOCK_CURL_MODE=network_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Network error calling Vault" + assert_contains "$output" "unreachable" +} + +@test "vault delete: DELETEs the correct Vault URL with token header" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-X DELETE" + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/nullplatform/abc-123" +} + +@test "vault delete: honors custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/abc-123" +} diff --git a/parameters/tests/providers/hashicorp-vault/retrieve.bats b/parameters/tests/providers/hashicorp-vault/retrieve.bats new file mode 100644 index 00000000..d6a28a34 --- /dev/null +++ b/parameters/tests/providers/hashicorp-vault/retrieve.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp-vault/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp-vault/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$CURL_LOG" +if [ "${MOCK_CURL_MODE:-success}" = "network_error" ]; then exit 6; fi +want_status=0 +for arg in "$@"; do + if [ "$arg" = "-w" ]; then want_status=1; break; fi +done +if [ -n "${MOCK_HTTP_BODY:-}" ]; then printf "%s" "$MOCK_HTTP_BODY"; fi +if [ "$want_status" = "1" ]; then printf "\n%s" "${MOCK_HTTP_STATUS:-200}"; fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/nullplatform" + export EXTERNAL_ID="abc-123" + + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" + export CONTEXT='{}' + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault retrieve: 200 returns stored value" { + body='{"data":{"data":{"value":"the-real-secret","parameter_id":42}}}' + + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-secret" +} + +@test "vault retrieve: 404 fails with troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=404 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "not found in Vault" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "vault retrieve: 403 fails with auth troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=403 MOCK_HTTP_BODY='{\"errors\":[\"permission denied\"]}' source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault GET failed with HTTP 403" + assert_contains "$output" "lacks read permission" +} + +@test "vault retrieve: 500 fails with server troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=500 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault GET failed with HTTP 500" +} + +@test "vault retrieve: network error fails with connectivity troubleshooting" { + run bash -c "$DEPS; MOCK_CURL_MODE=network_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Network error calling Vault" +} + +@test "vault retrieve: GETs the correct Vault URL with token header" { + body='{"data":{"data":{"value":"x"}}}' + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/nullplatform/abc-123" +} + +@test "vault retrieve: honors custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + body='{"data":{"data":{"value":"x"}}}' + + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/abc-123" +} diff --git a/parameters/tests/providers/hashicorp-vault/setup.bats b/parameters/tests/providers/hashicorp-vault/setup.bats new file mode 100644 index 00000000..879ae872 --- /dev/null +++ b/parameters/tests/providers/hashicorp-vault/setup.bats @@ -0,0 +1,70 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp-vault/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp-vault/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX PROVIDER_CONFIG +} + +@test "vault setup: fails when VAULT_ADDR is missing" { + unset VAULT_ADDR + export VAULT_TOKEN="hvs.xxx" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault address not configured" +} + +@test "vault setup: fails when VAULT_TOKEN is missing" { + export VAULT_ADDR="https://vault.example.com" + unset VAULT_TOKEN + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault token not configured" +} + +@test "vault setup: path_prefix is hardcoded to secret/data/nullplatform" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.xxx" + export PROVIDER_CONFIG='{"path_prefix":"kv/data/custom"}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=secret/data/nullplatform" +} + +@test "vault setup: address from PROVIDER_CONFIG" { + export VAULT_TOKEN="hvs.xxx" + export PROVIDER_CONFIG='{"setup":{"address":"https://cfg-vault.example.com"}}' + + run bash -c "$DEPS; source $SCRIPT && echo ADDR=\$VAULT_ADDR" + + assert_equal "$status" "0" + assert_contains "$output" "ADDR=https://cfg-vault.example.com" +} + +@test "vault setup: token must come from env (not PROVIDER_CONFIG)" { + export VAULT_ADDR="https://vault.example.com" + unset VAULT_TOKEN + export PROVIDER_CONFIG='{"token":"hvs.from-config"}' + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault token not configured" +} diff --git a/parameters/tests/providers/hashicorp-vault/store.bats b/parameters/tests/providers/hashicorp-vault/store.bats new file mode 100644 index 00000000..f71eab40 --- /dev/null +++ b/parameters/tests/providers/hashicorp-vault/store.bats @@ -0,0 +1,126 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp-vault/store +# external_id is now composed via parameters/utils/build_external_id. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_ROOT="$PARAMETERS_DIR" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp-vault/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + + # Pre-populate the np cache that utils/prefetch_np would normally produce. + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + echo '{"slug":"acme"}' > "$NP_CACHE_DIR/organization.json" + echo '{"slug":"prod"}' > "$NP_CACHE_DIR/account.json" + echo '{"slug":"billing"}' > "$NP_CACHE_DIR/namespace.json" + echo '{"slug":"api"}' > "$NP_CACHE_DIR/application.json" + echo '{"slug":"main"}' > "$NP_CACHE_DIR/scope.json" + + # Mock curl + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$CURL_LOG" +if [ "\${MOCK_CURL_EXIT:-0}" -ne 0 ]; then exit \$MOCK_CURL_EXIT; fi +# Vault KV v2 returns the new version number in response body +echo '{"data":{"created_time":"2026-06-23T00:00:00Z","version":3,"deletion_time":"","destroyed":false}}' +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/nullplatform" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-secret" + export CONTEXT='{ + "parameter_id": 42, + "value": "my-secret", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": {} + }' + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault store: external_id composed from entities + parameter_id + version" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + # Mock returns .data.version=3 + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#3" + assert_equal "$external_id" "$expected" +} + +@test "vault store: external_id includes sorted dimensions" { + export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + # Dimensions sorted alphabetically: country before environment + assert_contains "$external_id" "country=arg/environment=prod/42" +} + +@test "vault store: vault_path contains external_id" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + vault_path=$(echo "$output" | jq -r '.metadata.vault_path') + assert_contains "$vault_path" "secret/data/nullplatform/organization=acme-1255165411" + assert_contains "$vault_path" "/42" +} + +@test "vault store: POSTs to Vault URL with token" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-X POST" + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/nullplatform/organization=acme-1255165411" +} + +@test "vault store: POST body contains parameter_id, value, external_id, stored_at" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" '"parameter_id":42' + assert_contains "$captured" '"value":"my-secret"' + assert_contains "$captured" '"external_id":"organization=acme-1255165411' + assert_contains "$captured" '"stored_at":"' +} + +@test "vault store: fails with troubleshooting when curl returns non-zero" { + run bash -c "$DEPS; MOCK_CURL_EXIT=22 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in Vault" + assert_contains "$output" "💡 Possible causes:" +} + +@test "vault store: works without dimensions" { + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.dimensions)') + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#3" + assert_equal "$external_id" "$expected" +} diff --git a/parameters/tests/utils/assume_role.bats b/parameters/tests/utils/assume_role.bats new file mode 100644 index 00000000..f82ec943 --- /dev/null +++ b/parameters/tests/utils/assume_role.bats @@ -0,0 +1,408 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/assume_role — sourceable sts:AssumeRole step. +# Reads ASSUME_ROLE_ARN_RESOLVED (provider-agnostic) set by assume_role_step. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/assume_role" + export BIN_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$BIN_DIR" + + # Isolate the sts-creds cache per test: cache lives under + # $SERVICE_PATH/credentials/, so a fresh SERVICE_PATH per test = fresh cache. + export SERVICE_PATH="$BATS_TEST_TMPDIR/service" + mkdir -p "$SERVICE_PATH" + export STS_CACHE_DIR="$SERVICE_PATH/credentials" +} + +teardown() { + unset ASSUME_ROLE_ARN_RESOLVED ASSUME_ROLE_SESSION_PREFIX \ + SERVICE_PATH STS_CACHE_DIR NP_STS_CACHE_BUFFER_SECS \ + AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN +} + +# Helper: a far-future ISO 8601 timestamp (for the Credentials.Expiration field). +far_future_iso() { + echo "2099-12-31T23:59:59Z" +} + +# Helper: a far-future Unix epoch (well past any sane buffer window). +far_future_epoch() { + echo "4102444799" # 2099-12-31T23:59:59Z +} + +# Helper: a past Unix epoch (for cache-stale fixtures). +past_epoch() { + echo "1577836800" # 2020-01-01T00:00:00Z +} + +@test "assume_role: no-op when ASSUME_ROLE_ARN_RESOLVED is empty" { + unset ASSUME_ROLE_ARN_RESOLVED + + run bash -c " + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo AKID=[\${AWS_ACCESS_KEY_ID:-}] + " + + assert_equal "$status" "0" + assert_contains "$output" "AKID=[]" +} + +@test "assume_role: success exports temp credentials and logs ✅" { + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +[ "$1 $2" = "sts assume-role" ] || { echo "unexpected: $*" >&2; exit 1; } +cat << 'JSON' +{ + "Credentials": { + "AccessKeyId": "AKIA-TEST", + "SecretAccessKey": "sk-test", + "SessionToken": "token-test", + "Expiration": "2026-01-01T00:00:00Z" + } +} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/test" + export SCOPE_ID="scope-42" + export ASSUME_ROLE_SESSION_PREFIX="np-secret-manager" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo AKID=\$AWS_ACCESS_KEY_ID + echo SECRET=\$AWS_SECRET_ACCESS_KEY + echo TOKEN=\$AWS_SESSION_TOKEN + " + + assert_equal "$status" "0" + assert_contains "$output" "AKID=AKIA-TEST" + assert_contains "$output" "SECRET=sk-test" + assert_contains "$output" "TOKEN=token-test" + assert_contains "$output" "🔑 Assuming role: arn:aws:iam::1:role/test" + assert_contains "$output" "✅ Role assumed successfully" +} + +@test "assume_role: session name uses ASSUME_ROLE_SESSION_PREFIX + SCOPE_ID" { + export AWS_ARGS_LOG="$BATS_TEST_TMPDIR/aws-args" + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "$*" > "$AWS_ARGS_LOG" +cat << 'JSON' +{"Credentials":{"AccessKeyId":"a","SecretAccessKey":"s","SessionToken":"t","Expiration":"2026-01-01T00:00:00Z"}} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:role/x" + export SCOPE_ID="scp-99" + export ASSUME_ROLE_SESSION_PREFIX="np-parameter-store" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$AWS_ARGS_LOG" + assert_contains "$output" "--role-session-name np-parameter-store-scp-99" +} + +@test "assume_role: session name falls back to 'np-parameters' when prefix unset" { + export AWS_ARGS_LOG="$BATS_TEST_TMPDIR/aws-args" + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "$*" > "$AWS_ARGS_LOG" +cat << 'JSON' +{"Credentials":{"AccessKeyId":"a","SecretAccessKey":"s","SessionToken":"t","Expiration":"2026-01-01T00:00:00Z"}} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:role/x" + export SCOPE_ID="scp-1" + unset ASSUME_ROLE_SESSION_PREFIX + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$AWS_ARGS_LOG" + assert_contains "$output" "--role-session-name np-parameters-scp-1" +} + +@test "assume_role: returns 1 when aws sts assume-role fails" { + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "AccessDenied: not allowed" >&2 +exit 255 +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/test" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ sts:AssumeRole failed" + assert_contains "$output" "AccessDenied: not allowed" +} + +@test "assume_role: fails when aws returns incomplete credentials" { + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo '{"Credentials":{"AccessKeyId":"AKIA","SecretAccessKey":"","SessionToken":""}}' +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/test" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + [ "$status" -ne 0 ] + assert_contains "$output" "incomplete credentials" +} + +# ---- STS credentials cache ----------------------------------------------- + +@test "assume_role: cache hit — skips aws call entirely when cached creds are fresh" { + # aws mock that fails if invoked — proves we never called it. + export AWS_CALL_LOG="$BATS_TEST_TMPDIR/aws-calls.log" + : > "$AWS_CALL_LOG" + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "$*" >> "$AWS_CALL_LOG" +echo "should-not-be-called" >&2 +exit 1 +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/cached" + + # Pre-populate the cache with creds that expire far in the future. + # NOTE: freshness is decided from `_cache_exp_epoch`, not from .Expiration. + mkdir -p "$STS_CACHE_DIR" + CACHE_KEY=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | sha256sum 2>/dev/null | cut -c1-16) + [ -z "$CACHE_KEY" ] && CACHE_KEY=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | shasum -a 256 | cut -c1-16) + cat > "$STS_CACHE_DIR/$CACHE_KEY.json" << EOF +{ + "Credentials": { + "AccessKeyId": "AKIA-CACHED", + "SecretAccessKey": "sk-cached", + "SessionToken": "tok-cached", + "Expiration": "$(far_future_iso)" + }, + "_cache_exp_epoch": $(far_future_epoch) +} +EOF + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo AKID=\$AWS_ACCESS_KEY_ID + " + + assert_equal "$status" "0" + assert_contains "$output" "AKID=AKIA-CACHED" + # aws mock would have written to the log if called + run cat "$AWS_CALL_LOG" + [ -z "$output" ] +} + +@test "assume_role: cache stale — falls through to aws call and rewrites cache" { + export AWS_CALL_LOG="$BATS_TEST_TMPDIR/aws-calls.log" + : > "$AWS_CALL_LOG" + cat > "$BIN_DIR/aws" << EOF +#!/bin/bash +echo "\$*" >> "\$AWS_CALL_LOG" +cat << JSON +{ + "Credentials": { + "AccessKeyId": "AKIA-FRESH", + "SecretAccessKey": "sk-fresh", + "SessionToken": "tok-fresh", + "Expiration": "$(far_future_iso)" + } +} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/stale" + + mkdir -p "$STS_CACHE_DIR" + CACHE_KEY=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | sha256sum 2>/dev/null | cut -c1-16) + [ -z "$CACHE_KEY" ] && CACHE_KEY=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | shasum -a 256 | cut -c1-16) + # Past expiration — should trigger refresh. + cat > "$STS_CACHE_DIR/$CACHE_KEY.json" << EOF +{ + "Credentials": { + "AccessKeyId": "AKIA-STALE", + "SecretAccessKey": "sk-stale", + "SessionToken": "tok-stale", + "Expiration": "2020-01-01T00:00:00Z" + }, + "_cache_exp_epoch": $(past_epoch) +} +EOF + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo AKID=\$AWS_ACCESS_KEY_ID + " + + assert_equal "$status" "0" + assert_contains "$output" "AKID=AKIA-FRESH" + run cat "$AWS_CALL_LOG" + assert_contains "$output" "sts assume-role" + # New creds were written to the cache, with a precomputed epoch. + run cat "$STS_CACHE_DIR/$CACHE_KEY.json" + assert_contains "$output" "AKIA-FRESH" + assert_contains "$output" "_cache_exp_epoch" +} + +@test "assume_role: cache write stores precomputed _cache_exp_epoch" { + export AWS_CALL_LOG="$BATS_TEST_TMPDIR/aws-calls.log" + : > "$AWS_CALL_LOG" + cat > "$BIN_DIR/aws" << EOF +#!/bin/bash +echo "\$*" >> "\$AWS_CALL_LOG" +cat << JSON +{ + "Credentials": { + "AccessKeyId": "AKIA-NEW", + "SecretAccessKey": "sk", + "SessionToken": "tok", + "Expiration": "$(far_future_iso)" + } +} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/write-test" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + CACHE_KEY=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | sha256sum 2>/dev/null | cut -c1-16) + [ -z "$CACHE_KEY" ] && CACHE_KEY=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | shasum -a 256 | cut -c1-16) + + # _cache_exp_epoch must be a positive integer well in the future. + EPOCH=$(jq -r '._cache_exp_epoch' < "$STS_CACHE_DIR/$CACHE_KEY.json") + NOW=$(date -u +%s) + [ "$EPOCH" -gt "$NOW" ] +} + +@test "assume_role: unparseable AWS Expiration falls back to now+3540s (cache still works)" { + # AWS returns garbage in Expiration — date(1) will fail on it. The fallback + # `now + 3540` must kick in so the cache remains usable. + export AWS_CALL_LOG="$BATS_TEST_TMPDIR/aws-calls.log" + : > "$AWS_CALL_LOG" + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "$*" >> "$AWS_CALL_LOG" +cat << JSON +{ + "Credentials": { + "AccessKeyId": "AKIA-NEW", + "SecretAccessKey": "sk", + "SessionToken": "tok", + "Expiration": "this-is-not-a-date" + } +} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/garbage-exp" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + CACHE_KEY=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | sha256sum 2>/dev/null | cut -c1-16) + [ -z "$CACHE_KEY" ] && CACHE_KEY=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | shasum -a 256 | cut -c1-16) + + EPOCH=$(jq -r '._cache_exp_epoch' < "$STS_CACHE_DIR/$CACHE_KEY.json") + NOW=$(date -u +%s) + # Fallback should be ~now+3540 (1h - 60s buffer). Allow ±10s slack. + DIFF=$(( EPOCH - NOW )) + [ "$DIFF" -gt 3500 ] && [ "$DIFF" -lt 3600 ] +} + +@test "assume_role: different ARNs use different cache files" { + export AWS_CALL_LOG="$BATS_TEST_TMPDIR/aws-calls.log" + : > "$AWS_CALL_LOG" + cat > "$BIN_DIR/aws" << EOF +#!/bin/bash +echo "\$*" >> "\$AWS_CALL_LOG" +# Return creds reflecting which ARN was requested (parse --role-arn). +ARN="" +while [ \$# -gt 0 ]; do + if [ "\$1" = "--role-arn" ]; then ARN="\$2"; shift 2; else shift; fi +done +SUFFIX=\$(printf '%s' "\$ARN" | tr '/:' '__') +cat << JSON +{ + "Credentials": { + "AccessKeyId": "AKIA-\$SUFFIX", + "SecretAccessKey": "sk", + "SessionToken": "tok", + "Expiration": "$(far_future_iso)" + } +} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + # Two ARNs in the same cache dir. + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + ASSUME_ROLE_ARN_RESOLVED='arn:aws:iam::1:role/A' source $SCRIPT + unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN ASSUMED_CREDS + ASSUME_ROLE_ARN_RESOLVED='arn:aws:iam::1:role/B' source $SCRIPT + " + + assert_equal "$status" "0" + # Both ARNs hit aws (no cross-contamination). + run cat "$AWS_CALL_LOG" + assert_contains "$output" "arn:aws:iam::1:role/A" + assert_contains "$output" "arn:aws:iam::1:role/B" + # Both cache files exist. + run ls "$STS_CACHE_DIR" + [ "$(echo "$output" | wc -l | tr -d ' ')" -ge "2" ] +} diff --git a/parameters/tests/utils/assume_role_lib.bats b/parameters/tests/utils/assume_role_lib.bats new file mode 100644 index 00000000..091fc980 --- /dev/null +++ b/parameters/tests/utils/assume_role_lib.bats @@ -0,0 +1,146 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/assume_role_lib — pure helpers. +# Provider-agnostic: env var names are parameters, NOT hardcoded. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + source "$PARAMETERS_DIR/utils/assume_role_lib" +} + +teardown() { + unset \ + SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT \ + PARAMETER_STORE_ASSUME_ROLE_ARN PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT +} + +# ---- arn_for_selector ------------------------------------------------------- + +@test "arn_for_selector: returns matching ARN when selector exists" { + local json='{"iam_role_arns":{"arns":[ + {"selector":"containers","arn":"arn:aws:iam::1:role/containers"}, + {"selector":"secret_manager","arn":"arn:aws:iam::1:role/secret-mgr"} + ]}}' + + run arn_for_selector "$json" "secret_manager" + + assert_equal "$status" "0" + assert_equal "$output" "arn:aws:iam::1:role/secret-mgr" +} + +@test "arn_for_selector: works with a different selector (parameter_store)" { + local json='{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:sm"}, + {"selector":"parameter_store","arn":"arn:ps"} + ]}}' + + run arn_for_selector "$json" "parameter_store" + + assert_equal "$output" "arn:ps" +} + +@test "arn_for_selector: returns first match when selector appears twice" { + local json='{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:1"}, + {"selector":"secret_manager","arn":"arn:2"} + ]}}' + + run arn_for_selector "$json" "secret_manager" + + assert_equal "$output" "arn:1" +} + +@test "arn_for_selector: returns empty when selector not found" { + local json='{"iam_role_arns":{"arns":[{"selector":"containers","arn":"a"}]}}' + + run arn_for_selector "$json" "secret_manager" + + assert_equal "$status" "0" + assert_equal "$output" "" +} + +@test "arn_for_selector: empty/malformed input returns empty (no crash)" { + run arn_for_selector "" "secret_manager" + assert_equal "$output" "" + + run arn_for_selector "{}" "secret_manager" + assert_equal "$output" "" + + run arn_for_selector "not-json-at-all" "secret_manager" + assert_equal "$output" "" +} + +# ---- resolve_assume_role_arn ----------------------------------------------- +# Now takes 4 args: iam_json, selector, override_env_name, default_env_name. + +@test "resolve_assume_role_arn: override env (by name) wins over provider" { + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:from-env" + local json='{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:provider"}]}}' + + run resolve_assume_role_arn "$json" "secret_manager" \ + "SECRET_MANAGER_ASSUME_ROLE_ARN" "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" + + assert_equal "$output" "arn:from-env" +} + +@test "resolve_assume_role_arn: parameter_store uses ITS OWN env var (not secret_manager's)" { + export PARAMETER_STORE_ASSUME_ROLE_ARN="arn:ps-override" + # secret_manager's env is also set, but caller asked for parameter_store's + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:sm-IGNORED" + + run resolve_assume_role_arn "{}" "parameter_store" \ + "PARAMETER_STORE_ASSUME_ROLE_ARN" "PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT" + + assert_equal "$output" "arn:ps-override" +} + +@test "resolve_assume_role_arn: empty override falls through to provider selector" { + export SECRET_MANAGER_ASSUME_ROLE_ARN="" + local json='{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:provider"}]}}' + + run resolve_assume_role_arn "$json" "secret_manager" \ + "SECRET_MANAGER_ASSUME_ROLE_ARN" "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" + + assert_equal "$output" "arn:provider" +} + +@test "resolve_assume_role_arn: provider miss falls through to DEFAULT env" { + unset SECRET_MANAGER_ASSUME_ROLE_ARN + export SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT="arn:default" + local json='{"iam_role_arns":{"arns":[{"selector":"containers","arn":"a"}]}}' + + run resolve_assume_role_arn "$json" "secret_manager" \ + "SECRET_MANAGER_ASSUME_ROLE_ARN" "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" + + assert_equal "$output" "arn:default" +} + +@test "resolve_assume_role_arn: parameter_store DEFAULT env is independent of secret_manager's" { + export SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT="arn:sm-IGNORED" + export PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT="arn:ps-default" + + run resolve_assume_role_arn "{}" "parameter_store" \ + "PARAMETER_STORE_ASSUME_ROLE_ARN" "PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT" + + assert_equal "$output" "arn:ps-default" +} + +@test "resolve_assume_role_arn: no source set returns empty (use agent creds)" { + run resolve_assume_role_arn "{}" "secret_manager" \ + "SECRET_MANAGER_ASSUME_ROLE_ARN" "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" + + assert_equal "$output" "" +} + +@test "resolve_assume_role_arn: empty override-env-name arg is treated as 'no override'" { + # Caller passes "" for the override env name → step 1 of precedence is skipped + local json='{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:p"}]}}' + + run resolve_assume_role_arn "$json" "secret_manager" "" "" + + assert_equal "$output" "arn:p" +} diff --git a/parameters/tests/utils/assume_role_step.bats b/parameters/tests/utils/assume_role_step.bats new file mode 100644 index 00000000..b8d5c1b0 --- /dev/null +++ b/parameters/tests/utils/assume_role_step.bats @@ -0,0 +1,207 @@ +#!/usr/bin/env bats +bats_require_minimum_version 1.5.0 +# ============================================================================= +# Unit tests for parameters/utils/assume_role_step. +# +# After the prefetch refactor, assume_role_step does NOT call np itself. +# It reads $NP_CACHE_DIR/iam.json (pre-populated by utils/prefetch_np) and +# resolves the ARN via assume_role_lib, then sources assume_role to call +# sts:AssumeRole. NRN/dimensions/np-list assertions live in prefetch_np.bats. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/assume_role_step" + export BIN_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$BIN_DIR" + + # aws mock — records sts args, returns valid creds. + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "$@" >> "$AWS_INVOKED_LOG" +cat << 'JSON' +{"Credentials":{"AccessKeyId":"AKIA","SecretAccessKey":"sk","SessionToken":"t","Expiration":"2026-01-01T00:00:00Z"}} +JSON +EOF + chmod +x "$BIN_DIR/aws" + export AWS_INVOKED_LOG="$BATS_TEST_TMPDIR/aws-calls.log" + : > "$AWS_INVOKED_LOG" + + # Pre-populate NP_CACHE_DIR so prefetch_np is bypassed (and so is any np call). + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + + # assume_role uses $SERVICE_PATH/credentials/ for the sts cache. + export SERVICE_PATH="$BATS_TEST_TMPDIR/service" + mkdir -p "$SERVICE_PATH" +} + +teardown() { + unset CONTEXT SCOPE_ID NP_CACHE_DIR SERVICE_PATH \ + ASSUME_ROLE_SELECTOR ASSUME_ROLE_OVERRIDE_ENV ASSUME_ROLE_DEFAULT_ENV \ + ASSUME_ROLE_SESSION_PREFIX ASSUME_ROLE_ARN_RESOLVED \ + SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT \ + PARAMETER_STORE_ASSUME_ROLE_ARN PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT \ + AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN +} + +write_iam_cache() { + local arns_json="$1" + jq -n --argjson arns "$arns_json" \ + '{results:[{id:"prov-1", attributes:{iam_role_arns:{arns:$arns}}}]}' \ + > "$NP_CACHE_DIR/iam.json" +} + +SM_CALLER='ASSUME_ROLE_SELECTOR=secret_manager; ASSUME_ROLE_OVERRIDE_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN; ASSUME_ROLE_DEFAULT_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT' +PS_CALLER='ASSUME_ROLE_SELECTOR=parameter_store; ASSUME_ROLE_OVERRIDE_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN; ASSUME_ROLE_DEFAULT_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT' + +# ---- Contract ------------------------------------------------------------- + +@test "step: fails fast when ASSUME_ROLE_SELECTOR is missing" { + export CONTEXT='{}' + + run -127 bash -c " + export PATH=$BIN_DIR:\$PATH + ASSUME_ROLE_OVERRIDE_ENV=X ASSUME_ROLE_DEFAULT_ENV=Y + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_contains "$output" "ASSUME_ROLE_SELECTOR must be set" +} + +@test "step: fails fast when ASSUME_ROLE_OVERRIDE_ENV is missing" { + export CONTEXT='{}' + + run -127 bash -c " + export PATH=$BIN_DIR:\$PATH + ASSUME_ROLE_SELECTOR=secret_manager ASSUME_ROLE_DEFAULT_ENV=Y + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_contains "$output" "ASSUME_ROLE_OVERRIDE_ENV must be set" +} + +@test "step: fails fast when ASSUME_ROLE_DEFAULT_ENV is missing" { + export CONTEXT='{}' + + run -127 bash -c " + export PATH=$BIN_DIR:\$PATH + ASSUME_ROLE_SELECTOR=secret_manager ASSUME_ROLE_OVERRIDE_ENV=X + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_contains "$output" "ASSUME_ROLE_DEFAULT_ENV must be set" +} + +# ---- ARN resolution from cache ------------------------------------------- + +@test "step: override env wins over cached IAM provider" { + export CONTEXT='{}' + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:from-env-sm" + write_iam_cache '[{"selector":"secret_manager","arn":"arn:from-cache"}]' + + run bash -c " + export PATH=$BIN_DIR:\$PATH + $SM_CALLER + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED + " + + assert_equal "$status" "0" + assert_contains "$output" "ARN=arn:from-env-sm" + run cat "$AWS_INVOKED_LOG" + assert_contains "$output" "--role-arn arn:from-env-sm" +} + +@test "step: parameter_store caller — uses ITS OWN env var (not secret_manager's)" { + export CONTEXT='{}' + export PARAMETER_STORE_ASSUME_ROLE_ARN="arn:ps-env" + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:sm-MUST-NOT-BE-USED" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + $PS_CALLER + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED + " + + assert_contains "$output" "ARN=arn:ps-env" +} + +@test "step: cached IAM provider — matching selector ARN is picked" { + export CONTEXT='{}' + write_iam_cache '[ + {"selector":"containers","arn":"arn:containers"}, + {"selector":"secret_manager","arn":"arn:from-cache"} + ]' + + run bash -c " + export PATH=$BIN_DIR:\$PATH + $SM_CALLER + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED + " + + assert_contains "$output" "ARN=arn:from-cache" +} + +@test "step: parameter_store selector picks parameter_store ARN from cache" { + export CONTEXT='{}' + write_iam_cache '[ + {"selector":"secret_manager","arn":"arn:sm"}, + {"selector":"parameter_store","arn":"arn:ps"} + ]' + + run bash -c " + export PATH=$BIN_DIR:\$PATH + $PS_CALLER + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED + " + + assert_contains "$output" "ARN=arn:ps" +} + +@test "step: no iam.json in cache → empty ARN, no aws call (agent creds)" { + export CONTEXT='{}' # no iam.json created + + run bash -c " + export PATH=$BIN_DIR:\$PATH + $SM_CALLER + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=[\$ASSUME_ROLE_ARN_RESOLVED] + " + + assert_equal "$status" "0" + assert_contains "$output" "ARN=[]" + run cat "$AWS_INVOKED_LOG" + [ -z "$output" ] +} + +@test "step: session prefix flows through to assume_role with SCOPE_ID" { + export CONTEXT='{"value_entities":{"organization":"o","account":"a","namespace":"n","application":"ap","scope":"601620319"}}' + write_iam_cache '[{"selector":"secret_manager","arn":"arn:x"}]' + + run bash -c " + export PATH=$BIN_DIR:\$PATH + $SM_CALLER + ASSUME_ROLE_SESSION_PREFIX=np-secret-manager + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + run cat "$AWS_INVOKED_LOG" + assert_contains "$output" "--role-session-name np-secret-manager-601620319" +} diff --git a/parameters/tests/utils/build_context.bats b/parameters/tests/utils/build_context.bats new file mode 100644 index 00000000..8b7b3de4 --- /dev/null +++ b/parameters/tests/utils/build_context.bats @@ -0,0 +1,200 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/build_context — provider resolution via spec_id +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/build_context" + export TEST_PROVIDER_DIR="$PARAMETERS_DIR/providers/test_provider" + + # Mock the np CLI + mkdir -p "$BATS_TEST_TMPDIR/bin" + export NP_LOG="$BATS_TEST_TMPDIR/np.log" + cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$NP_LOG" +if [ "$1" = "provider" ] && [ "$2" = "specification" ] && [ "$3" = "read" ]; then + case "${MOCK_NP_SPEC_MODE:-success}" in + success) + slug="${MOCK_NP_SPEC_SLUG:-test_provider}" + echo "{\"slug\":\"$slug\",\"id\":\"some-uuid\"}" + ;; + not_found) + echo "Error: Specification not found" >&2 + exit 1 + ;; + no_slug) + echo "{\"id\":\"some-uuid\"}" + ;; + esac +fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/np" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + # Default CONTEXT: platform ships specification_slug directly in the payload. + export CONTEXT='{ + "parameter_id": 42, + "value": "my-val", + "parameter_name": "DB_PASS", + "encoding": "plain", + "secret": true, + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "provider": { + "specification_id": "ec885dd0-7c38-45b8-af2c-0b9e1deb7d3d", + "specification_slug": "test_provider", + "attributes": { + "region": "us-east-1", + "name_prefix": "parameters/" + } + } + }' +} + +teardown() { + rm -rf "$TEST_PROVIDER_DIR" + unset MOCK_NP_SPEC_MODE MOCK_NP_SPEC_SLUG + unset PARAMETER_KIND ACTIVE_PROVIDER PROVIDER_DIR PARAMETERS_ROOT + unset EXTERNAL_ID PARAMETER_ID PARAMETER_VALUE PARAMETER_NAME PARAMETER_ENCODING + unset PROVIDER_CONFIG +} + +@test "build_context: extracts notification fields and exports them" { + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PID=\$PARAMETER_ID VAL=\$PARAMETER_VALUE NAME=\$PARAMETER_NAME ENC=\$PARAMETER_ENCODING" + + assert_equal "$status" "0" + assert_contains "$output" "PID=42" + assert_contains "$output" "VAL=my-val" + assert_contains "$output" "NAME=DB_PASS" + assert_contains "$output" "ENC=plain" +} + +@test "build_context: derives PARAMETER_KIND=secret when .secret is true" { + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND" + + assert_equal "$status" "0" + assert_contains "$output" "KIND=secret" +} + +@test "build_context: derives PARAMETER_KIND=parameter when .secret is false" { + export CONTEXT=$(echo "$CONTEXT" | jq '.secret = false') + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND" + + assert_equal "$status" "0" + assert_contains "$output" "KIND=parameter" +} + +@test "build_context: resolves ACTIVE_PROVIDER from spec_id via np CLI" { + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER" + + assert_equal "$status" "0" + assert_contains "$output" "PROV=test_provider" +} + +@test "build_context: does NOT call np provider specification read (slug comes from payload)" { + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT" + + captured=$(cat "$NP_LOG" 2>/dev/null || echo "") + case "$captured" in *"provider specification read"*) return 1 ;; esac +} + +@test "build_context: fails when specification_slug is missing" { + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.provider.specification_slug)') + + run bash -c "source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Missing .provider.specification_slug" + assert_contains "$output" "💡 Possible causes:" +} + +@test "build_context: fails when provider directory doesn't exist" { + export CONTEXT=$(echo "$CONTEXT" | jq '.provider.specification_slug = "nonexistent_provider"') + + run bash -c "source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Provider implementation not found for slug 'nonexistent_provider'" +} + +@test "build_context: PROVIDER_CONFIG comes from .provider.attributes" { + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo CONFIG=\$PROVIDER_CONFIG" + + assert_equal "$status" "0" + assert_contains "$output" '"region":"us-east-1"' + assert_contains "$output" '"name_prefix":"parameters/"' +} + +@test "build_context: PROVIDER_CONFIG defaults to {} when attributes is missing" { + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.provider.attributes)') + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo CONFIG=\$PROVIDER_CONFIG" + + assert_equal "$status" "0" + assert_contains "$output" "CONFIG={}" +} + +@test "build_context: sources provider setup when present" { + mkdir -p "$TEST_PROVIDER_DIR" + echo 'export SETUP_RAN="yes"' > "$TEST_PROVIDER_DIR/setup" + + run bash -c "source $SCRIPT && echo SETUP=\$SETUP_RAN" + + assert_equal "$status" "0" + assert_contains "$output" "SETUP=yes" +} + +@test "build_context: setup can read PROVIDER_CONFIG via get_config_value" { + mkdir -p "$TEST_PROVIDER_DIR" + cat > "$TEST_PROVIDER_DIR/setup" << 'EOF' +REGION=$(get_config_value --provider '.region') +export RESOLVED_REGION="$REGION" +EOF + + run bash -c "source $SCRIPT && echo REGION=\$RESOLVED_REGION" + + assert_equal "$status" "0" + assert_contains "$output" "REGION=us-east-1" +} + +@test "build_context: succeeds when provider has no setup" { + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER" + + assert_equal "$status" "0" + assert_contains "$output" "PROV=test_provider" +} + +@test "build_context: exports PROVIDER_DIR and PARAMETERS_ROOT" { + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PD=\$PROVIDER_DIR ROOT=\$PARAMETERS_ROOT" + + assert_equal "$status" "0" + assert_contains "$output" "PD=$PARAMETERS_DIR/providers/test_provider" + assert_contains "$output" "ROOT=$PARAMETERS_DIR" +} diff --git a/parameters/tests/utils/dispatch.bats b/parameters/tests/utils/dispatch.bats new file mode 100644 index 00000000..e6ba7c29 --- /dev/null +++ b/parameters/tests/utils/dispatch.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/dispatch — unified action dispatcher +# Replaces the previous 4 standalone scripts (store, retrieve, delete, notify) +# with a single dispatcher that takes the action via $ACTION env var. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/dispatch" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" + + # dispatch logs a timing line via the log function; it's normally pre-loaded + # by the workflow's first step. Mirror that for tests. + export DISPATCH_PRELUDE="source $PARAMETERS_DIR/utils/log;" +} + +@test "dispatch: ACTION=store sources provider's store script" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo '{"external_id":"id-1","metadata":{}}' +EOF + + run bash -c "$DISPATCH_PRELUDE ACTION=store source $SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"external_id":"id-1","metadata":{}}' +} + +@test "dispatch: ACTION=retrieve sources provider's retrieve script" { + cat > "$PROVIDER_DIR/retrieve" << 'EOF' +echo '{"value":"v"}' +EOF + + run bash -c "$DISPATCH_PRELUDE ACTION=retrieve source $SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"value":"v"}' +} + +@test "dispatch: ACTION=delete sources provider's delete script" { + cat > "$PROVIDER_DIR/delete" << 'EOF' +echo '{"success":true}' +EOF + + run bash -c "$DISPATCH_PRELUDE ACTION=delete source $SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"success":true}' +} + +@test "dispatch: ACTION=notify with provider notify file sources it" { + cat > "$PROVIDER_DIR/notify" << 'EOF' +echo '{"success":true,"provider":"fake"}' +EOF + + run bash -c "$DISPATCH_PRELUDE ACTION=notify source $SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" '"provider":"fake"' +} + +@test "dispatch: ACTION=notify falls back to default ack when provider has no notify" { + # Intentionally do NOT create $PROVIDER_DIR/notify + run bash -c "$DISPATCH_PRELUDE ACTION=notify source $SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"success":true}' +} + +@test "dispatch: provider script's exit code propagates" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo "fatal" >&2 +exit 7 +EOF + + run bash -c "$DISPATCH_PRELUDE ACTION=store source $SCRIPT" + + assert_equal "$status" "7" + assert_contains "$output" "fatal" +} + +@test "dispatch: provider script sees PROVIDER_DIR env var" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo "{\"provider_dir\":\"$PROVIDER_DIR\"}" +EOF + + run bash -c "$DISPATCH_PRELUDE ACTION=store source $SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "$PROVIDER_DIR" +} + +@test "dispatch: fails when provider's script doesn't exist (non-notify)" { + # No store script exists + run bash -c "$DISPATCH_PRELUDE ACTION=store source $SCRIPT" + + [ "$status" -ne 0 ] +} diff --git a/parameters/tests/utils/get_config_value.bats b/parameters/tests/utils/get_config_value.bats new file mode 100644 index 00000000..9bd88eeb --- /dev/null +++ b/parameters/tests/utils/get_config_value.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/get_config_value +# Priority: provider config > env var > default +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + source "$PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset PROVIDER_CONFIG + unset TEST_ENV_VAR OTHER_ENV +} + +@test "get_config_value: provider wins over env var" { + export PROVIDER_CONFIG='{"address":"from-provider"}' + export TEST_ENV_VAR="from-env" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address' --default "default") + assert_equal "$result" "from-provider" +} + +@test "get_config_value: env wins when provider has no match" { + export PROVIDER_CONFIG='{"other":"value"}' + export TEST_ENV_VAR="from-env" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address' --default "default") + assert_equal "$result" "from-env" +} + +@test "get_config_value: default is last resort" { + result=$(get_config_value --env UNSET_VAR --provider '.address' --default "fallback") + assert_equal "$result" "fallback" +} + +@test "get_config_value: returns empty when no match and no default" { + result=$(get_config_value --env UNSET_VAR --provider '.address') + assert_equal "$result" "" +} + +@test "get_config_value: works without PROVIDER_CONFIG set" { + unset PROVIDER_CONFIG + export TEST_ENV_VAR="env-only" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address') + assert_equal "$result" "env-only" +} + +@test "get_config_value: multiple provider paths, first match wins" { + export PROVIDER_CONFIG='{"a":null,"b":"second"}' + + result=$(get_config_value --provider '.a' --provider '.b' --default "fallback") + assert_equal "$result" "second" +} + +@test "get_config_value: multiple env vars, first set wins" { + export TEST_ENV_VAR="" + export OTHER_ENV="other-set" + + result=$(get_config_value --env TEST_ENV_VAR --env OTHER_ENV --default "fallback") + assert_equal "$result" "other-set" +} + +@test "get_config_value: null value in PROVIDER_CONFIG is treated as missing" { + export PROVIDER_CONFIG='{"address":null}' + + result=$(get_config_value --provider '.address' --default "fallback") + assert_equal "$result" "fallback" +} + +@test "get_config_value: invalid JSON in PROVIDER_CONFIG falls through to env" { + export PROVIDER_CONFIG='not-valid-json' + export TEST_ENV_VAR="env-val" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address') + assert_equal "$result" "env-val" +} + +@test "get_config_value: nested provider path resolves correctly" { + export PROVIDER_CONFIG='{"connection":{"address":"https://vault.example.com"}}' + + result=$(get_config_value --provider '.connection.address') + assert_equal "$result" "https://vault.example.com" +} diff --git a/parameters/tests/utils/log.bats b/parameters/tests/utils/log.bats new file mode 100644 index 00000000..b4866b23 --- /dev/null +++ b/parameters/tests/utils/log.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/log +# All log levels route to stderr (stdout is reserved for JSON contract). +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" +} + +teardown() { + unset -f log 2>/dev/null || true + unset LOG_LEVEL +} + +@test "log: info routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log info "hello" 2>&1 >/dev/null) + assert_equal "$err" "hello" + + # Verify stdout is empty + out=$(log info "hello" 2>/dev/null) + assert_equal "$out" "" +} + +@test "log: warn routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log warn "uh oh" 2>&1 >/dev/null) + assert_equal "$err" "uh oh" +} + +@test "log: error routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log error "boom" 2>&1 >/dev/null) + assert_equal "$err" "boom" +} + +@test "log: debug is silent by default" { + source "$PARAMETERS_DIR/utils/log" + err=$(log debug "shhh" 2>&1 >/dev/null) + assert_equal "$err" "" +} + +@test "log: debug emits to stderr when LOG_LEVEL=debug" { + export LOG_LEVEL=debug + source "$PARAMETERS_DIR/utils/log" + err=$(log debug "spoke up" 2>&1 >/dev/null) + assert_equal "$err" "spoke up" +} + +@test "log: stdout is always empty (JSON contract)" { + source "$PARAMETERS_DIR/utils/log" + out=$( + log info "info msg" + log warn "warn msg" + log error "error msg" + log debug "debug msg" + LOG_LEVEL=debug log debug "debug enabled msg" + ) + assert_equal "$out" "" +} diff --git a/parameters/tests/utils/prefetch_np.bats b/parameters/tests/utils/prefetch_np.bats new file mode 100644 index 00000000..109fb03b --- /dev/null +++ b/parameters/tests/utils/prefetch_np.bats @@ -0,0 +1,259 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/prefetch_np — the parallel np-cache builder. +# +# Verifies: +# - action-aware: store fetches entity slugs; retrieve/delete skip them +# - slug-from-payload skips the `np provider specification read` call +# - dimensions come from .value_dimensions / .dimensions / (none) — no +# scope read needed just for dims +# - NRN is built locally from entities/value_entities (no api call) +# - existing NP_CACHE_DIR is honored (test/script escape hatch) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/prefetch_np" + export BIN_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$BIN_DIR" + + # `np` mock — records every invocation and returns a recognizable payload + # per subcommand. + export NP_LOG="$BATS_TEST_TMPDIR/np-calls.log" + : > "$NP_LOG" + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +echo "$*" >> "$NP_LOG" +sub="$1 $2" +case "$sub" in + "provider specification") echo '{"slug":"aws-secrets-manager"}' ;; + "provider list") echo '{"results":[{"attributes":{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:x"}]}}}]}' ;; + "organization read") echo '{"slug":"acme"}' ;; + "account read") echo '{"slug":"prod"}' ;; + "namespace read") echo '{"slug":"billing"}' ;; + "application read") echo '{"slug":"api"}' ;; + "scope read") echo '{"slug":"staging"}' ;; + *) echo '{}' ;; +esac +EOF + chmod +x "$BIN_DIR/np" +} + +teardown() { + unset CONTEXT SPEC_ID NP_CACHE_DIR NRN +} + +# ---- Action-aware: store fires entity reads, retrieve/delete don't -------- + +@test "prefetch_np: store action — fires 4 entity reads + iam" { + export CONTEXT='{ + "action":"parameter:store", + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + assert_contains "$output" "organization read --id O" + assert_contains "$output" "account read --id A" + assert_contains "$output" "namespace read --id N" + assert_contains "$output" "application read --id AP" + assert_contains "$output" "provider list --categories identity-access-control" + # slug is in payload — no spec read + case "$output" in *"provider specification read"*) return 1 ;; esac +} + +@test "prefetch_np: retrieve action — skips entity reads, only iam fires" { + export CONTEXT='{ + "action":"parameter:retrieve", + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + assert_contains "$output" "provider list --categories identity-access-control" + # No entity reads at all + case "$output" in *"organization read"*) return 1 ;; esac + case "$output" in *"account read"*) return 1 ;; esac + case "$output" in *"namespace read"*) return 1 ;; esac + case "$output" in *"application read"*) return 1 ;; esac + case "$output" in *"scope read"*) return 1 ;; esac +} + +@test "prefetch_np: delete action — skips entity reads, only iam fires" { + export CONTEXT='{ + "action":"parameter:delete", + "value_entities":{"organization":"O","account":"A","namespace":"N","application":"AP","scope":"S"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + assert_contains "$output" "provider list --categories identity-access-control" + case "$output" in *"organization read"*) return 1 ;; esac + case "$output" in *"scope read"*) return 1 ;; esac +} + +# ---- Slug from payload skips the spec call -------------------------------- + +@test "prefetch_np: specification_slug in payload skips `np provider specification read`" { + export CONTEXT='{ + "action":"parameter:retrieve", + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + run cat "$NP_LOG" + case "$output" in *"provider specification read"*) return 1 ;; esac +} + +# ---- Dimensions resolution: from .value_dimensions / .dimensions ---------- + +@test "prefetch_np: value_dimensions (scope-level) is passed to iam call in wave 1" { + export CONTEXT='{ + "action":"parameter:retrieve", + "value_entities":{"organization":"O","account":"A","namespace":"N","application":"AP","scope":"S"}, + "value_dimensions":{"country":"uruguay","environment":"development"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + assert_contains "$output" "--dimensions country:uruguay,environment:development" + # No scope read needed since dimensions came from payload + case "$output" in *"scope read"*) return 1 ;; esac +} + +@test "prefetch_np: top-level dimensions (dim-level) is passed to iam call" { + export CONTEXT='{ + "action":"parameter:retrieve", + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "dimensions":{"country":"argentina","site":"aws-main"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + run cat "$NP_LOG" + assert_contains "$output" "--dimensions country:argentina,site:aws-main" +} + +# ---- Store + scope-level still fetches scope (for the slug) --------------- + +@test "prefetch_np: store + scope-level fires scope read (for slug in build_external_id)" { + export CONTEXT='{ + "action":"parameter:store", + "value_entities":{"organization":"O","account":"A","namespace":"N","application":"AP","scope":"S"}, + "value_dimensions":{"environment":"production"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + assert_contains "$output" "scope read --id S" + # iam still fires with dims from value_dimensions (no wave 2) + assert_contains "$output" "--dimensions environment:production" +} + +# ---- Escape hatch + cache file presence ----------------------------------- + +@test "prefetch_np: pre-set NP_CACHE_DIR is honored — no np calls fired" { + export CONTEXT='{ + "action":"parameter:retrieve", + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/preset-cache" + mkdir -p "$NP_CACHE_DIR" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + [ -z "$output" ] +} + +@test "prefetch_np: store action writes the cache files it needs" { + export CONTEXT='{ + "action":"parameter:store", + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "provider":{"specification_id":"spec-123","specification_slug":"aws-secrets-manager"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + for f in organization account namespace application iam; do + if [ -s \"\$NP_CACHE_DIR/\$f.json\" ]; then echo \"OK \$f\"; else echo \"MISS \$f\"; fi + done + # spec.json should NOT exist when slug is in payload + if [ -s \"\$NP_CACHE_DIR/spec.json\" ]; then echo \"UNEXPECTED spec\"; else echo \"OK no-spec\"; fi + " + + assert_equal "$status" "0" + assert_contains "$output" "OK organization" + assert_contains "$output" "OK account" + assert_contains "$output" "OK namespace" + assert_contains "$output" "OK application" + assert_contains "$output" "OK iam" + assert_contains "$output" "OK no-spec" +} diff --git a/parameters/utils/assume_role b/parameters/utils/assume_role new file mode 100644 index 00000000..26018f9f --- /dev/null +++ b/parameters/utils/assume_role @@ -0,0 +1,130 @@ +#!/bin/bash +# Sourceable helper — do NOT execute directly. +# Reads ASSUME_ROLE_ARN_RESOLVED from the environment. If set, calls +# sts:AssumeRole (or pulls valid creds from the local cache) and exports +# temporary credentials so all subsequent AWS calls use that role. If empty, +# does nothing — the agent's credentials (pod IRSA) handle auth. +# +# Credentials cache +# STS tokens are valid for ~1h (configurable up to 12h via the target role's +# MaxSessionDuration). The default sts:AssumeRole call takes ~0.9s — costly +# to repeat on every workflow run. We cache the full JSON response per ARN +# under $SERVICE_PATH/credentials/, keyed by a SHA-256 hash of the ARN. +# +# Expiration handling: at write time we parse AWS's `.Credentials.Expiration` +# into a Unix epoch and store it as `._cache_exp_epoch` alongside the creds. +# If date(1) can't parse the ISO 8601 (busybox + non-GNU corner cases), we +# fall back to `now + 3540s` (1h minus a 60s margin). Read time is then just +# integer math: `_cache_exp_epoch - now > NP_STS_CACHE_BUFFER_SECS` (default +# 60s). This is robust across GNU date / BSD date / busybox. +# +# Files are mode 600 in a 700 dir; writes go through a tmp + atomic rename +# so two parallel workflows don't tear a half-written file. +# +# Provider-agnostic: the caller's setup goes through utils/assume_role_step, +# which writes the final ARN to ASSUME_ROLE_ARN_RESOLVED regardless of which +# provider-specific env var name was used as the override source. +# +# Requires: aws CLI, jq, and the `log` function (loaded by the workflow). +# Expects: ASSUME_ROLE_ARN_RESOLVED (set by utils/assume_role_step), +# ASSUME_ROLE_SESSION_PREFIX (set by assume_role_step, defaults to +# "np-parameters"), SCOPE_ID (optional, used for the session name), +# SERVICE_PATH (agent runtime — used for the cache dir). + +if [ -n "${ASSUME_ROLE_ARN_RESOLVED:-}" ]; then + log info " 🔑 Assuming role: $ASSUME_ROLE_ARN_RESOLVED" + + _ar_session_name="${ASSUME_ROLE_SESSION_PREFIX:-np-parameters}-${SCOPE_ID:-workflow}" + + # ---- Cache lookup -------------------------------------------------------- + : "${SERVICE_PATH:?SERVICE_PATH must be set (the agent runtime supplies this)}" + _ar_cache_dir="$SERVICE_PATH/credentials" + # SHA-256 of the ARN as cache key. Try GNU coreutils first, fall back to + # macOS `shasum -a 256` so this also works in local smoke tests. + if command -v sha256sum >/dev/null 2>&1; then + _ar_cache_key=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | sha256sum | cut -c1-16) + else + _ar_cache_key=$(printf '%s' "$ASSUME_ROLE_ARN_RESOLVED" | shasum -a 256 | cut -c1-16) + fi + _ar_cache_file="$_ar_cache_dir/$_ar_cache_key.json" + + # Cache freshness check: read the precomputed `_cache_exp_epoch` we wrote at + # store time and compare it to now. We never parse AWS's ISO 8601 here — that + # date format is unreliable across `date` implementations (GNU/BSD/busybox). + # The epoch is computed once at write, so read-time is pure integer math. + ASSUMED_CREDS="" + if [ -s "$_ar_cache_file" ]; then + _ar_exp_epoch=$(jq -r '._cache_exp_epoch // 0' < "$_ar_cache_file" 2>/dev/null) + _ar_now=$(date -u +%s) + _ar_ttl=$(( _ar_exp_epoch - _ar_now )) + _ar_buffer="${NP_STS_CACHE_BUFFER_SECS:-60}" + if [ "$_ar_ttl" -gt "$_ar_buffer" ]; then + ASSUMED_CREDS=$(cat "$_ar_cache_file") + log debug " 🎫 sts cache hit (expires in ${_ar_ttl}s)" + else + log debug " 🎫 sts cache stale (ttl=${_ar_ttl}s, buffer=${_ar_buffer}s)" + fi + fi + + # ---- Cache miss: do the real sts:AssumeRole call ------------------------- + if [ -z "$ASSUMED_CREDS" ]; then + _ar_sts_error=$(mktemp) + T_STS=$(timer_now 2>/dev/null || echo 0) + if ! ASSUMED_CREDS=$(aws sts assume-role \ + --role-arn "$ASSUME_ROLE_ARN_RESOLVED" \ + --role-session-name "$_ar_session_name" \ + --output json 2>"$_ar_sts_error"); then + log error " ❌ sts:AssumeRole failed for $ASSUME_ROLE_ARN_RESOLVED" + log error "$(cat "$_ar_sts_error")" + rm -f "$_ar_sts_error" + return 1 + fi + rm -f "$_ar_sts_error" + log debug " ⏱ sts:AssumeRole $(timer_elapsed "$T_STS" 2>/dev/null || echo "?")" + + # Compute the cache-expiration epoch ONCE at write time. We try multiple + # date(1) implementations to parse AWS's ISO 8601; if all of them fail + # (e.g. on busybox), fall back to `now + 3540s` — STS tokens default to 1h + # and we always keep a 60s safety margin. + _ar_now=$(date -u +%s) + _ar_exp_iso=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.Expiration // empty') + _ar_exp_epoch=$(( _ar_now + 3540 )) # conservative default (1h - 60s) + if [ -n "$_ar_exp_iso" ]; then + _ar_parsed=$(date -u -d "$_ar_exp_iso" +%s 2>/dev/null) \ + || _ar_parsed=$(date -u -j -f "%Y-%m-%dT%H:%M:%S%z" "${_ar_exp_iso/Z/+0000}" +%s 2>/dev/null) \ + || _ar_parsed="" + if [ -n "$_ar_parsed" ] && [ "$_ar_parsed" -gt "$_ar_now" ]; then + _ar_exp_epoch="$_ar_parsed" + fi + fi + + # Persist to cache (atomic via tmp + rename so concurrent workflows can't + # observe a half-written file). Permissions: 600 on file, 700 on dir. + mkdir -p "$_ar_cache_dir" + chmod 700 "$_ar_cache_dir" 2>/dev/null || true + _ar_cache_tmp="$_ar_cache_file.$$" + echo "$ASSUMED_CREDS" | jq --argjson e "$_ar_exp_epoch" --arg eiso "$_ar_exp_iso" \ + '. + {_cache_exp_epoch: $e, _cache_exp_iso: $eiso}' > "$_ar_cache_tmp" + chmod 600 "$_ar_cache_tmp" 2>/dev/null || true + mv -f "$_ar_cache_tmp" "$_ar_cache_file" + log debug " 🎫 sts cached at $_ar_cache_file (expires in $(( _ar_exp_epoch - _ar_now ))s)" + fi + + # ---- Extract + export ---------------------------------------------------- + _ar_access_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.AccessKeyId // ""') + _ar_secret_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SecretAccessKey // ""') + _ar_session_token=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SessionToken // ""') + + if [ -z "$_ar_access_key" ] || [ -z "$_ar_secret_key" ] || [ -z "$_ar_session_token" ]; then + log error " ❌ sts:AssumeRole returned incomplete credentials for $ASSUME_ROLE_ARN_RESOLVED" + return 1 + fi + + export AWS_ACCESS_KEY_ID="$_ar_access_key" + export AWS_SECRET_ACCESS_KEY="$_ar_secret_key" + export AWS_SESSION_TOKEN="$_ar_session_token" + + log info " ✅ Role assumed successfully" +else + log debug " ✅ assume_role=skipped (using agent credentials)" +fi diff --git a/parameters/utils/assume_role_lib b/parameters/utils/assume_role_lib new file mode 100644 index 00000000..b2d004f4 --- /dev/null +++ b/parameters/utils/assume_role_lib @@ -0,0 +1,48 @@ +#!/bin/bash +# Sourceable library of PURE helpers for assume-role resolution. +# +# Provider-agnostic: each caller passes its own selector and the names of the +# env vars to consult. Makes NO np/aws calls and has no source side effects — +# fully unit-testable. +# +# Requires (at call time): jq, bash >= 4 (uses ${!var} indirect expansion). + +# arn_for_selector +# Echoes the ARN whose entry in .iam_role_arns.arns[] matches , +# or "" if none. First match wins. Never crashes on empty/malformed input. +arn_for_selector() { + local json="$1" selector="$2" + [ -n "$json" ] || return 0 + [ -n "$selector" ] || return 0 + printf '%s' "$json" | jq -r --arg sel "$selector" ' + [ .iam_role_arns.arns[]? + | select(.selector == $sel) + | .arn ] + | first // ""' 2>/dev/null +} + +# resolve_assume_role_arn +# Echoes the ARN to assume ("" = use agent credentials), in precedence order: +# 1. ${!override_env_name} — explicit per-run override +# 2. iam_attributes_json entry matching (caller pre-resolved the +# provider via `np provider list` for the scope's dimensions) +# 3. ${!default_env_name} — per-account agent default +# Empty / are treated as unset (chain continues). +# An explicitly-empty ${!override_env_name}="" is also treated as unset. +resolve_assume_role_arn() { + local iam_json="$1" selector="$2" override_env="$3" default_env="$4" arn="" + + if [ -n "$override_env" ]; then + arn="${!override_env:-}" + fi + + if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then + arn=$(arn_for_selector "$iam_json" "$selector") + fi + + if [ -z "$arn" ] && [ -n "$default_env" ]; then + arn="${!default_env:-}" + fi + + printf '%s' "$arn" +} diff --git a/parameters/utils/assume_role_step b/parameters/utils/assume_role_step new file mode 100644 index 00000000..d940ec4c --- /dev/null +++ b/parameters/utils/assume_role_step @@ -0,0 +1,102 @@ +#!/bin/bash +# Dedicated workflow step: resolve the target IAM role and assume it, exporting +# temporary credentials so every subsequent step inherits them. +# +# Provider-agnostic. The caller's setup MUST set these three variables BEFORE +# sourcing this script (none have defaults — being explicit is part of the +# contract): +# +# ASSUME_ROLE_SELECTOR — selector key under .iam_role_arns.arns[] in +# the IAM provider (e.g. "secret_manager", +# "aws-parameter-store"). +# ASSUME_ROLE_OVERRIDE_ENV — name of the env var that, when set, overrides +# the IAM-provider lookup +# (e.g. "SECRET_MANAGER_ASSUME_ROLE_ARN"). +# ASSUME_ROLE_DEFAULT_ENV — name of the env var consulted as the agent's +# per-account default if no other source yields +# an ARN (e.g. "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT"). +# +# Optional: +# ASSUME_ROLE_SESSION_PREFIX — prefix for the STS session name. Defaults to +# "np-parameters". +# +# Output (exported for downstream scripts): +# ASSUME_ROLE_ARN_RESOLVED — the final ARN, or empty for "use agent creds". +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN — set when an +# ARN was resolved and sts:AssumeRole succeeded. +# +# Unlike the k8s flow — where the platform pre-resolves the IAM provider into +# CONTEXT.providers["identity-access-control"] using the scope's dimensions — +# parameter actions don't get the IAM provider in the payload. We fetch it +# ourselves via `np provider list --categories identity-access-control`, passing +# `--nrn` and (when present) `--dimensions` so the platform performs the same +# dimension-aware resolution it would do for k8s scopes. +# +# NRN construction (organization/account/namespace/application are guaranteed): +# - value_entities present → NRN includes scope segment (`...:scope=`) +# - else → NRN is app-level (organization=…:account=…:namespace=…:application=…) +# +# Dimension resolution precedence: +# 1. $CONTEXT.dimensions, if it's a non-empty object → pass to np +# 2. else if value_entities.scope is present → `np scope read --id ... --query .dimensions` +# 3. else no `--dimensions` flag (NRN-only lookup) +# +# ARN resolution precedence (see resolve_assume_role_arn in assume_role_lib): +# ${!ASSUME_ROLE_OVERRIDE_ENV} -> IAM provider by selector +# -> ${!ASSUME_ROLE_DEFAULT_ENV} -> agent credentials +# +# Requires: aws CLI, jq, np CLI. Expects: CONTEXT (engine-injected), +# SCOPE_ID (optional). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/assume_role_lib" + +# --- 0. Contract: caller must supply selector + env-var names --------------- +: "${ASSUME_ROLE_SELECTOR:?ASSUME_ROLE_SELECTOR must be set by the caller (e.g. 'secret_manager' or 'aws-parameter-store')}" +: "${ASSUME_ROLE_OVERRIDE_ENV:?ASSUME_ROLE_OVERRIDE_ENV must be set by the caller (name of the override env var)}" +: "${ASSUME_ROLE_DEFAULT_ENV:?ASSUME_ROLE_DEFAULT_ENV must be set by the caller (name of the default-fallback env var)}" + +# Session prefix for the STS session name (informational). +export ASSUME_ROLE_SESSION_PREFIX="${ASSUME_ROLE_SESSION_PREFIX:-np-parameters}" + +T_AR_PREP=$(timer_now) + +# SCOPE_ID is used by assume_role only for the STS session-name suffix. +# Pull it from the same source NRN was built from (entities/value_entities). +if [ -z "${SCOPE_ID:-}" ]; then + SCOPE_ID=$(echo "${CONTEXT:-}" | jq -r '(.value_entities // .entities // {}).scope // empty' 2>/dev/null) + export SCOPE_ID +fi + +# IAM provider data is pre-fetched by utils/prefetch_np into $NP_CACHE_DIR/iam.json. +# No np call here — just read the cached result. +if [ -n "${NP_CACHE_DIR:-}" ] && [ -s "$NP_CACHE_DIR/iam.json" ]; then + IAM_ATTRIBUTES=$(jq -c '.results[0].attributes // {}' < "$NP_CACHE_DIR/iam.json" 2>/dev/null) +else + if [ -n "${NP_CACHE_DIR:-}" ] && [ -s "$NP_CACHE_DIR/iam.err" ]; then + log warn " ⚠️ IAM provider lookup failed during prefetch: $(cat "$NP_CACHE_DIR/iam.err")" + fi + IAM_ATTRIBUTES="" +fi + +# --- 4. Resolve and export the ARN ----------------------------------------- +ASSUME_ROLE_ARN_RESOLVED=$(resolve_assume_role_arn \ + "$IAM_ATTRIBUTES" \ + "$ASSUME_ROLE_SELECTOR" \ + "$ASSUME_ROLE_OVERRIDE_ENV" \ + "$ASSUME_ROLE_DEFAULT_ENV") +export ASSUME_ROLE_ARN_RESOLVED +log debug " ⏱ assume_role_step.resolve_arn $(timer_elapsed "$T_AR_PREP")" + +# --- 5. Perform the assume-role (no-op if ARN is empty) -------------------- +if ! source "$SCRIPT_DIR/assume_role"; then + log error " ❌ assume_role step failed: could not assume $ASSUME_ROLE_ARN_RESOLVED" + log error "" + log error "💡 Possible causes:" + log error " • The agent's role is not allowed to sts:AssumeRole the target role" + log error " • The target role does not exist or does not trust the agent role" + log error " • No role ARN configured for selector=$ASSUME_ROLE_SELECTOR" + log error " in the IAM provider for NRN=$NRN" + log error "" + exit 1 +fi diff --git a/parameters/utils/build_context b/parameters/utils/build_context new file mode 100755 index 00000000..cdc7edd7 --- /dev/null +++ b/parameters/utils/build_context @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +# Resolves which provider implementation handles this workflow run. +# +# Inputs: +# CONTEXT — JSON of the notification body (set by entrypoint) +# +# Flow: +# 1. Extract notification fields into env vars +# 2. Derive PARAMETER_KIND from $CONTEXT.secret (still useful for providers +# like aws-parameter-store that choose String vs SecureString) +# 3. Resolve ACTIVE_PROVIDER from $CONTEXT.provider.specification_id via +# `np provider specification read` — its `.slug` IS the provider directory name +# 4. Set PROVIDER_CONFIG from $CONTEXT.provider.attributes (config travels in +# the payload — no separate fetch_configuration step needed) +# 5. Source providers//setup (validates provider-specific config and +# exports connection handles) +# +# Outputs (exported for downstream workflow steps): +# PARAMETERS_ROOT, ACTIVE_PROVIDER, PROVIDER_DIR, PARAMETER_KIND +# PROVIDER_CONFIG, EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE +# PARAMETER_NAME, PARAMETER_ENCODING +# Plus anything the provider's setup exports (VAULT_ADDR, AWS_REGION, etc.) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# build_context lives under utils/; PARAMETERS_ROOT is the parent (parameters/) +export PARAMETERS_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +source "$SCRIPT_DIR/log" +source "$SCRIPT_DIR/get_config_value" + +T_BUILD_CTX=$(timer_now) +T_PARSE=$(timer_now) + +# --- Notification fields --- +export EXTERNAL_ID=$(echo "$CONTEXT" | jq -r '.external_id // empty') +# external_id format: # +# Split into path (used for backend naming) and version (optional, used by retrieve). +# Store rebuilds EXTERNAL_ID after the backend returns the new version_id. +if [[ "$EXTERNAL_ID" == *"#"* ]]; then + export EXTERNAL_ID_PATH="${EXTERNAL_ID%#*}" + export EXTERNAL_ID_VERSION="${EXTERNAL_ID##*#}" +else + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" +fi +export PARAMETER_ID=$(echo "$CONTEXT" | jq -r '.parameter_id // empty') +export PARAMETER_VALUE=$(echo "$CONTEXT" | jq -r '.value // empty') +export PARAMETER_NAME=$(echo "$CONTEXT" | jq -r '.parameter_name // empty') +export PARAMETER_ENCODING=$(echo "$CONTEXT" | jq -r '.encoding // empty') + +case "$(echo "$CONTEXT" | jq -r '.secret | tostring')" in + true) PARAMETER_KIND="secret" ;; + false) PARAMETER_KIND="parameter" ;; + *) PARAMETER_KIND="" ;; +esac +export PARAMETER_KIND + +log debug "⏱ build_context.notification_parse $(timer_elapsed "$T_PARSE")" + +# --- Resolve ACTIVE_PROVIDER --- +# The platform ships .provider.specification_slug in the payload; use it directly. +ACTIVE_PROVIDER=$(echo "$CONTEXT" | jq -r '.provider.specification_slug // empty') +SPEC_ID=$(echo "$CONTEXT" | jq -r '.provider.specification_id // empty') + +if [ -z "$ACTIVE_PROVIDER" ]; then + log error "❌ Missing .provider.specification_slug in CONTEXT" + log error "" + log error "💡 Possible causes:" + log error " • The notification payload is malformed" + log error " • The parameter has no associated provider in nullplatform" + log error "" + log error "🔧 How to fix:" + log error " • Verify the parameter has a provider configured" + exit 1 +fi + +# Pre-fetch every `np` read this workflow needs, in parallel. Downstream +# (assume_role_step, build_external_id) reads from $NP_CACHE_DIR instead of +# re-invoking np. +source "$SCRIPT_DIR/prefetch_np" + +PROVIDER_DIR="$PARAMETERS_ROOT/providers/$ACTIVE_PROVIDER" +if [ ! -d "$PROVIDER_DIR" ]; then + available=$(ls "$PARAMETERS_ROOT/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true) + log error "❌ Provider implementation not found for slug '$ACTIVE_PROVIDER'" + log error "" + log error "💡 Possible causes:" + log error " • The provider specification slug doesn't match any installed provider" + log error "" + log error "🔧 How to fix:" + log error " • Available providers: ${available:-(none installed)}" + log error " • Rename the spec slug, or add a provider at parameters/providers/$ACTIVE_PROVIDER/" + exit 1 +fi +export ACTIVE_PROVIDER +export PROVIDER_DIR + +# --- PROVIDER_CONFIG comes directly from the payload (no fetch needed) --- +export PROVIDER_CONFIG=$(echo "$CONTEXT" | jq -c '.provider.attributes // {}') + +log debug "📦 active_provider=$ACTIVE_PROVIDER kind=$PARAMETER_KIND spec_id=$SPEC_ID" + +# --- Source provider's setup for validation + connection handles --- +if [ -f "$PROVIDER_DIR/setup" ]; then + log debug "📡 Sourcing $ACTIVE_PROVIDER/setup" + T_SETUP=$(timer_now) + source "$PROVIDER_DIR/setup" + log debug "⏱ Provider setup $(timer_elapsed "$T_SETUP")" +fi + +log debug "⏱ build_context total $(timer_elapsed "$T_BUILD_CTX")" diff --git a/parameters/utils/build_external_id b/parameters/utils/build_external_id new file mode 100755 index 00000000..0f945b6c --- /dev/null +++ b/parameters/utils/build_external_id @@ -0,0 +1,93 @@ +#!/bin/bash + +# Constructs EXTERNAL_ID for a store operation by composing: +# =-/.../=/- +# +# The path is human-friendly: anyone entering the storage layer manually (AWS +# console, Vault UI, az portal) sees the entity hierarchy, the dimensions, and +# the parameter name + id directly in the path. +# +# Slugs are fetched in parallel via `np read --id --format json --query '.slug'`. +# Entities are iterated in canonical NRN order; only present entities are included. +# Dimensions are sorted alphabetically by key for determinism. +# +# Slugs in nullplatform are immutable (contract guarantee), so the resulting +# EXTERNAL_ID is stable for the lifetime of the parameter. +# +# Requires: np CLI in PATH, jq, mktemp +# Reads: CONTEXT (set by entrypoint), log function in scope +# Exports: EXTERNAL_ID (slash-separated canonical form) +# +# Each provider's store decides how to use EXTERNAL_ID: +# - vault/SM/PS: append to a prefix that ends with `/` +# - AKV: replace `/` and `=` with `-` (AKV doesn't allow those chars) + +# Replaces characters not allowed by AWS SM / Parameter Store secret names. +# AWS SM allows alphanumerics and /_+=.@- ; everything else becomes underscore. +sanitize_for_path() { + echo "$1" | sed 's|[^A-Za-z0-9._-]|_|g' +} + +build_external_id() { + local t_start + t_start=$(timer_now) + + # value_entities is the scope-level shape (carries the scope segment). + # entities is the app/dimension-level shape (no scope). Falling back to {} + # only matters for tests; in production at least one is always present. + local entities_json + entities_json=$(echo "$CONTEXT" | jq -c '.value_entities // .entities // {}') + + local entity_order=("organization" "account" "namespace" "application" "scope") + local segments=() + + for entity_type in "${entity_order[@]}"; do + local entity_id + entity_id=$(echo "$entities_json" | jq -r ".$entity_type // empty") + [ -z "$entity_id" ] && continue + + # Entity slugs are pre-fetched by utils/prefetch_np into the cache. No + # np call here — just read the JSON record and pull .slug. + local entity_file="${NP_CACHE_DIR:-}/$entity_type.json" + if [ ! -s "$entity_file" ]; then + log error "❌ Missing pre-fetched record for $entity_type=$entity_id" + log error "" + log error "💡 Possible causes:" + log error " • Entity does not exist in nullplatform" + log error " • np CLI is not authenticated" + log error " • utils/prefetch_np was not sourced before build_external_id" + log error "" + log error "🔧 How to fix:" + log error " • Verify: np $entity_type read --id $entity_id --format json" + [ -s "${NP_CACHE_DIR:-}/$entity_type.err" ] && \ + log error " • Underlying error: $(cat "${NP_CACHE_DIR}/$entity_type.err")" + exit 1 + fi + + local slug + slug=$(jq -r '.slug // empty' < "$entity_file") + if [ -z "$slug" ]; then + log error "❌ Pre-fetched $entity_type record has no .slug (id=$entity_id)" + exit 1 + fi + segments+=("$entity_type=${slug}-${entity_id}") + done + + while IFS= read -r dim; do + [ -n "$dim" ] && segments+=("$dim") + done < <(echo "$CONTEXT" | jq -r '(.dimensions // {}) | to_entries | sort_by(.key) | .[] | "\(.key)=\(.value)"') + + local param_id param_name + param_id=$(echo "$CONTEXT" | jq -r '.parameter_id // empty') + param_name=$(echo "$CONTEXT" | jq -r '.parameter_name // empty') + if [ -n "$param_name" ] && [ -n "$param_id" ]; then + segments+=("$(sanitize_for_path "$param_name")-$param_id") + elif [ -n "$param_id" ]; then + segments+=("$param_id") + fi + + local IFS=/ + EXTERNAL_ID="${segments[*]}" + export EXTERNAL_ID + log debug "⏱ build_external_id $(timer_elapsed "$t_start")" +} diff --git a/parameters/utils/dispatch b/parameters/utils/dispatch new file mode 100755 index 00000000..949e6ace --- /dev/null +++ b/parameters/utils/dispatch @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +# Unified dispatcher — delegates to the active provider's implementation for $ACTION. +# +# Invoked by every workflow as the second step, after build_context has resolved +# ACTIVE_PROVIDER and exported PROVIDER_DIR. The workflow YAML supplies the +# action via configuration: +# +# configuration: +# ACTION: store # or retrieve / delete / notify +# +# Special case: notify is OPTIONAL per provider. If the provider doesn't expose +# a notify script, return the default success ack so the action stays a no-op +# without forcing every provider to ship a trivial notify file. + +if [ "$ACTION" = "notify" ] && [ ! -f "$PROVIDER_DIR/notify" ]; then + echo '{"success":true}' + exit 0 +fi + +T_DISPATCH=$(timer_now) +source "$PROVIDER_DIR/$ACTION" +log debug "⏱ dispatch($ACTION) total $(timer_elapsed "$T_DISPATCH")" diff --git a/parameters/utils/get_config_value b/parameters/utils/get_config_value new file mode 100755 index 00000000..2ae29408 --- /dev/null +++ b/parameters/utils/get_config_value @@ -0,0 +1,60 @@ +#!/bin/bash + +# Get configuration value with priority: provider config > environment variable > default. +# Usage: get_config_value [--provider "jq.path"] ... [--env ENV_VAR] ... [--default "value"] +# +# Reads provider config from $PROVIDER_CONFIG — a JSON string scoped to the +# currently active provider. It is populated by providers//fetch_configuration +# (each provider owns its own config-fetching mechanism). The shape of +# PROVIDER_CONFIG is defined by each provider. +# +# Example (inside providers/hashicorp-vault/setup): +# VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') +# VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') + +get_config_value() { + local default_value="" + local -a providers=() + local -a env_vars=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --env) env_vars+=("${2:-}"); shift 2 ;; + --provider) providers+=("${2:-}"); shift 2 ;; + --default) default_value="${2:-}"; shift 2 ;; + *) shift ;; + esac + done + + # Priority 1: provider config + if [ "${#providers[@]}" -gt 0 ]; then + for jq_path in "${providers[@]}"; do + if [[ -n "$jq_path" && -n "${PROVIDER_CONFIG:-}" ]]; then + local v + v=$(echo "$PROVIDER_CONFIG" | jq -r "$jq_path // empty" 2>/dev/null || true) + if [[ -n "$v" && "$v" != "null" ]]; then + echo "$v" + return 0 + fi + fi + done + fi + + # Priority 2: env vars + if [ "${#env_vars[@]}" -gt 0 ]; then + for env_var in "${env_vars[@]}"; do + if [[ -n "$env_var" && -n "${!env_var:-}" ]]; then + echo "${!env_var}" + return 0 + fi + done + fi + + # Priority 3: default + if [[ -n "$default_value" ]]; then + echo "$default_value" + return 0 + fi + + echo "" +} diff --git a/parameters/utils/log b/parameters/utils/log new file mode 100755 index 00000000..01d05a5f --- /dev/null +++ b/parameters/utils/log @@ -0,0 +1,52 @@ +#!/bin/bash + +# Minimal structured logging. +# Usage: log +# level ∈ debug | info | warn | error +# +# All levels route to STDERR. stdout is reserved for the JSON contract output +# of operation scripts (store/retrieve/delete/notify). Logging on stdout would +# corrupt the JSON parsed by the platform. +# +# Debug is silent unless LOG_LEVEL=debug. + +log() { + local level="${1:-info}" + shift || true + local msg="$*" + case "$level" in + debug) [ "${LOG_LEVEL:-info}" = "debug" ] && echo "$msg" >&2 || true ;; + info) echo "$msg" >&2 ;; + warn) echo "$msg" >&2 ;; + error) echo "$msg" >&2 ;; + *) echo "$level $msg" >&2 ;; + esac +} + +# Timer helpers — used to measure where time goes in a workflow run. +# Precedence at source time: +# 1. bash 5+ $EPOCHREALTIME (sub-microsecond, no fork) +# 2. GNU `date +%s.%N` (nanosecond, one fork; Linux) +# 3. integer `date +%s` (second; macOS bash 3.2 default — coarse) +# Detection happens once so timer_now stays cheap (no test per call). +if [ -n "${EPOCHREALTIME:-}" ]; then + _NP_TIMER_KIND="bash" +elif [[ "$(date +%N 2>/dev/null)" =~ ^[0-9]+$ ]]; then + _NP_TIMER_KIND="gnudate" +else + _NP_TIMER_KIND="coarse" +fi + +timer_now() { + case "$_NP_TIMER_KIND" in + bash) echo "$EPOCHREALTIME" ;; + gnudate) date +%s.%N ;; + *) date +%s ;; + esac +} + +timer_elapsed() { + local start="$1" end + end=$(timer_now) + awk -v s="$start" -v e="$end" 'BEGIN { printf "%.3fs", e - s }' +} diff --git a/parameters/utils/prefetch_np b/parameters/utils/prefetch_np new file mode 100644 index 00000000..843fa2fa --- /dev/null +++ b/parameters/utils/prefetch_np @@ -0,0 +1,127 @@ +#!/bin/bash +# Sourceable. Fires every `np` read this workflow will need, in parallel, +# into a single tmpdir cache. Downstream scripts (build_context, +# assume_role_step, build_external_id) read from the cache instead of +# re-invoking `np`. +# +# Why this exists: each `np` CLI invocation is ~1–2s (startup + HTTPS call to +# the platform). The store/retrieve/delete flow chains 5–9 such calls in +# series across modules. Running them in parallel turns Σ(t) into max(t) and +# typically cuts wall time by 3–6×. +# +# Action-aware: retrieve/delete only need the IAM provider (everything else +# is reconstructible from the payload's external_id). Store additionally needs +# entity slugs (org/account/namespace/application + scope) to compose the +# external_id path. +# +# Inputs: +# CONTEXT — JSON of the notification body (set by entrypoint). Must include +# .provider.specification_slug (platform contract) and +# .value_dimensions / .dimensions when applicable. +# +# Escape hatch for tests: if NP_CACHE_DIR is pre-set and exists, prefetch is +# skipped — tests pre-populate the cache with fixtures and source the rest of +# the chain normally without touching `np` at all. +# +# Output: +# NP_CACHE_DIR — exported; contains (only the files that were fetched): +# {organization,account,namespace,application}.json — entity records (store only) +# scope.json — entity record (store + scope-level only) +# iam.json — `np provider list --categories identity-access-control` +# NRN — exported; computed locally from entities/value_entities + +: "${CONTEXT:?CONTEXT must be set before sourcing prefetch_np}" + +if [ -n "${NP_CACHE_DIR:-}" ] && [ -d "$NP_CACHE_DIR" ]; then + log debug "📦 NP_CACHE_DIR pre-set ($NP_CACHE_DIR) — skipping prefetch" + return 0 2>/dev/null || exit 0 +fi + +NP_CACHE_DIR=$(mktemp -d) +export NP_CACHE_DIR + +# value_entities (scope-level) wins over entities (app/dimension-level). +ENTITIES_JSON=$(echo "$CONTEXT" | jq -c '.value_entities // .entities // {}') + +NRN=$(printf '%s' "$ENTITIES_JSON" | jq -r ' + if (.organization // null) == null then "" else + [ + ("organization=" + .organization), + ("account=" + .account), + ("namespace=" + .namespace), + ("application=" + .application) + ] + + (if .scope then [("scope=" + .scope)] else [] end) + | join(":") + end') +export NRN + +SCOPE_ID_PREFETCH=$(printf '%s' "$ENTITIES_JSON" | jq -r '.scope // empty') + +# Dimensions for the IAM lookup. Resolution order: +# 1. .value_dimensions — scope-level (platform pre-resolved scope dimensions) +# 2. .dimensions — dim-level without scope +# 3. (none) — app-level +DIMS_JSON=$(echo "$CONTEXT" | jq -c '.value_dimensions // .dimensions // empty') +DIMS_ARG="" +if [ -n "$DIMS_JSON" ] && [ "$DIMS_JSON" != "null" ] && [ "$DIMS_JSON" != "{}" ]; then + DIMS_ARG=$(printf '%s' "$DIMS_JSON" | jq -r ' + to_entries | map("\(.key):\(.value)") | join(",")') +fi + +# Action-aware: only `store` needs entity slugs (build_external_id composes +# the path from them). retrieve/delete/notify reuse the EXTERNAL_ID that's +# already in the payload. +ACTION_NAME=$(echo "$CONTEXT" | jq -r '.action // empty' | awk -F: '{print $2}') +NEEDS_ENTITIES="no" +[ "$ACTION_NAME" = "store" ] && NEEDS_ENTITIES="yes" + +# ── Single wave: entities (store only) + IAM, all in parallel ────────────── +T_PREFETCH=$(timer_now) +log debug "🚀 Prefetch wave 1 (NRN=$NRN${DIMS_ARG:+ dims=$DIMS_ARG} entities=$NEEDS_ENTITIES)" + +# Entity reads — only for `store` (need slugs for the canonical path). +if [ "$NEEDS_ENTITIES" = "yes" ]; then + for entity_type in organization account namespace application; do + entity_id=$(printf '%s' "$ENTITIES_JSON" | jq -r ".$entity_type // empty") + if [ -n "$entity_id" ]; then + ( _t=$(timer_now); \ + np "$entity_type" read --id "$entity_id" --format json \ + > "$NP_CACHE_DIR/$entity_type.json" 2>"$NP_CACHE_DIR/$entity_type.err"; \ + printf '%s' "$(timer_elapsed "$_t")" > "$NP_CACHE_DIR/timings.$entity_type" ) & + fi + done + + # Scope read — only when scope-level AND store (need the slug for the path). + # retrieve/delete don't need it; dimensions for IAM come from .value_dimensions. + if [ -n "$SCOPE_ID_PREFETCH" ]; then + ( _t=$(timer_now); \ + np scope read --id "$SCOPE_ID_PREFETCH" --format json \ + > "$NP_CACHE_DIR/scope.json" 2>"$NP_CACHE_DIR/scope.err"; \ + printf '%s' "$(timer_elapsed "$_t")" > "$NP_CACHE_DIR/timings.scope" ) & + fi +fi + +# IAM lookup — always (sts:AssumeRole needs the ARN). Runs in parallel with +# the entity reads because dimensions come from the payload up-front. +if [ -n "$NRN" ]; then + if [ -n "$DIMS_ARG" ]; then + ( _t=$(timer_now); \ + np provider list --categories identity-access-control --nrn "$NRN" --dimensions "$DIMS_ARG" --format json \ + > "$NP_CACHE_DIR/iam.json" 2>"$NP_CACHE_DIR/iam.err"; \ + printf '%s' "$(timer_elapsed "$_t")" > "$NP_CACHE_DIR/timings.iam" ) & + else + ( _t=$(timer_now); \ + np provider list --categories identity-access-control --nrn "$NRN" --format json \ + > "$NP_CACHE_DIR/iam.json" 2>"$NP_CACHE_DIR/iam.err"; \ + printf '%s' "$(timer_elapsed "$_t")" > "$NP_CACHE_DIR/timings.iam" ) & + fi +fi + +wait +log debug "⏱ Prefetch wave 1 $(timer_elapsed "$T_PREFETCH")" +# Per-call wall times so the long pole stands out: +for tfile in "$NP_CACHE_DIR"/timings.*; do + [ -f "$tfile" ] || continue + log debug " ↳ $(basename "$tfile" | sed 's/^timings\.//') $(cat "$tfile")" +done diff --git a/parameters/workflows/delete.yaml b/parameters/workflows/delete.yaml new file mode 100644 index 00000000..1d946640 --- /dev/null +++ b/parameters/workflows/delete.yaml @@ -0,0 +1,15 @@ +configuration: + ACTION: delete +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/utils/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: EXTERNAL_ID_PATH, type: environment } + - name: delete + type: script + file: "$SERVICE_PATH/utils/dispatch" diff --git a/parameters/workflows/notify.yaml b/parameters/workflows/notify.yaml new file mode 100644 index 00000000..ae9aff44 --- /dev/null +++ b/parameters/workflows/notify.yaml @@ -0,0 +1,16 @@ +configuration: + ACTION: notify +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/utils/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: EXTERNAL_ID_PATH, type: environment } + - { name: PARAMETER_ID, type: environment } + - name: notify + type: script + file: "$SERVICE_PATH/utils/dispatch" diff --git a/parameters/workflows/retrieve.yaml b/parameters/workflows/retrieve.yaml new file mode 100644 index 00000000..6c326e22 --- /dev/null +++ b/parameters/workflows/retrieve.yaml @@ -0,0 +1,18 @@ +configuration: + ACTION: retrieve +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/utils/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: EXTERNAL_ID_PATH, type: environment } + - { name: EXTERNAL_ID_VERSION, type: environment } + - { name: PARAMETER_NAME, type: environment } + - { name: PARAMETER_ENCODING, type: environment } + - name: retrieve + type: script + file: "$SERVICE_PATH/utils/dispatch" diff --git a/parameters/workflows/store.yaml b/parameters/workflows/store.yaml new file mode 100644 index 00000000..7cc73035 --- /dev/null +++ b/parameters/workflows/store.yaml @@ -0,0 +1,20 @@ +configuration: + ACTION: store +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/utils/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: EXTERNAL_ID_PATH, type: environment } + - { name: EXTERNAL_ID_VERSION, type: environment } + - { name: PARAMETER_ID, type: environment } + - { name: PARAMETER_VALUE, type: environment } + - { name: PARAMETER_NAME, type: environment } + - { name: PARAMETER_ENCODING, type: environment } + - name: store + type: script + file: "$SERVICE_PATH/utils/dispatch"