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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
140 changes: 140 additions & 0 deletions parameters/providers/hashicorp-vault/README.md
Original file line number Diff line number Diff line change
@@ -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/<username> \
password="<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="<agent-serviceaccount>" \
bound_service_account_namespaces="<agent-namespace>" \
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: <agent-serviceaccount>`, `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.
57 changes: 57 additions & 0 deletions parameters/providers/hashicorp-vault/delete
Original file line number Diff line number Diff line change
@@ -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
}'
113 changes: 113 additions & 0 deletions parameters/providers/hashicorp-vault/docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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/<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. |
| `notify` | Not implemented — dispatcher returns default `{success: true}`. |

---

## Storage layout

Every secret path is composed by `parameters/utils/build_external_id`:

```
<VAULT_PATH_PREFIX>/organization=<slug>-<id>/account=<slug>-<id>/namespace=<slug>-<id>/application=<slug>-<id>[/scope=<slug>-<id>][/<dim_key>=<dim_value>...]/<parameter_name>-<parameter_id>
```

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/<path>` creates a new version, all retained inside the same path. Old versions can be fetched with `?version=<N>`.

### Version identity in external_id

The `external_id` returned by `store` is the full Vault path (KV prefix included)
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.

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 `#<N>` → 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/<user>` |
| `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.
72 changes: 72 additions & 0 deletions parameters/providers/hashicorp-vault/retrieve
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/bin/bash
set -euo pipefail

# Retrieves a value from Vault by external_id.
#
# Semantics:
# - HTTP 2xx → return {value: "<stored 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
Loading
Loading