A Vault Job
When deploying software services, they usually link to other services using long-lived static credentials (API keys or passwords). For example, a web application and its database, or an LLM provider access token. When the service supports it, those long-lived static credentials can be replaced with short-lived tokens obtained through OIDC-based workload identity federation, with access controlled by RBAC.
I’ve been tinkering a lot with OIDC and RBAC recently because I’m handing over responsibility that can’t be tied to my identity. But before I get there with everything that I run, here’s a writeup of what I’ve been doing in the past year with long-lived static credentials that makes secrets rotation possible.
The approach in this blog post is valuable when the transition to OIDC and RBAC for internal services is still too abstract, or you have no choice.
Internal and external secrets ¶
A secret is internal when it is issued by you to connect to another service hosted by you
A secret is external when it is issued by a 3rd party to connect to a 3rd party service
This dichotomy can, of course, be further complicated by organizational structures with more than one set of walls in the building. The class of secrets that I really address here are ones that can programmatically be altered.
Internal secrets can typically be altered by running a program inside your infrastructure that reads and updates the secret. External secrets can often not easily be re-issued automatically:
- In unlikely cases, the external service has an endpoint that lets you deprecate and re-issue a secret.
- Most often, this is hidden behind a web-based dashboard for human fingers and copy-pasting.
- Sadly, sometimes you need to call a person on the phone or write them an email to rotate a secret.
It is very hard to rotate secrets that require humans in the loop, so this post focuses entirely on when you’re in control, because the service is internal, and as a consequence, you can do the same in the unlikely case that your external endpoint allows it. I have never actually seen this happen outside of major cloud providers.
Nobody needs to know ¶
The tl;dr:
- A Kubernetes
Jobgenerates the secret in-cluster and writes it to Vault. - It never appears on my laptop, in git, or in a password manager / keyring.
- The
Jobis idempotent: it checks whether the path is populated before writing. Job.specis immutable, so the name carries a hash of the script.- Editing the script makes a new
Jobinstead of a rejected apply. - A
VaultStaticSecretrenders the Vault path back into a KubernetesSecretthat pods mount. - The
Jobis given write access and no read access, which breaks the obvious idempotency check in a way that rotates your secret on every deploy.
Vaults and Secrets ¶
In plain Kubernetes, secrets are stored as Secret, stored unencrypted at rest by default.
Vault stores secrets and hands them out to pods. A pod authenticates with its ServiceAccount token, Vault asks the Kubernetes API whether that token is real, and if a role maps that ServiceAccount to a policy, Vault issues a token carrying it.
A policy is path-scoped. This one can write one KV path and read nothing:
path "secret/data/incidental/bootstrap" {
capabilities = ["create", "update"]
}
path "secret/metadata/incidental/bootstrap" {
capabilities = ["read"]
}
secret/data/... is the value. secret/metadata/... is version info, timestamps, and whether the
path exists at all. On a KV v2 mount they are separate paths with separate capabilities.
Getting a secret out of Vault and into a pod ¶
To automate the propagation of Vault secrets into Kubernetes Secrets, the Vault Secrets
Operator (VSO) renders a VaultStaticSecret as an ordinary Secret:
kubernetes.resources.vaultstaticsecrets.incidental-bootstrap = {
metadata.namespace = "incidental";
spec = {
vaultAuthRef = "vault/default";
mount = "secret";
path = "incidental/bootstrap";
type = "kv-v2";
refreshAfter = "30s";
destination = {
name = "incidental-bootstrap";
create = true;
overwrite = true;
};
};
};
The pod then reads it like any other Secret:
env = [
{
name = "INCIDENTAL_BOOTSTRAP_TOKEN";
valueFrom.secretKeyRef = {
name = "incidental-bootstrap";
key = "token";
};
}
];
The machinery necessary for that to happen comes in four parts:
1. A VaultAuth
¶
vaultauthmethods.default = {
metadata.namespace = "vault";
spec = {
vaultConnectionRef = "default";
method = "kubernetes";
mount = "kubernetes";
kubernetes = {
role = "vso";
serviceAccount = "vault-secrets-operator-controller-manager";
audiences = [ "vault" ];
};
allowedNamespaces = [ "incidental" /* ... */ ];
};
};
A namespace missing from allowedNamespaces means VSO refuses to reconcile and says so only in its own logs.
2. That same ServiceAccount has to exist in every namespace holding a VaultStaticSecret ¶
VSO mints a token for it locally and logs in with that. One per namespace:
{
apiVersion = "v1";
kind = "ServiceAccount";
metadata = {
name = "vault-secrets-operator-controller-manager";
namespace = "incidental";
};
}
3. Vault’s own vso role has to list the namespace too
¶
allowedNamespaces is what VSO permits itself; this is what Vault permits. Miss it and the login is
refused on the Vault side only.
4. destination.overwrite = true
¶
An apply never prunes, so a Secret deleted from source outlives its own removal, and VSO checks
its ownership labels before every write and refuses one it did not create. Without overwrite it
stalls forever on an object not present in git.
Since it’s easy to forget any of these, deployment happens across namespaces, and failure is silent, I’ve made some property tests on both the Vault side and the application using the secret side that ensure that they always occur when deploying new things.
TODO: Show kubenix property test examples here.
The Job ¶
So I’ve got a setup where VaultStaticSecret is established and correctly propagates to a Secret.
Now I want to make a Job that populates the VaultStaticSecret.
# services/incidental.nix
imports = [
(vaultBootstrap.mkRandomSecret {
namespace = ns;
name = "incidental-bootstrap";
vaultPath = "incidental/bootstrap";
fields.token = 48;
})
];
That renders a ServiceAccount, a ConfigMap, a Job, and the VaultStaticSecret from earlier. The
ConfigMap holds this:
#!/bin/sh
set -eu
SA_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
VAULT_TOKEN=$(vault write -field=token \
auth/kubernetes/login
role=incidental-bootstrap
jwt="$SA_TOKEN")
export VAULT_TOKEN
SECRET="secret/incidental/bootstrap"
if vault kv metadata get "$SECRET" >/dev/null 2>&1; then
echo "$SECRET already populated; nothing to do."
exit 0
fi
randstr() {
head -c "$1" /dev/urandom | base64 | tr -d '/+=' | head -c "$1"
}
token_value=$(randstr 48)
vault kv put $SECRET token="$token_value"
echo "$SECRET bootstrapped."
The value exists for the length of one shell variable in a pod that then exits. I have never seen it. There is nothing to rotate on laptop loss, nothing to put in 1Password, nothing to forget to remove from a terminal history.
randstr strips /+= from base64 so the value survives every config format that consumes it
without quoting. It over-reads before truncating, because stripping those characters shortens the
string.
Job.spec is immutable
¶
You cannot edit a Job in place. An apply that changes its spec is rejected. So the name carries a
hash of what the Job would run:
scriptData."bootstrap.sh" = bootstrapScript;
specHash = builtins.substring 0 12 (builtins.hashString "sha256" (builtins.toJSON scriptData));
metadata = {
name = "${name}-${specHash}";
inherit namespace;
};
Change the script, get a new Job. Leave it alone, apply the same one forever. With
ttlSecondsAfterFinished = 300 the old ones clean themselves up.
This is also why the idempotency check matters. A new Job runs against a Vault path that is
already populated, on a service that is already using it.
The bug that check had ¶
Here is what I wrote first:
if vault kv get secret/incidental/bootstrap >/dev/null 2>&1; then
And here is the policy I wrote for it, deliberately:
path "secret/data/incidental/bootstrap" {
capabilities = ["create", "update"]
}
No read. A bootstrap that can read back what it wrote is a bootstrap worth stealing a token for.
vault kv get reads secret/data/.... Vault denies it. >/dev/null 2>&1 swallows the denial.
if sees a non-zero exit and reads it as “not populated”. The Job then generates a new token and
puts it, rotating the credential out from under the running pod. That happens every time I edit
the script, which changes the hash, which makes a new Job.
Three correct-looking lines in three files, and the failure only appears on the second deploy.
vault kv metadata get reads secret/metadata/..., which the policy does grant. It answers whether
the path exists without handing back what is in it:
if vault kv metadata get secret/incidental/bootstrap >/dev/null 2>&1; then
The test asserts the shape rather than the behaviour, since the policy lives in another file:
(it "leaves a populated path alone" [
(matches "already populated" script)
(matches "vault kv metadata get" script)
(notMatches "vault kv get" script)
])