Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 26 additions & 2 deletions parameters/providers/hashicorp-vault/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<namespace-path>/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 `<namespace>/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
Expand Down
2 changes: 1 addition & 1 deletion parameters/providers/hashicorp-vault/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<full_path>` with a JSON payload. Captures the new version number from Vault's response. Returns `external_id = <VAULT_PATH_PREFIX>/<path>#<version>`. |
| `retrieve` | Splits `EXTERNAL_ID` into path + version. GETs `$VAULT_ADDR/v1/<full_path>?version=<N>` 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. |
Expand Down
48 changes: 41 additions & 7 deletions parameters/providers/hashicorp-vault/setup
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<namespace-path>/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 `<namespace>/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 ""
Expand Down Expand Up @@ -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}')
;;

Expand Down Expand Up @@ -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}')
;;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
93 changes: 93 additions & 0 deletions parameters/tests/providers/hashicorp-vault/setup.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading