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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### 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.
- Fix HashiCorp Vault parameters not being retrievable after being stored on a Vault Enterprise namespace.
- Report a clear error when storing a parameter in HashiCorp Vault fails, instead of silently reporting success.

### Changed

- Rename the HashiCorp Vault `namespace` setup field label to "Namespace" and clarify its description (namespace and path prefix where parameters are stored).
- Configure the HashiCorp Vault namespace and KV path prefix as separate fields.

## [0.2.0] - 2026-07-14
### Added
Expand Down
60 changes: 33 additions & 27 deletions parameters/providers/hashicorp-vault/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,49 @@ 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 path prefix parameters are stored under; on Vault Enterprise it may be prefixed with a Vault namespace. |
| `setup.namespace` | no (default root) | Vault Enterprise namespace the parameters live under, e.g. `admin/eks-null-alfa-136`. Leave empty for the root namespace (Vault OSS). |
| `setup.path_prefix` | no (default `secret/data/nullplatform`) | KV v2 path prefix (relative to the namespace) parameters are stored under. Must be the full `<mount>/data/<subpath>`. |
| `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 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`).
`namespace` and `path_prefix` are **two independent axes**:

- **`setup.namespace`** (env: `VAULT_NAMESPACE`) — the Vault Enterprise namespace.
It is applied as a URL prefix (`$VAULT_ADDR/v1/<namespace>/…`) on the login **and**
on every store/retrieve/delete request. Empty means the root namespace, so
non-Enterprise (Vault OSS) deployments simply leave it unset.
- **`setup.path_prefix`** (env: `VAULT_PATH_PREFIX`, default
`secret/data/nullplatform`) — the KV v2 path prefix relative to the namespace. It
must be the full `<mount>/data/<subpath>`: the mount name, then the KV v2 `data`
segment, then an optional subpath. `store` embeds it in the `external_id`, so
stored references survive a later `path_prefix` change.

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`).
must target that namespace or Vault returns **access denied**. Inside a namespace, a
KV v2 secret lives at `<namespace>/<mount>/data/<subpath>` — the mount name sits
between the namespace and `data`, which is why the two are configured separately.

| `setup.namespace` | `setup.path_prefix` | Full KV path used by store/retrieve |
|-------------------------|-----------------------------|-------------------------------------------|
| *(empty — root)* | `secret/data/nullplatform` *(default)* | `secret/data/nullplatform/…` |
| *(empty — root)* | `secret/data/team-x` | `secret/data/team-x/…` |
| `admin/ns` | `secret/data/nullplatform` | `admin/ns/secret/data/nullplatform/…` |
| `admin/team/sub` | `kv/data/params` | `admin/team/sub/kv/data/params/…` |

> **Note:** changing `setup.namespace` after parameters exist is a migration, not a
> config tweak — the namespace is not embedded in the `external_id`, and Vault does
> not move secrets between namespaces. Point it at the namespace where the secrets
> already live.

> **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.
> set a custom `setup.path_prefix`, adjust the policy paths to match it — the KV
> mount and the `metadata/` counterpart of your configured `data/` path. On Vault
> Enterprise, write the policy inside the configured `setup.namespace`.

## Authentication

Expand Down
15 changes: 10 additions & 5 deletions parameters/providers/hashicorp-vault/delete
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,21 @@ set -euo pipefail
# - Any other HTTP status or network error → exit 1 with troubleshooting
#
# Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN
# Optional env: VAULT_NAMESPACE (Vault Enterprise namespace, applied as a URL prefix)

# 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.
# EXTERNAL_ID_PATH is the full, self-contained KV 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.path_prefix changes.
# The Vault namespace is not part of the path; it is applied from the current
# config as a URL prefix.
VAULT_PATH="$EXTERNAL_ID_PATH"

NS_PREFIX=""
[ -n "${VAULT_NAMESPACE:-}" ] && NS_PREFIX="${VAULT_NAMESPACE}/"

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
"$VAULT_ADDR/v1/${NS_PREFIX}${VAULT_PATH}" 2>/dev/null); then
log error "❌ Network error calling Vault at $VAULT_ADDR"
log error ""
log error "💡 Possible causes:"
Expand Down
14 changes: 8 additions & 6 deletions parameters/providers/hashicorp-vault/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ 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`). 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"}`. |
| `setup` | Reads `VAULT_ADDR`, `VAULT_AUTH_MODE` (default `userpass`) and the mode's inputs, plus two independent path axes: `VAULT_NAMESPACE` (the Vault Enterprise namespace, empty = root) and `VAULT_PATH_PREFIX` (the KV v2 `<mount>/data/<subpath>` prefix, default `secret/data/nullplatform`). When a namespace is set, 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` plus `VAULT_NAMESPACE`. 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 KV path (the namespace is applied separately as a URL prefix, not embedded). POSTs to `$VAULT_ADDR/v1/<namespace>/<full_path>` with a JSON payload. **Validates the HTTP status** (curl `-s` exits 0 on HTTP errors, so a failed write must not be reported as success). 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/<namespace>/<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.path_prefix` change can't orphan the reference). The namespace comes from the current config. 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}`. |

Expand Down Expand Up @@ -51,9 +51,11 @@ plus the version:
<VAULT_PATH_PREFIX>/<canonical_path>#<version_id>
```

Embedding the prefix makes the `external_id` self-contained: `retrieve`/`delete`
resolve it verbatim, so reconfiguring `setup.namespace` never orphans previously
stored references.
Embedding the KV prefix makes the `external_id` self-contained: `retrieve`/`delete`
resolve it verbatim, so reconfiguring `setup.path_prefix` never orphans previously
stored references. (The Vault namespace is applied from the current config as a URL
prefix and is deliberately not embedded — moving to a different namespace is a
migration, not a config tweak.)

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:

Expand Down
13 changes: 9 additions & 4 deletions parameters/providers/hashicorp-vault/retrieve
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ set -euo pipefail
# - Any other HTTP status or network error → exit 1 with troubleshooting
#
# Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN
# Optional env: VAULT_NAMESPACE (Vault Enterprise namespace, applied as a URL prefix)

# 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
# the full, self-contained KV 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.
# keeps references valid across setup.path_prefix changes. The Vault namespace is
# NOT part of the path; it is applied from the current config as a URL prefix.
VAULT_PATH="$EXTERNAL_ID_PATH"

URL="$VAULT_ADDR/v1/$VAULT_PATH"
NS_PREFIX=""
[ -n "${VAULT_NAMESPACE:-}" ] && NS_PREFIX="${VAULT_NAMESPACE}/"

URL="$VAULT_ADDR/v1/${NS_PREFIX}${VAULT_PATH}"
if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then
URL="${URL}?version=${EXTERNAL_ID_VERSION}"
fi
Expand Down Expand Up @@ -53,7 +58,7 @@ case "$HTTP_STATUS" in
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 " • Verify: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/${NS_PREFIX}${VAULT_PATH}"
log error " • If genuinely missing, the parameter value needs to be re-stored"
exit 1
;;
Expand Down
84 changes: 52 additions & 32 deletions parameters/providers/hashicorp-vault/setup
Original file line number Diff line number Diff line change
Expand Up @@ -17,49 +17,69 @@ set -euo pipefail
# 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 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.
# Two independent config axes:
#
# VAULT_NAMESPACE The Vault Enterprise namespace (e.g. `admin/eks-null-alfa-136`).
# Operator-configurable via .setup.namespace / VAULT_NAMESPACE.
# Empty means the root namespace — backward compatible with
# non-Enterprise deployments. It is applied as a URL prefix
# (`$VAULT_ADDR/v1/<namespace>/...`) on login AND on every
# store/retrieve/delete request; Vault resolves the leading
# path segments to the namespace before routing to the mount.
#
# VAULT_PATH_PREFIX The KV v2 path prefix RELATIVE to the namespace, i.e. the
# full `<mount>/data/<subpath>` (e.g. `secret/data/nullplatform`).
# It MUST include the mount and the KV v2 `data` segment.
# Defaults to `secret/data/nullplatform`. store embeds it in
# the external_id, so references survive a path_prefix change.
#
# The two are kept separate because a KV v2 path inside a namespace is
# `<namespace>/<mount>/data/<subpath>` — the mount name sits between the namespace
# and `data`, so a single field cannot encode both without ambiguity (which broke
# read/write on Enterprise namespaces). The auth mounts are hardcoded to Vault's
# defaults (auth/userpass, auth/kubernetes); 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).
# must target it too — otherwise Vault returns "access denied".
#
# Exports: VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX
# Exports: VAULT_ADDR, VAULT_TOKEN, VAULT_NAMESPACE, 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 "$VAULT_DEFAULT_PATH_PREFIX")
# Trim a trailing slash so store/retrieve/delete don't compose a double slash.
VAULT_NAMESPACE=$(get_config_value --env VAULT_NAMESPACE --provider '.setup.namespace' --default "")
VAULT_PATH_PREFIX=$(get_config_value --env VAULT_PATH_PREFIX --provider '.setup.path_prefix' --default "$VAULT_DEFAULT_PATH_PREFIX")
# Trim surrounding slashes so URL composition never doubles them.
VAULT_NAMESPACE="${VAULT_NAMESPACE#/}"
VAULT_NAMESPACE="${VAULT_NAMESPACE%/}"
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
# Defense-in-depth: both values are interpolated raw into request URLs (the login
# call and every store/retrieve/delete). They are multi-segment paths where `/` is
# significant, so we validate rather than URL-encode (unlike the single-segment
# username, which is @uri-encoded): allow only Vault-legal path characters and
# reject `..` segments that could rewrite the request path. Values are operator
# config, so this guards against misconfiguration, not attacker input.
validate_vault_path_config() {
local name="$1" value="$2"
[ -z "$value" ] && return 0
if [[ "$value" == *".."* ]] || [[ "$value" =~ [^A-Za-z0-9._/-] ]]; then
log error "❌ Invalid Vault $name: '$value'"
log error ""
log error "💡 Possible causes:"
log error " • .setup.$name contains characters outside [A-Za-z0-9._/-] or a '..' segment"
log error ""
log error "🔧 How to fix:"
log error " • Use only letters, digits, '.', '_', '-' and '/' path separators"
exit 1
fi
}
validate_vault_path_config namespace "$VAULT_NAMESPACE"
validate_vault_path_config path_prefix "$VAULT_PATH_PREFIX"

# 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.
# otherwise. store/retrieve/delete compose the same namespace prefix themselves.
VAULT_LOGIN_BASE="$VAULT_ADDR/v1"
if [ -n "$VAULT_NAMESPACE" ]; then
VAULT_LOGIN_BASE="$VAULT_ADDR/v1/$VAULT_NAMESPACE"
Expand Down Expand Up @@ -198,4 +218,4 @@ if [ -z "$VAULT_TOKEN" ]; then
exit 1
fi

export VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX
export VAULT_ADDR VAULT_TOKEN VAULT_NAMESPACE VAULT_PATH_PREFIX
Loading
Loading