Prerequisite for the SSO Manager plugin system (shipped in sso-manager-node v1.17.0). Adds secret/data/plugins/* (CRUD+list) + secret/metadata/plugins/* (list/read/delete) to the sso-broker policy HCL so the SSO can store per-instance plugin secrets in OpenBao instead of sso-secrets.js. ensure_policy is idempotent, so re-running ./setup.sh grants the existing SSO_VAULT_TOKEN live. Docs: secrets.md (Plugin secrets section + policy row), architecture.md. Co-Authored-By: Claude <noreply@anthropic.com>
11 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; 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 role sso-broker — allowed_policies=sso-admin,
allowed_policies_glob=user-*,app-*, orphan, renewable, token_period=24h.
The SSO mints per-user, per-admin, and per-app tokens through this role at
runtime, so it never needs the root token to issue scoped access.
The three per-app tokens (SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN,
JUMP_VAULT_TOKEN) are minted orphan + renewable and stored in ./.env by
setup.sh. They use OpenBao's default service-token TTL; if one expires,
re-run ./setup.sh and the ensure_token helper re-mints it (the old one
expires on its own). Automated renewal is a planned follow-up, not yet built.
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.)