- theta-svc token role (periodic 768h): SSO/PROXY/JUMP_VAULT_TOKEN now minted through it; ensure_token renews periodic tokens on every setup.sh re-run and detects/revokes/re-mints valid-but-non-periodic tokens from older installs. - bao-renewer sidecar (docker-compose): renews the three service tokens every 12h while the stack runs. - sso-app token role (periodic 768h) + sso-broker policy grants for auth/token/create/sso-app and renew/revoke/lookup-accessor. - docs/secrets.md rewritten around the new lifecycle. - Bump sso-manager-node gitlink to v1.23.0 (real vault-403 fix + app-token lifecycle). Co-Authored-By: Claude <noreply@anthropic.com>
12 KiB
layout, title, description
| layout | title | description |
|---|---|---|
| default | Secrets (OpenBao) | theta-suite's central secrets architecture — OpenBao as the single store for all app, per-user, and external-app secrets, with scoped tokens and policies. |
Secrets — OpenBao as the central store
theta-suite keeps every secret in one place: OpenBao
(a Vault-community fork), running on the theta-net docker network at
http://openbao:8200. The three apps (SSO Manager, proxy, jump host) load
their boot secrets from it; end users get personal per-user secret storage
through the SSO UI; and external apps get scoped, self-contained access to
their own namespace.
This page is the operator reference. For the package API, see @simpleworkjs/bao-conf.
Why a central store
Before this, secret handling was partial and inconsistent: only the SSO read
one path from OpenBao; the proxy and jump host read bind-mounted
./config/*-secrets.js files; the bootstrap wrote generated OAuth creds to
those files on disk; and the SSO /api/vault UI was an ungated, broken
pass-through. Centralising on OpenBao gives every app the same fail-soft load
path, makes per-user secret storage possible, and lets external apps get
least-privilege access without anyone handing them the root token.
The load path (every app)
@simpleworkjs/confsynchronously loads the bind-mounted./config/<app>-secrets.jsat require time — the file is the operator-edit layer and the fail-soft fallback.@simpleworkjs/bao-conf'sinit({ path: '<app>', conf })deep-mergessecret/data/<app>/conffrom OpenBao over the liveconfobject. It is fail-soft: if OpenBao is unreachable or the path is absent, boot continues with the file-loaded config.- A few secrets are captured at require time (notably the OIDC
clientSecret, consumed insidecreateOidcClientduringrequire('../models')). Soinit()must resolve before thatrequire(). Each app'sbin/wwwhandles this:- proxy — defers
require('../app')(which transitively loads models) behindbao-conf.init(). - jump host — gates the explicit
require('../models')behindbao-conf.init(). - SSO — swaps the old
conf_manager.init()call (same position in its existing.then()boot chain) forbao-conf.init(); nothing in the SSO captures a secret at require time, so no reordering was needed.
- proxy — defers
VAULT_TOKEN (a scoped per-app token, not the root token) and
VAULT_ADDR=http://openbao:8200 are passed to each container via
docker-compose.yml. The ./config/*-secrets.js mounts stay as the fallback.
Policies, token role, and tokens
setup.sh creates the ACL policies and mints the per-app tokens
(idempotently — re-running keeps existing tokens and re-mints only expired
ones). The root token stays in .env for setup/maintenance only and is
never passed to a service container.
| Policy | Capabilities | Held by |
|---|---|---|
sso-broker |
read/write secret/sso-manager/conf, secret/users/*, secret/apps/*, secret/plugins/*; update on auth/token/create/sso-broker + create/sso-app and auth/token/renew-accessor/revoke-accessor/lookup-accessor; update on sys/policies/acl/user-*, app-*, sso-admin |
SSO (SSO_VAULT_TOKEN) |
sso-admin |
read/write/list all of secret/* |
admin UI sessions (minted by the broker) |
proxy |
read secret/proxy/conf |
proxy (PROXY_VAULT_TOKEN) |
jump-host |
read secret/jump-host/conf |
jump host (JUMP_VAULT_TOKEN) |
user-<uid> |
read/write secret/users/<uid>/* |
per-user tokens (minted lazily by the broker) |
app-<name> |
read/write secret/apps/<name>/* |
per-external-app tokens (minted by an admin) |
Token roles — three, all orphan + renewable:
sso-broker—allowed_policies=sso-admin,allowed_policies_glob=user-*,app-*,token_period=24h. The SSO mints per-user and per-admin tokens through this role at runtime, so it never needs the root token to issue scoped access. The 24h period is fine here because the broker re-mints these from its Redis cache transparently.sso-app—allowed_policies_glob=app-*,token_period=768h. External-app tokens minted from the vault UI's Apps tab go through this role: they are long-lived credentials, so they get a monthly period instead of a daily one.theta-svc—allowed_policies=sso-broker,proxy,jump-host,token_period=768h. The services' own tokens (below).
Token lifecycle — nothing expires by surprise
Periodic tokens never hit a max TTL, but they die if nothing renews them inside a period window. Renewal is automated at every layer:
- Service tokens (
SSO_VAULT_TOKEN,PROXY_VAULT_TOKEN,JUMP_VAULT_TOKEN, minted viatheta-svc, stored in./.env): thebao-renewersidecar (docker-compose) renews all three every 12 hours, and everysetup.shre-run renews them too. A valid-but-non-periodic token from an older install is detected, revoked, and re-minted as periodic on the nextsetup.shrun. - External-app tokens (minted in the SSO vault UI): the SSO stores each token's accessor (which can renew/revoke but not authenticate) and renews it every 6 hours and at boot — a downstream app's credential stays valid as long as the SSO is running, with no renewal code in the downstream app. Re-minting an app's token revokes the previous one via its accessor, so exactly one credential per app is ever live.
- Per-user / admin tokens: 24h TTL by design; the broker re-mints them transparently, so there is nothing to renew.
Worst case (the whole stack was down for >32 days): re-run ./setup.sh — it
re-mints anything that lapsed; external-app tokens are re-minted from the
Apps tab (the app's policy and stored secrets are kept).
Seeding
setup.sh seeds, on first run only (skipped if the path already exists):
secret/sso-manager/conf— from./config/sso-secrets.js(operator-set LDAP/SMTP/jwtSecret; the SSO has no bootstrap-generated creds, so the file is the complete source of truth).secret/proxy/conf— from./config/proxy-secrets.js(placeholder OAuth creds at this point).secret/jump-host/conf— from./config/jump-secrets.jsafter the bootstrap writes it.
The bootstrap (bootstrap/bootstrap.js) then generates the real OAuth
client credentials and writes the complete proxy-secrets.js and
jump-secrets.js objects into secret/proxy/conf and secret/jump-host/conf
(POST, replacing the placeholder seed). After the first run, OpenBao is
authoritative; the ./config/*-secrets.js files are operator-edit seed
artifacts and the fail-soft fallback.
End-user personal secrets
Every logged-in user has a personal namespace secret/users/<uid>/*, reached
through the SSO UI at Vault → My Secrets. The SSO mints a user-<uid>
token on first access (cached in Redis for the token's lifetime) and proxies
/api/vault to OpenBao with that token injected server-side — the
client's SSO session token never reaches OpenBao.
- Non-admins see only their own namespace; the UI fixes the path prefix
to
users/<uid>/. They can list, read, write, and delete secrets there. - Admins (
app_sso_admin/app_super_admin) get free-form access across all ofsecret/plus an Apps tab (see below).
Scoping is enforced at two layers: the SSO's scopeGuard rejects any path
outside the subject's prefix with a 403 (defense-in-depth), and the token's
own OpenBao policy enforces the same at the API layer.
External apps
An external (non-theta42) app gets scoped access to its own namespace,
secret/apps/<name>/*, via a token an admin mints once from the SSO UI's
Vault → Apps tab. The token is shown once (copy it immediately; it is
not stored retrievably) and confined by an app-<name> policy.
Convention:
secret/apps/<name>/conffor config-style secrets,secret/apps/<name>/*for arbitrary keys.- The app authenticates with the header
X-Vault-Token: <minted token>againsthttp://<openbao-host>:8200/v1/secret/data/apps/<name>/....
Non-Node consumers (curl):
VAULT_ADDR=http://openbao:8200 # or your external-facing openbao address
# Write
curl -X POST "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
-H "X-Vault-Token: <token>" -H "Content-Type: application/json" \
-d '{"data":{"db_password":"..."}}'
# Read
curl -s "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
-H "X-Vault-Token: <token>" | jq .data.data
Node consumers can use @simpleworkjs/bao-conf directly:
const baoConf = require('@simpleworkjs/bao-conf');
const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-service/conf
await baoConf.set('apps/my-service/conf', { db_password: '...' });
Plugin secrets
The SSO Manager's plugin system (configurable plugin instances you create,
edit, load/unload, and run from the Plugins page) stores each instance's
secrets in its own OpenBao namespace, secret/plugins/<instance-id>/conf,
rather than in the static sso-secrets.js discovery.plugins block. The
SSO reads and writes these server-side through the sso-broker token (the
plugin runs in-process as a BullMQ worker, so it needs no token of its own),
and the admin UI only ever sees masked (********) values.
- A plugin type is a module under
nodejs/plugins/<category>/<type>.jsexporting a manifest (configSchemadeclares which fields aresecret). - A plugin instance is a configured, loadable/unloadable copy of a type,
tracked in the
PluginInstancetable; you can have multiple instances of the same type (e.g. two Proxmox endpoints with their own tokens). - Non-secret config lives in the DB row; only the
secret:truefield values live insecret/plugins/<instance-id>/conf.
Deleting an instance removes both the DB row and its secret/plugins/<id>/*
namespace. Legacy discovery.plugins entries in sso-secrets.js are migrated
to instances automatically on the first boot of SSO Manager ≥ v1.17.0 (the
secret fields are copied into OpenBao at that point). See the SSO Manager
plugins docs for the
UI/API reference.
Operator rotation
If a secret is exposed (or just on a routine schedule), rotate it at the
provider first (the LDAP server, the SMTP host, the OAuth jwtSecret,
etc.), then update OpenBao:
# Read the current sso-manager conf
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv get secret/sso-manager/conf
# Write a new value (KV-v2 POST replaces the data; merge carefully)
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put secret/sso-manager/conf \
ldap.bindPassword='<new>' smtp.password='<new>' oauth.jwtSecret='<new>'
Then restart the affected app so bao-conf.init() re-reads it
(docker compose restart sso-manager). Call-time readers pick up the change
on next read; require-time captures (OIDC clientSecret) need the restart.
The SSO admin Configuration UI (
/api/conf) writessecret/sso-manager/confand updates the live conf immediately, so SMTP/discovery/oauth edits made there don't need a manualbao kv put.
Backups
The OpenBao data volume openbao-data holds every secret. Back it up with the
rest of the stack (see the README's Backups and restore section). The
./config/*-secrets.js files are not a complete secret backup once OpenBao
is authoritative — they're the first-run seed and the fallback. A full disaster
recovery restores both the openbao-data volume (the authoritative store) and
./config/ (the seed/fallback), then runs ./setup.sh to unseal OpenBao and
re-mint the per-app tokens.
What's not in scope yet
- Renewal automation — per-app/user tokens use OpenBao's default TTL and
are re-minted by
setup.shon expiry; a periodic renewal worker is a follow-up. - History scrubbing — if a secret was committed to git, rotating it is the
fix; scrubbing it from git history (BFG /
git filter-repo) is a separate, git-destructive operation you can opt into. - Per-app secrets beyond boot config (e.g. the proxy's DNS-provider creds,
the jump host's per-user LDAP SSH keys) moving into OpenBao — only the
boot-critical
*-secrets.jscontents moved in this phase. (Plugin instance secrets are in OpenBao, atsecret/plugins/<id>/conf— see above.)