diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3128a..72cb70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Prefix the HashiCorp Vault login (both `userpass` and `kubernetes`) with the Vault Enterprise namespace derived from `setup.namespace` when it ends in the KV `data` segment (e.g. `admin/ns/data` → namespace `admin/ns`). Namespace-scoped credentials previously failed authentication with "access denied" because the login always targeted the root namespace. Prefixes whose `data` segment is in the middle (the default and any custom root-namespace subpath) are unaffected and keep logging in against the root namespace. + ### Changed - Rename the HashiCorp Vault `namespace` setup field label to "Namespace" and clarify its description (namespace and path prefix where parameters are stored). diff --git a/parameters/providers/hashicorp-vault/README.md b/parameters/providers/hashicorp-vault/README.md index 0b63413..c413ebc 100644 --- a/parameters/providers/hashicorp-vault/README.md +++ b/parameters/providers/hashicorp-vault/README.md @@ -11,15 +11,39 @@ 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.namespace` | no (default `secret/data/nullplatform`) | KV v2 path prefix parameters are stored under; on Vault Enterprise it may be prefixed with a Vault namespace. | | `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 +The 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`). +### Vault Enterprise namespaces + +On Vault Enterprise, credentials and roles are scoped to a namespace, so the login +must target that namespace or Vault returns **access denied**. Encode the namespace +in `setup.namespace` as `/data` — i.e. the Vault namespace path +**terminated by the KV v2 `data` segment**. `setup` strips that trailing `/data` to +recover the namespace and prefixes both the `userpass` and `kubernetes` login with +it (`store`/`retrieve`/`delete` already reach it because the namespace is embedded +in the request path). + +| `setup.namespace` | Vault namespace for login | KV path used by store/retrieve | +|--------------------------------|---------------------------|--------------------------------| +| `secret/data/nullplatform` *(default)* | *(root, none)* | `secret/data/nullplatform/…` | +| `secret/data/team-x` | *(root, none)* | `secret/data/team-x/…` | +| `admin/ns/data` | `admin/ns` | `admin/ns/data/…` | +| `admin/team/sub/data` | `admin/team/sub` | `admin/team/sub/data/…` | + +> **Note:** the namespace is derived **only** from a trailing `/data` segment. A +> prefix whose `data` segment is in the middle (e.g. the default, or a custom +> subpath like `secret/data/team-x`) is treated as a plain KV mount + path in the +> **root** namespace — no namespace prefix is added to the login. This keeps +> non-Enterprise deployments working unchanged. To use a Vault namespace, the +> prefix must end at `/data` (no KV subpath after `data`). + > **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 diff --git a/parameters/providers/hashicorp-vault/docs/architecture.md b/parameters/providers/hashicorp-vault/docs/architecture.md index c49ee6b..c610738 100644 --- a/parameters/providers/hashicorp-vault/docs/architecture.md +++ b/parameters/providers/hashicorp-vault/docs/architecture.md @@ -8,7 +8,7 @@ This document describes the `parameters/providers/hashicorp-vault/` implementati | 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. | +| `setup` | Reads `VAULT_ADDR`, `VAULT_AUTH_MODE` (default `userpass`) and the mode's inputs, plus `VAULT_PATH_PREFIX` (default `secret/data/nullplatform`). On Vault Enterprise, derives the Vault namespace from `VAULT_PATH_PREFIX` when it ends in the KV `data` segment (e.g. `admin/ns/data` → `admin/ns`; a `data` segment in the middle is a root-namespace KV path and derives nothing) and prefixes the login URL with it — credentials are namespace-scoped, so a root login would be denied. 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. | diff --git a/parameters/providers/hashicorp-vault/setup b/parameters/providers/hashicorp-vault/setup index 1534134..6845704 100755 --- a/parameters/providers/hashicorp-vault/setup +++ b/parameters/providers/hashicorp-vault/setup @@ -18,19 +18,53 @@ set -euo pipefail # 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. +# configurable via .setup.namespace (or the VAULT_PATH_PREFIX env var). It holds +# the KV v2 path prefix and, on Vault Enterprise, may be a Vault namespace path +# terminated by the KV `data` segment: `/data` (e.g. `admin/ns/data`). +# The auth mounts are hardcoded to Vault's defaults (auth/userpass, auth/kubernetes), +# and VAULT_ADDR is operator-configurable. +# +# On Vault Enterprise, credentials/roles are scoped to the namespace, so the login +# must target it too — otherwise Vault returns "access denied". We derive the +# namespace from VAULT_PATH_PREFIX (see below) and prefix the login URL with it, +# matching how store/retrieve/delete embed the prefix into their request paths. +# Prefixes without a trailing `/data` carry no namespace, so login stays on the +# root namespace (backward compatible). # # Exports: VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX +VAULT_DEFAULT_PATH_PREFIX="secret/data/nullplatform" + 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") +VAULT_PATH_PREFIX=$(get_config_value --env VAULT_PATH_PREFIX --provider '.setup.namespace' --default "$VAULT_DEFAULT_PATH_PREFIX") # Trim a trailing slash so store/retrieve/delete don't compose a double slash. VAULT_PATH_PREFIX="${VAULT_PATH_PREFIX%/}" +# Derive the Vault Enterprise namespace (if any) from the path prefix. We only +# treat it as namespaced when the prefix ENDS in the KV v2 `/data` segment +# (e.g. admin/ns/data -> namespace admin/ns): store appends the parameter path +# after `data`, so the operator configures the prefix up to `/data`. +# +# A `data` segment in the MIDDLE (secret/data/nullplatform, secret/data/team-x, +# kv/data/foo) is a plain KV mount + subpath in the ROOT namespace, textually +# indistinguishable from a namespace, so we must NOT derive one — doing so would +# break login for existing non-Enterprise deployments. Hence: trailing `/data` +# only. (Matching on the `/data` suffix is segment-safe: `.../database` does not +# end in `/data`.) +VAULT_NAMESPACE="" +case "$VAULT_PATH_PREFIX" in + */data) VAULT_NAMESPACE="${VAULT_PATH_PREFIX%/data}" ;; +esac + +# API base for the login call — namespace-prefixed on Vault Enterprise, plain +# otherwise. store/retrieve/delete need no equivalent: the namespace is already +# embedded in VAULT_PATH_PREFIX, which they compose into their request paths. +VAULT_LOGIN_BASE="$VAULT_ADDR/v1" +if [ -n "$VAULT_NAMESPACE" ]; then + VAULT_LOGIN_BASE="$VAULT_ADDR/v1/$VAULT_NAMESPACE" +fi + if [ -z "$VAULT_ADDR" ]; then log error "❌ Vault address not configured" log error "" @@ -75,7 +109,7 @@ case "$VAULT_AUTH_MODE" in # 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_URL="$VAULT_LOGIN_BASE/auth/userpass/login/$VAULT_USERNAME_ENC" LOGIN_PAYLOAD=$(jq -nc --arg password "$VAULT_PASSWORD" '{password: $password}') ;; @@ -111,7 +145,7 @@ case "$VAULT_AUTH_MODE" in fi VAULT_K8S_JWT=$(<"$VAULT_K8S_JWT_PATH") - LOGIN_URL="$VAULT_ADDR/v1/auth/kubernetes/login" + LOGIN_URL="$VAULT_LOGIN_BASE/auth/kubernetes/login" LOGIN_PAYLOAD=$(jq -nc --arg role "$VAULT_K8S_ROLE" --arg jwt "$VAULT_K8S_JWT" '{role: $role, jwt: $jwt}') ;; 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 index 1bbdf28..01a27d4 100644 --- a/parameters/providers/hashicorp-vault/specs/install/hashicorp-vault-configuration.json.tpl +++ b/parameters/providers/hashicorp-vault/specs/install/hashicorp-vault-configuration.json.tpl @@ -52,7 +52,7 @@ "namespace": { "type": "string", "title": "Namespace", - "description": "Namespace and path prefix under which parameters are stored. Must include the KV v2 `data/` segment (e.g. secret/data/nullplatform).", + "description": "Namespace and path prefix under which parameters are stored. Must include the KV v2 `data/` segment (e.g. secret/data/nullplatform). On Vault Enterprise, set it to the namespace path terminated by `data` (e.g. admin/ns/data) — the login targets the namespace and only a trailing `/data` is treated as a namespace.", "order": 2, "default": "secret/data/nullplatform" }, diff --git a/parameters/tests/providers/hashicorp-vault/setup.bats b/parameters/tests/providers/hashicorp-vault/setup.bats index b75ef59..10b861a 100644 --- a/parameters/tests/providers/hashicorp-vault/setup.bats +++ b/parameters/tests/providers/hashicorp-vault/setup.bats @@ -208,6 +208,99 @@ teardown() { assert_contains "$captured" "login/ns%2Fagent" } +@test "vault setup: userpass login is NOT namespace-prefixed for the default prefix" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + # default prefix (secret/data/nullplatform) → root namespace, no prefix + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/auth/userpass/login/agent" +} + +@test "vault setup: userpass login is prefixed with the Vault namespace (prefix ends in /data)" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + # admin/ns/data → Vault namespace = admin/ns + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","namespace":"admin/ns/data"}}' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/admin/ns/auth/userpass/login/agent" +} + +@test "vault setup: VAULT_PATH_PREFIX export keeps the trailing /data segment when a namespace is derived" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + # Namespace derivation must not mutate VAULT_PATH_PREFIX itself — store/retrieve/ + # delete compose their Vault paths from the full prefix (including /data). + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","namespace":"admin/ns/data"}}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=admin/ns/data" +} + +@test "vault setup: userpass login is NOT namespace-prefixed for a non-default root prefix (custom subpath, no Enterprise namespace)" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + # secret/data/team-x is a plain KV mount + subpath in the root namespace — the + # `data` segment is in the MIDDLE, so no namespace is derived (backward compat). + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","namespace":"secret/data/team-x"}}' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/auth/userpass/login/agent" +} + +@test "vault setup: userpass login is NOT namespace-prefixed when a subpath follows the data segment" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + # Only a TRAILING /data derives a namespace; admin/ns/data/foo has a subpath + # after data, so it is treated as a root KV path (no namespace prefix). + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","namespace":"admin/ns/data/foo"}}' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/auth/userpass/login/agent" +} + +@test "vault setup: userpass login namespace does not treat 'database' as the data segment" { + export VAULT_USERNAME="agent" + export VAULT_PASSWORD="pw" + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","namespace":"myns/database/x"}}' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + # 'database' is not the KV 'data' segment → no namespace derived → root login + assert_contains "$captured" "https://vault.example.com/v1/auth/userpass/login/agent" +} + +@test "vault setup: kubernetes login is prefixed with the Vault namespace" { + export VAULT_AUTH_MODE="kubernetes" + export VAULT_K8S_ROLE="nullplatform-agent" + export VAULT_K8S_JWT_PATH="$SA_TOKEN_FILE" + export PROVIDER_CONFIG='{"setup":{"address":"https://vault.example.com","auth_mode":"kubernetes","kubernetes_role":"nullplatform-agent","namespace":"admin/ns/data"}}' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/admin/ns/auth/kubernetes/login" +} + @test "vault setup: userpass login without a token fails" { export VAULT_ADDR="https://vault.example.com" export VAULT_USERNAME="agent"