diff --git a/CHANGELOG.md b/CHANGELOG.md index 31331b7..3e06345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Add support to manage (store, retrieve and delete) parameters from HashiCorp Vault. ### Changed diff --git a/parameters/providers/hashicorp-vault/README.md b/parameters/providers/hashicorp-vault/README.md new file mode 100644 index 0000000..0b63413 --- /dev/null +++ b/parameters/providers/hashicorp-vault/README.md @@ -0,0 +1,140 @@ +# HashiCorp Vault parameters provider + +Stores nullplatform parameter values in HashiCorp Vault KV v2, using Vault's +native versioning. See [`docs/architecture.md`](./docs/architecture.md) for the +full lifecycle, storage layout, and versioning model. + +## Configuration + +Provider config (from the nullplatform provider specification): + +| Field | Required | Description | +|------------------------|---------------------|-----------------------------------------------------------------------------| +| `setup.address` | yes | Vault HTTP(S) endpoint, e.g. `https://vault.example.com:8200`. | +| `setup.namespace` | no (default `secret/data/nullplatform`) | KV v2 mount + path prefix parameters are stored under. | +| `setup.auth_mode` | yes (default `userpass`) | Authentication mode: `userpass` or `kubernetes`. | +| `setup.kubernetes_role`| when `kubernetes` | Vault Kubernetes auth role bound to the agent's ServiceAccount. | + +The KV path prefix defaults to `secret/data/nullplatform` and is configurable via +`setup.namespace` (or the `VAULT_PATH_PREFIX` env var). It must include the KV v2 +`data/` segment. The auth mounts are fixed to Vault's defaults (`auth/userpass`, +`auth/kubernetes`). + +> **Note:** the Vault policy examples below grant access to the default +> `secret/data/nullplatform/*` (and `secret/metadata/nullplatform/*`) paths. If you +> set a custom `setup.namespace`, adjust the policy paths to match it — the KV mount +> and the `metadata/` counterpart of your configured `data/` path. + +## Authentication + +`setup` exchanges the configured credentials/identity for a short-lived Vault +client token and exports it as `VAULT_TOKEN`; `store` / `retrieve` / `delete` then +use it via the `X-Vault-Token` header. + +### Mode: `userpass` + +The agent logs in with a username and password. Both are **sensitive** and are +read from environment variables in the agent runtime — never from provider config: + +| Env var | Description | +|------------------|-----------------------------------| +| `VAULT_USERNAME` | Vault `userpass` username. | +| `VAULT_PASSWORD` | Vault `userpass` password. | + +Vault setup: + +```sh +# Enable userpass (once per Vault) +vault auth enable userpass + +# Policy granting read/write on the nullplatform namespace +vault policy write nullplatform-parameters - <<'EOF' +path "secret/data/nullplatform/*" { capabilities = ["create", "update", "read"] } +path "secret/metadata/nullplatform/*" { capabilities = ["read", "delete", "list"] } +EOF + +# Create the user and bind the policy +vault write auth/userpass/users/ \ + password="" \ + token_policies="nullplatform-parameters" +``` + +Set `VAULT_USERNAME` / `VAULT_PASSWORD` in the agent Helm installation (as secret +env vars). + +### Mode: `kubernetes` + +The agent authenticates with its **Kubernetes ServiceAccount identity** — there +are no secrets to configure. It reads the projected ServiceAccount token (default +path `/var/run/secrets/kubernetes.io/serviceaccount/token`, overridable via +`VAULT_K8S_JWT_PATH`) and exchanges it for a Vault token bound to `kubernetes_role`. + +#### 1. Vault setup + +```sh +# Enable the kubernetes auth method (once per Vault) +vault auth enable kubernetes + +# Point Vault at the cluster's API server. When Vault runs inside the cluster it +# can use its own ServiceAccount token and the in-cluster CA: +vault write auth/kubernetes/config \ + kubernetes_host="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" \ + kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \ + token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token + +# Policy granting read/write on the nullplatform namespace +vault policy write nullplatform-parameters - <<'EOF' +path "secret/data/nullplatform/*" { capabilities = ["create", "update", "read"] } +path "secret/metadata/nullplatform/*" { capabilities = ["read", "delete", "list"] } +EOF + +# Role binding the agent's ServiceAccount (name + namespace) to the policy. +# The role name here must match setup.kubernetes_role. +vault write auth/kubernetes/role/nullplatform-agent \ + bound_service_account_names="" \ + bound_service_account_namespaces="" \ + token_policies="nullplatform-parameters" \ + ttl="1h" +``` + +#### 2. Cluster setup + +The in-cluster resources Vault's kubernetes auth needs are provisioned by the +Terraform module in [`specs/requirements/`](./specs/requirements/) — it applies +`.yaml` templates for the agent ServiceAccount and the token-reviewer +ServiceAccount + its `system:auth-delegator` ClusterRoleBinding: + +```sh +cd specs/requirements +cp terraform.tfvars.example terraform.tfvars # edit agent namespace + SA name +tofu init && tofu apply +``` + +> The manifests are applied with `kubernetes_manifest`, which reaches the cluster +> API at **plan** time (server-side dry-run), not just apply. Make sure the target +> cluster is reachable and pin the context with `kube_config_context` — this module +> creates a cluster-wide `ClusterRoleBinding`. + +Its outputs (`agent_service_account`, `token_reviewer_service_account`) give the +names/namespaces to plug into the Vault role bindings and the `token_reviewer_jwt` +above. The agent pod must run with that ServiceAccount and mount its token +(`serviceAccountName: `, `automountServiceAccountToken: true`). + +Then set `setup.auth_mode = kubernetes` and `setup.kubernetes_role = nullplatform-agent` +in the provider config (see [`specs/install/`](./specs/install/)). No credential +env vars are needed in this mode. + +## Installation + +The provider specification and per-instance configs are installed with the +Terraform in [`specs/install/`](./specs/install/), which builds on the shared +`parameter_storage_definition` / `parameter_storage_configuration` modules +(same structure as the AWS providers). For the `kubernetes` auth mode, apply +[`specs/requirements/`](./specs/requirements/) first (see above). + +## Troubleshooting + +`setup` fails fast with actionable messages when the address or credentials are +missing, and when the login endpoint returns no token (wrong credentials, role +not bound to the ServiceAccount, or the auth method not enabled). Re-read the +error output — it names the exact env var / config field / Vault command to check. diff --git a/parameters/providers/hashicorp-vault/delete b/parameters/providers/hashicorp-vault/delete new file mode 100755 index 0000000..0de37da --- /dev/null +++ b/parameters/providers/hashicorp-vault/delete @@ -0,0 +1,57 @@ +#!/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 + +# EXTERNAL_ID_PATH is the full, self-contained Vault path (the KV prefix was +# embedded by store), so it is used verbatim — never recomposed against the +# current VAULT_PATH_PREFIX. This keeps references valid across setup.namespace +# changes. +VAULT_PATH="$EXTERNAL_ID_PATH" + +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 + +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 0000000..c49ee6b --- /dev/null +++ b/parameters/providers/hashicorp-vault/docs/architecture.md @@ -0,0 +1,113 @@ +# 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_AUTH_MODE` (default `userpass`) and the mode's inputs, plus `VAULT_PATH_PREFIX` (default `secret/data/nullplatform`). Authenticates against Vault and exports the derived short-lived token as `VAULT_TOKEN`. Fails fast if address or credentials/identity are missing. | +| `store` | Composes the canonical path via `build_external_id` and prepends `VAULT_PATH_PREFIX`, so the `external_id` is the full, self-contained Vault path. POSTs to `$VAULT_ADDR/v1/` with a JSON payload. Captures the new version number from Vault's response. Returns `external_id = /#`. | +| `retrieve` | Splits `EXTERNAL_ID` into path + version. GETs `$VAULT_ADDR/v1/?version=` using the path **verbatim** (the KV prefix is already embedded — never recomposed against the current `VAULT_PATH_PREFIX`, so a later `setup.namespace` change can't orphan the reference). 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` is the full Vault path (KV prefix included) +plus the version: + +``` +/# +``` + +Embedding the prefix makes the `external_id` self-contained: `retrieve`/`delete` +resolve it verbatim, so reconfiguring `setup.namespace` never orphans previously +stored references. + +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: + +``` +secret/data/nullplatform/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": "secret/data/nullplatform/organization=acme-1255165411/.../DB_PASSWORD-42" + } +} +``` + +The `data` wrapper is KV v2's API requirement; the inner object is our envelope. + +--- + +## Authentication + +`setup` authenticates against Vault and exchanges the credentials/identity for a +short-lived client token, which it exports as `VAULT_TOKEN`. `store`, `retrieve` +and `delete` are auth-agnostic — they just send that token in the `X-Vault-Token` +header. Two modes are selectable via `.setup.auth_mode` (or `VAULT_AUTH_MODE`): + +| Mode | Inputs | Login endpoint | +|--------------|-----------------------------------------------------------------------|---------------------------------------| +| `userpass` | `VAULT_USERNAME`, `VAULT_PASSWORD` (env only — the password is sensitive) | `POST /v1/auth/userpass/login/` | +| `kubernetes` | `.setup.kubernetes_role` + the pod's ServiceAccount JWT (mounted file) | `POST /v1/auth/kubernetes/login` | + +The authenticated identity (userpass user or the Vault Kubernetes role) must have +a policy granting read/write on the configured `VAULT_PATH_PREFIX` namespace. + +The auth mounts are hardcoded to Vault's defaults (`auth/userpass`, +`auth/kubernetes`). Because the agent re-authenticates on every run, each token is +short-lived and scoped to the identity's policy — credential/role lifecycle +management (rotating passwords, binding the ServiceAccount) is the operator's +responsibility. See the provider [`README.md`](../README.md) for the required +Vault + cluster setup per mode. diff --git a/parameters/providers/hashicorp-vault/retrieve b/parameters/providers/hashicorp-vault/retrieve new file mode 100755 index 0000000..1cb7e82 --- /dev/null +++ b/parameters/providers/hashicorp-vault/retrieve @@ -0,0 +1,72 @@ +#!/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 + +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context. The path is +# the full, self-contained Vault path (the KV prefix was embedded by store), so it +# is used verbatim — never recomposed against the current VAULT_PATH_PREFIX. This +# keeps references valid across setup.namespace changes. +VAULT_PATH="$EXTERNAL_ID_PATH" + +URL="$VAULT_ADDR/v1/$VAULT_PATH" +if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + URL="${URL}?version=${EXTERNAL_ID_VERSION}" +fi + +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 + +HTTP_STATUS="${RESPONSE##*$'\n'}" +HTTP_BODY="${RESPONSE%$'\n'*}" + +case "$HTTP_STATUS" in + 2*) + STORED_VALUE=$(echo "$HTTP_BODY" | jq -r '.data.data.value // empty') + jq -n --arg value "$STORED_VALUE" '{value: $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 0000000..1534134 --- /dev/null +++ b/parameters/providers/hashicorp-vault/setup @@ -0,0 +1,167 @@ +#!/bin/bash +set -euo pipefail + +# Validates HashiCorp Vault connection config and authenticates. +# +# Two authentication modes (selected via .setup.auth_mode / VAULT_AUTH_MODE): +# +# userpass Username/password (Vault "userpass" auth). Credentials are +# sensitive and come from env only (VAULT_USERNAME, VAULT_PASSWORD) +# — never from provider config. +# kubernetes The pod's ServiceAccount identity (Vault "kubernetes" auth). No +# secrets to configure: the SA JWT is read from the projected token +# file and exchanged for a Vault token bound to a Vault role. +# +# Either way setup exchanges the credentials/identity for a short-lived Vault +# client token via the login endpoint and exports it as VAULT_TOKEN, so +# store/retrieve/delete keep using the X-Vault-Token header with no knowledge of +# how the token was obtained. +# +# The KV path prefix defaults to `secret/data/nullplatform` but is operator- +# configurable via .setup.namespace (or the VAULT_PATH_PREFIX env var) — it is the +# KV v2 mount + path prefix, not a Vault Enterprise namespace. Auth mounts are +# hardcoded to Vault's defaults (auth/userpass, auth/kubernetes). VAULT_ADDR is +# operator-configurable. +# +# Exports: VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.setup.address') +VAULT_AUTH_MODE=$(get_config_value --env VAULT_AUTH_MODE --provider '.setup.auth_mode' --default "userpass") +VAULT_PATH_PREFIX=$(get_config_value --env VAULT_PATH_PREFIX --provider '.setup.namespace' --default "secret/data/nullplatform") +# Trim a trailing slash so store/retrieve/delete don't compose a double slash. +VAULT_PATH_PREFIX="${VAULT_PATH_PREFIX%/}" + +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 + +# Build the login request (URL + JSON payload) for the selected auth mode. +case "$VAULT_AUTH_MODE" in + userpass) + VAULT_USERNAME=$(get_config_value --env VAULT_USERNAME) + VAULT_PASSWORD=$(get_config_value --env VAULT_PASSWORD) + + if [ -z "$VAULT_USERNAME" ]; then + log error "❌ Vault username not configured" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_USERNAME env var is not set in the agent runtime" + log error "" + log error "🔧 How to fix:" + log error " • Set VAULT_USERNAME= in the agent environment" + exit 1 + fi + + if [ -z "$VAULT_PASSWORD" ]; then + log error "❌ Vault password not configured" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_PASSWORD env var is not set in the agent runtime" + log error "" + log error "🔧 How to fix:" + log error " • Set VAULT_PASSWORD= in the agent environment" + log error " • The password is sensitive — set via env, never via provider config" + exit 1 + fi + + # URL-encode the username — it is interpolated as a path segment of the login URL. + VAULT_USERNAME_ENC=$(jq -rn --arg u "$VAULT_USERNAME" '$u | @uri') + LOGIN_URL="$VAULT_ADDR/v1/auth/userpass/login/$VAULT_USERNAME_ENC" + LOGIN_PAYLOAD=$(jq -nc --arg password "$VAULT_PASSWORD" '{password: $password}') + ;; + + kubernetes) + VAULT_K8S_ROLE=$(get_config_value --env VAULT_K8S_ROLE --provider '.setup.kubernetes_role') + VAULT_K8S_JWT_PATH=$(get_config_value --env VAULT_K8S_JWT_PATH \ + --default "/var/run/secrets/kubernetes.io/serviceaccount/token") + + if [ -z "$VAULT_K8S_ROLE" ]; then + log error "❌ Vault Kubernetes role not configured" + log error "" + log error "💡 Possible causes:" + log error " • .setup.kubernetes_role is missing in the provider config" + log error " • VAULT_K8S_ROLE env var is not set in the agent runtime" + log error "" + log error "🔧 How to fix:" + log error " • Set .setup.kubernetes_role to the Vault role bound to the agent's ServiceAccount" + log error " • See the provider README for the required Vault + cluster setup" + exit 1 + fi + + if [ ! -r "$VAULT_K8S_JWT_PATH" ]; then + log error "❌ Kubernetes ServiceAccount token not readable at $VAULT_K8S_JWT_PATH" + log error "" + log error "💡 Possible causes:" + log error " • The agent pod has automountServiceAccountToken disabled" + log error " • A custom projected-token path is used but VAULT_K8S_JWT_PATH is not set" + log error "" + log error "🔧 How to fix:" + log error " • Ensure the pod mounts its ServiceAccount token" + log error " • Or set VAULT_K8S_JWT_PATH to the projected token file" + exit 1 + fi + + VAULT_K8S_JWT=$(<"$VAULT_K8S_JWT_PATH") + LOGIN_URL="$VAULT_ADDR/v1/auth/kubernetes/login" + LOGIN_PAYLOAD=$(jq -nc --arg role "$VAULT_K8S_ROLE" --arg jwt "$VAULT_K8S_JWT" '{role: $role, jwt: $jwt}') + ;; + + *) + log error "❌ Unknown Vault authentication mode '$VAULT_AUTH_MODE'" + log error "" + log error "💡 Possible causes:" + log error " • .setup.auth_mode (or VAULT_AUTH_MODE) has an unsupported value" + log error "" + log error "🔧 How to fix:" + log error " • Set auth_mode to one of: userpass, kubernetes" + exit 1 + ;; +esac + +# Exchange credentials/identity for a short-lived client token. The payload +# carries the password / ServiceAccount JWT, so it is sent via stdin (--data @-) +# rather than argv, keeping the secret out of the process table (ps / /proc). +if ! LOGIN_RESPONSE=$(curl -s -X POST "$LOGIN_URL" --data @- <<<"$LOGIN_PAYLOAD"); then + log error "❌ Failed to authenticate to Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Network unreachable to $VAULT_ADDR" + 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 + +# Guard the parse: a non-JSON body (proxy/LB error page, sealed Vault) must fall +# through to the troubleshooting block below, not abort under `set -o pipefail`. +VAULT_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.auth.client_token // empty' 2>/dev/null || true) + +if [ -z "$VAULT_TOKEN" ]; then + log error "❌ Vault $VAULT_AUTH_MODE login did not return a token" + log error "" + log error "💡 Possible causes:" + if [ "$VAULT_AUTH_MODE" = "userpass" ]; then + log error " • Wrong VAULT_USERNAME / VAULT_PASSWORD" + log error " • The userpass auth method is not enabled (vault auth enable userpass)" + else + log error " • The Vault role '$VAULT_K8S_ROLE' does not exist or does not bind this pod's ServiceAccount" + log error " • The kubernetes auth method is not enabled or configured (vault auth enable kubernetes)" + fi + log error "" + log error "🔧 How to fix:" + log error " • See the provider README for the required Vault + cluster setup" + log error "Vault error: $(echo "$LOGIN_RESPONSE" | jq -r '(.errors // ["unknown error"]) | join("; ")' 2>/dev/null || echo "unknown error")" + exit 1 +fi + +export VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX diff --git a/parameters/providers/hashicorp-vault/specs/install/hashicorp-vault-configuration.json.tpl b/parameters/providers/hashicorp-vault/specs/install/hashicorp-vault-configuration.json.tpl new file mode 100644 index 0000000..6559e8b --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/install/hashicorp-vault-configuration.json.tpl @@ -0,0 +1,143 @@ +{ + "name": "HashiCorp Vault", + "description": "Stores nullplatform parameter values in HashiCorp Vault KV v2 with native versioning", + "slug": "hashicorp-vault", + "category": "parameters-storage", + "icon": "simple-icons: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", "auth_mode"], + "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 + }, + "namespace": { + "type": "string", + "title": "KV path prefix", + "description": "Vault KV v2 mount + path prefix under which parameters are stored. Must include the KV v2 `data/` segment (e.g. secret/data/nullplatform). This is a KV path prefix, not a Vault Enterprise namespace.", + "order": 2, + "default": "secret/data/nullplatform" + }, + "auth_mode": { + "type": "string", + "title": "Authentication mode", + "description": "How the nullplatform agent authenticates to Vault.", + "order": 3, + "default": "userpass", + "oneOf": [ + { "const": "userpass", "title": "Username and password" }, + { "const": "kubernetes", "title": "Kubernetes (pod identity)" } + ] + }, + "kubernetes_role": { + "type": "string", + "title": "Vault Kubernetes role", + "description": "Name of the Vault Kubernetes auth role bound to the agent's ServiceAccount. Required when the authentication mode is Kubernetes.", + "order": 4 + } + }, + "allOf": [ + { + "if": { "properties": { "auth_mode": { "const": "kubernetes" } } }, + "then": { "required": ["kubernetes_role"] } + } + ] + } + }, + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/sensibility/properties/applies_to" + }, + { + "type": "Control", + "scope": "#/properties/setup/properties/address" + }, + { + "type": "Control", + "scope": "#/properties/setup/properties/namespace" + }, + { + "type": "Control", + "scope": "#/properties/setup/properties/auth_mode", + "options": { "format": "radio-cards" } + }, + { + "type": "Label", + "text": "> **ℹ️ Username / password authentication**\n\nCredentials are **not** stored in this configuration — the password is sensitive, so both values are read from environment variables in the nullplatform agent runtime:\n\n- **`VAULT_USERNAME`** — the Vault `userpass` username\n- **`VAULT_PASSWORD`** — the user's password (sensitive)\n\nSet both as environment variables in your agent Helm installation. The agent exchanges them for a short-lived Vault token on every run. The user must have a Vault policy granting read/write on `secret/data/nullplatform/*`. See the provider README for the full Vault setup.", + "options": { "format": "markdown" }, + "rule": { + "effect": "HIDE", + "condition": { + "scope": "#/properties/setup/properties/auth_mode", + "schema": { "not": { "const": "userpass" } } + } + } + }, + { + "type": "Control", + "scope": "#/properties/setup/properties/kubernetes_role", + "rule": { + "effect": "HIDE", + "condition": { + "scope": "#/properties/setup/properties/auth_mode", + "schema": { "not": { "const": "kubernetes" } } + } + } + }, + { + "type": "Label", + "text": "> **ℹ️ Kubernetes authentication (pod identity)**\n\nThe agent authenticates with its **Kubernetes ServiceAccount identity** — no secrets to configure. It reads the projected ServiceAccount token and exchanges it for a Vault token bound to the **Vault role** above.\n\nThis requires one-time setup on both Vault and the cluster:\n\n- Enable and configure the `kubernetes` auth method on Vault (`vault auth enable kubernetes`)\n- Create a Vault policy granting read/write on `secret/data/nullplatform/*`\n- Create a Vault role that binds the agent's ServiceAccount (name + namespace) to that policy\n\nProvision the cluster-side resources with the `specs/requirements/` module. See the provider README for the exact Vault + cluster commands.", + "options": { "format": "markdown" }, + "rule": { + "effect": "HIDE", + "condition": { + "scope": "#/properties/setup/properties/auth_mode", + "schema": { "not": { "const": "kubernetes" } } + } + } + } + ] + } + } +} diff --git a/parameters/providers/hashicorp-vault/specs/install/locals.tf b/parameters/providers/hashicorp-vault/specs/install/locals.tf new file mode 100644 index 0000000..0f3f042 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/install/locals.tf @@ -0,0 +1,24 @@ +locals { + # Attributes are passed straight to the parameter_storage_configuration module, + # but null-valued optional keys are dropped: the spec schema types them as + # strings (with their own defaults), so emitting `null` would fail server-side + # validation. Each key is only included when set — kubernetes_role when + # auth_mode = "kubernetes", and namespace when overriding the default KV prefix. + instance_attributes = { + for key, instance in var.instances : key => { + sensibility = instance.attributes.sensibility + setup = merge( + { + address = instance.attributes.setup.address + auth_mode = instance.attributes.setup.auth_mode + }, + instance.attributes.setup.namespace == null ? {} : { + namespace = instance.attributes.setup.namespace + }, + instance.attributes.setup.kubernetes_role == null ? {} : { + kubernetes_role = instance.attributes.setup.kubernetes_role + } + ) + } + } +} diff --git a/parameters/providers/hashicorp-vault/specs/install/main.tf b/parameters/providers/hashicorp-vault/specs/install/main.tf new file mode 100644 index 0000000..445d690 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/install/main.tf @@ -0,0 +1,45 @@ +module "hashicorp_vault_spec" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/parameter_storage_definition?ref=v6.3.0" + + nrn = var.nrn + np_api_key = var.np_api_key + extra_visible_to_nrns = var.extra_visible_to_nrns + template_path = var.template_path + repository_parameter_storage_spec_branch = var.repository_parameter_storage_spec_branch + repository_parameter_storage_spec = var.repository_parameter_storage_spec +} + +module "hashicorp_vault_api_keys" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/api_key?ref=v6.3.0" + for_each = { for key, instance in var.instances : key => instance if instance.enable_notification_channel } + + type = "agent" + nrn = each.value.nrn + specification_slug = "parameter_storage" +} + +module "hashicorp_vault_configuration" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/parameter_storage_configuration?ref=v6.3.0" + + for_each = var.instances + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = module.hashicorp_vault_spec.slug + dimensions = each.value.dimensions + attributes = local.instance_attributes[each.key] + + depends_on = [module.hashicorp_vault_spec] +} + +module "hashicorp_vault_channels" { + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/parameter_storage_definition_agent_association?ref=v6.3.0" + + for_each = { for key, instance in var.instances : key => instance if instance.enable_notification_channel } + + nrn = each.value.nrn + api_key = module.hashicorp_vault_api_keys[each.key].api_key + tags_selectors = each.value.tags_selectors + + depends_on = [module.hashicorp_vault_spec] +} diff --git a/parameters/providers/hashicorp-vault/specs/install/outputs.tf b/parameters/providers/hashicorp-vault/specs/install/outputs.tf new file mode 100644 index 0000000..ab32bb1 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/install/outputs.tf @@ -0,0 +1,24 @@ +output "storage_configuration" { + description = "Provider specification ID/slug and the per-instance provider configs (id, nrn, dimensions), keyed by instance key." + value = { + specification_id = module.hashicorp_vault_spec.specification_id + slug = module.hashicorp_vault_spec.slug + instances = { + for key, instance in var.instances : key => { + id = module.hashicorp_vault_configuration[key].provider_config_id + nrn = instance.nrn + dimensions = instance.dimensions + } + } + } +} + +output "notification_channels" { + description = "Per-instance agent notification channels created (enable_notification_channel=true), keyed by instance key: id, nrn." + value = { + for key, instance in var.instances : key => { + id = module.hashicorp_vault_channels[key].notification_channel_id + nrn = instance.nrn + } if instance.enable_notification_channel + } +} diff --git a/parameters/providers/hashicorp-vault/specs/install/terraform.tfvars.example b/parameters/providers/hashicorp-vault/specs/install/terraform.tfvars.example new file mode 100644 index 0000000..5e0d9ef --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/install/terraform.tfvars.example @@ -0,0 +1,50 @@ +nrn = "organization=acme-1255165411:account=prod-95118862" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +# Optional overrides (defaults defined in variables.tf): +# template_path = "parameters/providers/hashicorp-vault/specs/install/hashicorp-vault-configuration.json.tpl" +# repository_parameter_storage_spec_branch = "main" +# repository_parameter_storage_spec = "https://raw.githubusercontent.com/nullplatform/parameters-provider/refs/heads" + +instances = { + prod-billing = { + nrn = "organization=acme-1255165411:account=prod-95118862:namespace=billing-37094320" + dimensions = { environment = "production" } + enable_notification_channel = true # create the agent API key + notification channel (default false) + tags_selectors = { environment = "production" } + attributes = { + sensibility = { + applies_to = ["secret", "non_secret"] + } + setup = { + address = "https://vault.prod.example.com:8200" + # namespace is the KV v2 mount + path prefix; it defaults to "secret/data/nullplatform" + # when omitted. Override it to store parameters under a different KV path — keep the + # KV v2 "data/" segment (e.g. "secret/data/acme-billing"): + # namespace = "secret/data/acme-billing" + # auth_mode defaults to "userpass": the agent reads VAULT_USERNAME / VAULT_PASSWORD from env. + } + } + } + staging-billing = { + nrn = "organization=acme-1255165411:account=staging-95118863:namespace=billing-37094320" + dimensions = { environment = "staging" } + attributes = { + sensibility = { + applies_to = ["secret", "non_secret"] + } + setup = { + address = "https://vault.staging.example.com:8200" + # Custom KV path prefix — parameters are stored under secret/data/acme-staging/... + namespace = "secret/data/acme-staging" + # Kubernetes auth: the agent uses its pod ServiceAccount identity — no credentials in env. + # kubernetes_role must match the Vault role bound to the agent's ServiceAccount. + # Provision the cluster-side resources with the requirements/ module. + auth_mode = "kubernetes" + kubernetes_role = "nullplatform-agent" + } + } + } +} diff --git a/parameters/providers/hashicorp-vault/specs/install/variables.tf b/parameters/providers/hashicorp-vault/specs/install/variables.tf new file mode 100644 index 0000000..6f2aec1 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/install/variables.tf @@ -0,0 +1,87 @@ +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 parameter_storage modules to register the provider spec and 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 "template_path" { + description = "Path to the provider specification configuration template used by the parameter_storage_definition module." + type = string + default = "parameters/providers/hashicorp-vault/specs/install/hashicorp-vault-configuration.json.tpl" +} + +variable "repository_parameter_storage_spec_branch" { + description = "Branch of the parameters repository from which the parameter storage spec is fetched." + type = string + default = "main" +} + +variable "repository_parameter_storage_spec" { + description = "Base raw URL of the parameters repository hosting the parameter storage spec." + type = string + default = "https://raw.githubusercontent.com/nullplatform/parameters-provider/refs/heads" +} + +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, and a provider-specific `attributes` + object shaped to match the hashicorp-vault provider specification schema. + Instances with enable_notification_channel=true also get their own agent API key + + notification channel (anchored at the instance NRN). Fields: + attributes — provider config matching the spec schema: + sensibility.applies_to plus setup.address / setup.namespace / + setup.auth_mode / setup.kubernetes_role. namespace is the KV v2 + mount + path prefix; leave it null to use the default + (secret/data/nullplatform). auth_mode is "userpass" (default) or + "kubernetes"; kubernetes_role is only used (and required) when + auth_mode = "kubernetes" — leave it null for userpass. + enable_notification_channel — create the agent API key + notification channel (default false). + tags_selectors — tags the agent uses to select/filter this channel against scope + tags (e.g. { environment = "production" }); default {}. + EOT + type = map(object({ + nrn = string + dimensions = map(string) + enable_notification_channel = optional(bool, false) + tags_selectors = optional(map(string), {}) + attributes = object({ + sensibility = object({ + applies_to = list(string) + }) + setup = object({ + address = string + namespace = optional(string) + auth_mode = optional(string, "userpass") + kubernetes_role = optional(string) + }) + }) + })) + default = {} + + validation { + condition = alltrue([ + for _, instance in var.instances : contains(["userpass", "kubernetes"], instance.attributes.setup.auth_mode) + ]) + error_message = "attributes.setup.auth_mode must be \"userpass\" or \"kubernetes\"." + } + + validation { + condition = alltrue([ + for _, instance in var.instances : + instance.attributes.setup.auth_mode != "kubernetes" || try(instance.attributes.setup.kubernetes_role, null) != null + ]) + error_message = "attributes.setup.kubernetes_role is required when attributes.setup.auth_mode = \"kubernetes\"." + } +} diff --git a/parameters/providers/hashicorp-vault/specs/install/versions.tf b/parameters/providers/hashicorp-vault/specs/install/versions.tf new file mode 100644 index 0000000..5a677bc --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/install/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/specs/requirements/locals.tf b/parameters/providers/hashicorp-vault/specs/requirements/locals.tf new file mode 100644 index 0000000..16d5c63 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/locals.tf @@ -0,0 +1,8 @@ +locals { + enabled = var.kubernetes_auth.enable + + # The token reviewer defaults to living alongside the agent unless overridden. + token_reviewer_namespace = coalesce(var.kubernetes_auth.token_reviewer_namespace, var.kubernetes_auth.agent_namespace) + + create_agent_sa = local.enabled && var.kubernetes_auth.create_agent_service_account +} diff --git a/parameters/providers/hashicorp-vault/specs/requirements/main.tf b/parameters/providers/hashicorp-vault/specs/requirements/main.tf new file mode 100644 index 0000000..3539896 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/main.tf @@ -0,0 +1,42 @@ +# All cluster resources are declared as .yaml templates under ./templates and +# applied via kubernetes_manifest so the manifests stay readable and reviewable +# as plain Kubernetes YAML. +# +# NOTE: kubernetes_manifest performs a server-side dry-run at PLAN time, so the +# target cluster must be reachable when running `tofu plan` (not just apply). +# Pin the target with kube_config_context to avoid touching the wrong cluster — +# this module creates a cluster-wide RBAC binding. + +provider "kubernetes" { + config_path = var.kube_config_path + config_context = var.kube_config_context +} + +resource "kubernetes_manifest" "agent_service_account" { + count = local.create_agent_sa ? 1 : 0 + + manifest = yamldecode(templatefile("${path.module}/templates/agent-service-account.yaml", { + name = var.kubernetes_auth.agent_service_account_name + namespace = var.kubernetes_auth.agent_namespace + })) +} + +resource "kubernetes_manifest" "token_reviewer_service_account" { + count = local.enabled ? 1 : 0 + + manifest = yamldecode(templatefile("${path.module}/templates/token-reviewer-service-account.yaml", { + name = var.kubernetes_auth.token_reviewer_service_account_name + namespace = local.token_reviewer_namespace + })) +} + +resource "kubernetes_manifest" "token_reviewer_binding" { + count = local.enabled ? 1 : 0 + + manifest = yamldecode(templatefile("${path.module}/templates/token-reviewer-cluster-role-binding.yaml", { + name = var.kubernetes_auth.token_reviewer_service_account_name + namespace = local.token_reviewer_namespace + })) + + depends_on = [kubernetes_manifest.token_reviewer_service_account] +} diff --git a/parameters/providers/hashicorp-vault/specs/requirements/outputs.tf b/parameters/providers/hashicorp-vault/specs/requirements/outputs.tf new file mode 100644 index 0000000..c5e8e32 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/outputs.tf @@ -0,0 +1,15 @@ +output "agent_service_account" { + description = "Name/namespace of the agent ServiceAccount to bind in the Vault role (bound_service_account_names / bound_service_account_namespaces). Null when disabled." + value = local.enabled ? { + name = var.kubernetes_auth.agent_service_account_name + namespace = var.kubernetes_auth.agent_namespace + } : null +} + +output "token_reviewer_service_account" { + description = "Name/namespace of the token-reviewer ServiceAccount. Use its token as token_reviewer_jwt in `vault write auth/kubernetes/config`. Null when disabled." + value = local.enabled ? { + name = var.kubernetes_auth.token_reviewer_service_account_name + namespace = local.token_reviewer_namespace + } : null +} diff --git a/parameters/providers/hashicorp-vault/specs/requirements/templates/agent-service-account.yaml b/parameters/providers/hashicorp-vault/specs/requirements/templates/agent-service-account.yaml new file mode 100644 index 0000000..b034ab3 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/templates/agent-service-account.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ${name} + namespace: ${namespace} + labels: + app.kubernetes.io/managed-by: nullplatform + app.kubernetes.io/part-of: hashicorp-vault-parameters-provider diff --git a/parameters/providers/hashicorp-vault/specs/requirements/templates/token-reviewer-cluster-role-binding.yaml b/parameters/providers/hashicorp-vault/specs/requirements/templates/token-reviewer-cluster-role-binding.yaml new file mode 100644 index 0000000..62e7c5b --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/templates/token-reviewer-cluster-role-binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: ${name}-tokenreview-binding + labels: + app.kubernetes.io/managed-by: nullplatform + app.kubernetes.io/part-of: hashicorp-vault-parameters-provider +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: ${name} + namespace: ${namespace} diff --git a/parameters/providers/hashicorp-vault/specs/requirements/templates/token-reviewer-service-account.yaml b/parameters/providers/hashicorp-vault/specs/requirements/templates/token-reviewer-service-account.yaml new file mode 100644 index 0000000..b034ab3 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/templates/token-reviewer-service-account.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ${name} + namespace: ${namespace} + labels: + app.kubernetes.io/managed-by: nullplatform + app.kubernetes.io/part-of: hashicorp-vault-parameters-provider diff --git a/parameters/providers/hashicorp-vault/specs/requirements/terraform.tfvars.example b/parameters/providers/hashicorp-vault/specs/requirements/terraform.tfvars.example new file mode 100644 index 0000000..16b71a7 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/terraform.tfvars.example @@ -0,0 +1,18 @@ +# Pin the target cluster so the cluster-wide RBAC binding isn't applied to the +# wrong context (kubernetes_manifest reaches the cluster at plan time): +# kube_config_context = "my-prod-cluster" +# kube_config_path = "~/.kube/config" + +kubernetes_auth = { + enable = true + agent_namespace = "nullplatform" + agent_service_account_name = "nullplatform-agent" + + # Token reviewer defaults: name "vault-auth", namespace = agent_namespace. + # Override if Vault reviews tokens with a ServiceAccount in another namespace: + # token_reviewer_service_account_name = "vault-auth" + # token_reviewer_namespace = "vault" + + # Set false if the agent ServiceAccount is already managed by its Helm chart: + # create_agent_service_account = false +} diff --git a/parameters/providers/hashicorp-vault/specs/requirements/variables.tf b/parameters/providers/hashicorp-vault/specs/requirements/variables.tf new file mode 100644 index 0000000..0659590 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/variables.tf @@ -0,0 +1,51 @@ +variable "kube_config_path" { + description = "Path to the kubeconfig used to apply the cluster resources. Null uses the provider's default discovery (KUBE_CONFIG_PATH / ~/.kube/config)." + type = string + default = null +} + +variable "kube_config_context" { + description = "kubeconfig context to target. Set this to avoid applying the cluster-wide RBAC (system:auth-delegator binding) to the wrong cluster." + type = string + default = null +} + +variable "kubernetes_auth" { + description = <<-EOT + Optionally create the in-cluster Kubernetes resources that Vault's `kubernetes` + auth method needs so the nullplatform agent can authenticate with its pod + ServiceAccount identity (auth_mode = "kubernetes" in the install module). + + It provisions two things via .yaml templates applied from Terraform: + 1. The agent ServiceAccount the Vault role binds to (bound_service_account_names / + bound_service_account_namespaces). Skip with create_agent_service_account=false + if the agent Helm chart already manages it. + 2. A token-reviewer ServiceAccount bound to the built-in `system:auth-delegator` + ClusterRole. Its token is used as `token_reviewer_jwt` when configuring + `vault write auth/kubernetes/config`. + + Fields: + enable — set true to create the resources. + agent_namespace — namespace of the nullplatform agent (required when enable=true). + agent_service_account_name — name of the agent ServiceAccount (required when enable=true). + create_agent_service_account — also create the agent ServiceAccount (default true). + token_reviewer_service_account_name — token-reviewer SA name (default "vault-auth"). + token_reviewer_namespace — token-reviewer SA namespace (default: agent_namespace). + EOT + type = object({ + enable = bool + agent_namespace = optional(string, "") + agent_service_account_name = optional(string, "") + create_agent_service_account = optional(bool, true) + token_reviewer_service_account_name = optional(string, "vault-auth") + token_reviewer_namespace = optional(string, "") + }) + default = { + enable = false + } + + validation { + condition = !var.kubernetes_auth.enable || (var.kubernetes_auth.agent_namespace != "" && var.kubernetes_auth.agent_service_account_name != "") + error_message = "agent_namespace and agent_service_account_name are required when kubernetes_auth.enable=true." + } +} diff --git a/parameters/providers/hashicorp-vault/specs/requirements/versions.tf b/parameters/providers/hashicorp-vault/specs/requirements/versions.tf new file mode 100644 index 0000000..13ced37 --- /dev/null +++ b/parameters/providers/hashicorp-vault/specs/requirements/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.20" + } + } +} diff --git a/parameters/providers/hashicorp-vault/store b/parameters/providers/hashicorp-vault/store new file mode 100755 index 0000000..69a832e --- /dev/null +++ b/parameters/providers/hashicorp-vault/store @@ -0,0 +1,60 @@ +#!/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 + +# Embed the KV path prefix so the external_id IS the full, self-contained Vault +# path. retrieve/delete then use it verbatim instead of recomposing it against the +# current VAULT_PATH_PREFIX — so existing references stay valid even if +# setup.namespace is later reconfigured. +EXTERNAL_ID="$VAULT_PATH_PREFIX/$EXTERNAL_ID" +VAULT_PATH="$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}}') + +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 + +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/providers/hashicorp-vault/delete.bats b/parameters/tests/providers/hashicorp-vault/delete.bats new file mode 100644 index 0000000..80e1a69 --- /dev/null +++ b/parameters/tests/providers/hashicorp-vault/delete.bats @@ -0,0 +1,109 @@ +#!/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" + # external_id path is the full, self-contained Vault path (KV prefix embedded by store). + export EXTERNAL_ID="secret/data/nullplatform/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: uses external_id path verbatim, ignoring current VAULT_PATH_PREFIX" { + # Regression: simulates setup.namespace being reconfigured AFTER this secret was + # stored. The full path lives in the external_id, so it must win over the current + # prefix — otherwise the delete would target the wrong (or a non-existent) path. + export EXTERNAL_ID_PATH="secret/data/original-ns/abc-123" + export VAULT_PATH_PREFIX="secret/data/reconfigured-ns" + + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/secret/data/original-ns/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 0000000..79458ac --- /dev/null +++ b/parameters/tests/providers/hashicorp-vault/retrieve.bats @@ -0,0 +1,120 @@ +#!/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" + # external_id path is the full, self-contained Vault path (KV prefix embedded by store). + export EXTERNAL_ID="secret/data/nullplatform/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: 200 preserves values with quotes without JSON injection" { + # A stored value crafted to break naive string-interpolated JSON output. + malicious='inject","injected":"x' + body=$(jq -nc --arg v "$malicious" '{data:{data:{value:$v}}}') + + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + assert_equal "$status" "0" + # Output must be valid JSON with the value preserved verbatim... + echo "$output" | jq -e . >/dev/null + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "$malicious" + # ...and must NOT have gained an injected top-level key. + injected=$(echo "$output" | jq -r '.injected // "ABSENT"') + assert_equal "$injected" "ABSENT" +} + +@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: uses external_id path verbatim, ignoring current VAULT_PATH_PREFIX" { + # Regression: simulates setup.namespace being reconfigured AFTER this secret was + # stored. The full path lives in the external_id, so it must win over the current + # prefix — otherwise the reference would be silently lost. + export EXTERNAL_ID_PATH="secret/data/original-ns/abc-123" + export VAULT_PATH_PREFIX="secret/data/reconfigured-ns" + 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/secret/data/original-ns/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 0000000..b75ef59 --- /dev/null +++ b/parameters/tests/providers/hashicorp-vault/setup.bats @@ -0,0 +1,284 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp-vault/setup +# +# setup authenticates against Vault (userpass or kubernetes) and exports the +# derived short-lived token as VAULT_TOKEN. curl (the login call) is mocked. +# ============================================================================= + +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" + + # Mock curl — logs args, returns a login response with a client_token by default. + 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" +# The request body is fed via stdin (--data @-); capture it so tests can assert on it. +if [ ! -t 0 ]; then echo "STDIN: $(cat)" >> "$CURL_LOG"; fi +if [ "${MOCK_CURL_EXIT:-0}" -ne 0 ]; then exit "$MOCK_CURL_EXIT"; fi +if [ -n "${MOCK_LOGIN_BODY:-}" ]; then + printf '%s' "$MOCK_LOGIN_BODY" +else + printf '%s' '{"auth":{"client_token":"hvs.derived-token"}}' +fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + # A readable ServiceAccount token file for kubernetes-mode tests. + export SA_TOKEN_FILE="$BATS_TEST_TMPDIR/sa-token" + printf 'eyJhbGc.k8s-jwt.sig' > "$SA_TOKEN_FILE" + + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset VAULT_ADDR VAULT_AUTH_MODE VAULT_TOKEN VAULT_PATH_PREFIX PROVIDER_CONFIG \ + VAULT_USERNAME VAULT_PASSWORD VAULT_K8S_ROLE VAULT_K8S_JWT_PATH \ + MOCK_LOGIN_BODY MOCK_CURL_EXIT +} + +# --- Address / common ------------------------------------------------------- + +@test "vault setup: fails when VAULT_ADDR is missing" { + unset VAULT_ADDR + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault address not configured" +} + +@test "vault setup: address from PROVIDER_CONFIG" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + 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: path_prefix defaults to secret/data/nullplatform when unset" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + + 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: path_prefix from PROVIDER_CONFIG (.setup.namespace)" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","namespace":"secret/data/team-x"}}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=secret/data/team-x" +} + +@test "vault setup: path_prefix from VAULT_PATH_PREFIX env var" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + export VAULT_PATH_PREFIX="secret/data/from-env" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=secret/data/from-env" +} + +@test "vault setup: path_prefix from PROVIDER_CONFIG wins over VAULT_PATH_PREFIX env var" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + export VAULT_PATH_PREFIX="secret/data/from-env" + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","namespace":"secret/data/from-config"}}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=secret/data/from-config" +} + +@test "vault setup: path_prefix trailing slash is trimmed" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","namespace":"secret/data/team-x/"}}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=secret/data/team-x" +} + +@test "vault setup: unknown auth_mode fails" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_AUTH_MODE="ldap" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Unknown Vault authentication mode 'ldap'" +} + +# --- userpass mode (default) ------------------------------------------------ + +@test "vault setup: userpass fails when VAULT_USERNAME is missing" { + export VAULT_ADDR="https://vault.example.com" + unset VAULT_USERNAME + export VAULT_PASSWORD="pw" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault username not configured" +} + +@test "vault setup: userpass fails when VAULT_PASSWORD is missing" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="agent" + unset VAULT_PASSWORD + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault password not configured" +} + +@test "vault setup: userpass credentials must come from env (not PROVIDER_CONFIG)" { + export VAULT_ADDR="https://vault.example.com" + unset VAULT_USERNAME VAULT_PASSWORD + export PROVIDER_CONFIG='{"setup":{"username":"from-config","password":"from-config"}}' + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault username not configured" +} + +@test "vault setup: userpass login exports derived VAULT_TOKEN" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + + run bash -c "$DEPS; source $SCRIPT && echo TOKEN=\$VAULT_TOKEN" + + assert_equal "$status" "0" + assert_contains "$output" "TOKEN=hvs.derived-token" +} + +@test "vault setup: userpass POSTs password to the userpass login endpoint" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="s3cr3t" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-X POST" + assert_contains "$captured" "https://vault.example.com/v1/auth/userpass/login/agent" + assert_contains "$captured" '"password":"s3cr3t"' +} + +@test "vault setup: userpass URL-encodes the username in the login path" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="ns/agent" + export VAULT_PASSWORD="pw" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "login/ns%2Fagent" +} + +@test "vault setup: userpass login without a token fails" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + export MOCK_LOGIN_BODY='{"errors":["invalid username or password"]}' + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault userpass login did not return a token" +} + +# --- kubernetes mode -------------------------------------------------------- + +@test "vault setup: non-JSON login response fails with troubleshooting (not a jq crash)" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + export MOCK_LOGIN_BODY='502 Bad Gateway' + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault userpass login did not return a token" +} + +@test "vault setup: auth_mode kubernetes from PROVIDER_CONFIG" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_K8S_JWT_PATH="$SA_TOKEN_FILE" + export PROVIDER_CONFIG='{"setup":{"auth_mode":"kubernetes","kubernetes_role":"nullplatform-agent"}}' + + run bash -c "$DEPS; source $SCRIPT && echo TOKEN=\$VAULT_TOKEN" + + assert_equal "$status" "0" + assert_contains "$output" "TOKEN=hvs.derived-token" +} + +@test "vault setup: kubernetes fails when role is missing" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_AUTH_MODE="kubernetes" + export VAULT_K8S_JWT_PATH="$SA_TOKEN_FILE" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault Kubernetes role not configured" +} + +@test "vault setup: kubernetes fails when SA token file is unreadable" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_AUTH_MODE="kubernetes" + export VAULT_K8S_ROLE="nullplatform-agent" + export VAULT_K8S_JWT_PATH="$BATS_TEST_TMPDIR/does-not-exist" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Kubernetes ServiceAccount token not readable" +} + +@test "vault setup: kubernetes POSTs role + jwt to the kubernetes login endpoint" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_AUTH_MODE="kubernetes" + export VAULT_K8S_ROLE="nullplatform-agent" + export VAULT_K8S_JWT_PATH="$SA_TOKEN_FILE" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/auth/kubernetes/login" + assert_contains "$captured" '"role":"nullplatform-agent"' + assert_contains "$captured" '"jwt":"eyJhbGc.k8s-jwt.sig"' +} diff --git a/parameters/tests/providers/hashicorp-vault/store.bats b/parameters/tests/providers/hashicorp-vault/store.bats new file mode 100644 index 0000000..fb36d05 --- /dev/null +++ b/parameters/tests/providers/hashicorp-vault/store.bats @@ -0,0 +1,134 @@ +#!/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" + + # 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" + # Slugs travel in the payload next to each entity id (build_external_id reads + # them straight from CONTEXT — no np call, no cache files). + export CONTEXT='{ + "parameter_id": 42, + "value": "my-secret", + "entities": { + "organization": "1255165411", + "organization_slug": "acme", + "account": "95118862", + "account_slug": "prod", + "namespace": "37094320", + "namespace_slug": "billing", + "application": "321402625", + "application_slug": "api" + }, + "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') + # external_id embeds the KV path prefix and the version (mock returns .data.version=3). + expected="secret/data/nullplatform/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":"secret/data/nullplatform/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: external_id embeds a custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + # The configured prefix is baked into the external_id, not just the request URL. + assert_contains "$external_id" "kv/data/custom-mount/organization=acme-1255165411" +} + +@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="secret/data/nullplatform/organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#3" + assert_equal "$external_id" "$expected" +}