diff --git a/CHANGELOG.md b/CHANGELOG.md index 2908290..2cf3101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,150 @@ orchestration code; see each submodule's own `CHANGELOG.md` [sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md)) for what changed inside the apps it composes. +## [v1.28.0] - 2026-08-01 + +OpenBao becomes the central secrets store for the whole stack. Every app now +loads its secrets from OpenBao at boot via the new +[`@simpleworkjs/bao-conf`](https://simpleworkjs.github.io/bao-conf/) package; +end users get personal per-user secret storage through the SSO UI; external +apps get scoped `secret/apps//*` access. `setup.sh` mints the policies +and scoped tokens, `bootstrap.js` writes generated creds into OpenBao, and a +new `docs/secrets.md` documents the architecture. + +### Changed (theta-env orchestration) +- **`setup.sh`** — after the KV-v2 enable, a new idempotent block writes four + OpenBao policies (`sso-broker`, `sso-admin`, `proxy`, `jump-host`) via + heredocs, a `sso-broker` token role (`allowed_policies_glob` `user-*`/`app-*`, + 24h period), and mints scoped `SSO_VAULT_TOKEN` / `PROXY_VAULT_TOKEN` / + `JUMP_VAULT_TOKEN` (orphan, renewable, reused from `setup.env` if still + valid). `seed_app_conf` seeds `secret/{sso-manager,proxy,jump-host}/conf` + from the operator-edit `/config/*-secrets.js` files on first run. The + bootstrap exec now passes the root `VAULT_ADDR`/`VAULT_TOKEN` for seeding. + The root token stays in `setup.env` for maintenance only — it is never passed + to a service container. `bash -n` + `shellcheck` clean. +- **`docker-compose.yml`** — `sso-manager`/`proxy`/`jump-host` get + `VAULT_ADDR=http://openbao:8200` and `VAULT_TOKEN=${SSO|PROXY|JUMP}_VAULT_TOKEN:-`; + `proxy`/`jump-host` gain `depends_on: openbao: service_started`. compose + config valid. +- **`bootstrap/bootstrap.js`** — `baoPut()` writes the generated OAuth client + creds to `secret/proxy/conf` and `secret/jump-host/conf` (POST replaces; the + full file object), so OpenBao — not the on-disk `/config/*-secrets.js` — is + authoritative after first boot. Fail-soft. `node --check` clean. +- **`docs/secrets.md`** (new) — the full secrets architecture: load path, + policy/token table, the `sso-broker` role, seeding, end-user personal + secrets, external-app convention (curl + Node `bao-conf` examples), operator + rotation, backups. Linked from the README and the docs nav (`_config.yml`). +- **README** — Configuration section rewritten (OpenBao authoritative, link to + `docs/secrets.md`); Secrets-backup section adds the `openbao-data` volume. + +### Submodule bumps +- `sso-manager-node` → **v1.16.0** (was v1.15.2-era). +- `proxy` → **v1.13.1** (was v1.12.1; passes through v1.13.0). +- `jump-host` → **v1.14.1** (was v1.11.0-era; passes through v1.14.0). +- `ldap-client` unchanged (v1.1.1). + +#### sso-manager-node — [v1.16.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.16.0) + +OpenBao becomes the central secrets store for the theta42 stack, and the SSO +Manager becomes its broker. This is the SSO's half of the move: it loads its +own secrets from OpenBao, mints scoped tokens for users and external apps, +and exposes a fixed, role-scoped personal-secrets UI. + +##### Changed +- **Secrets now load from OpenBao at boot** via + [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which + deep-merges `secret/sso-manager/conf` over the file-loaded config + (replacing the old `utils/conf_manager.js`, which did a shallow-per-key + merge). `bin/www` runs `bao-conf.init()` after `models.initORM()` and + before `listen`. Fail-soft: if OpenBao is unreachable, boot continues from + `CONF_SECRETS`. The SSO authenticates with a scoped `VAULT_TOKEN` (policy + `sso-broker`), never the root token. The admin **Configuration** UI + (`/api/conf`) now writes through `bao-conf.set('sso-manager', …)`. +- **`/api/vault` proxy reworked** — the old endpoint was an ungated + pass-through that never injected an `X-Vault-Token` (so the UI was both + ungated *and* broken). It is now `middleware.auth` → `scopeGuard` → a + token-injecting proxy. `scopeGuard` resolves a per-user (`user-`) or + per-admin (`sso-admin`) token via the new `utils/vault_broker.js` + (Redis-cached, minted through the `sso-broker` token role) and enforces a + path prefix as a second layer on top of the OpenBao policy. The client + `auth-token` is stripped; only the server-minted token reaches OpenBao. +- **Vault UI reworked and renamed** (`views/vaultwarden.ejs` → + `views/vault.ejs`; the `/vault` route is now `middleware.auth`-gated). + Non-admin users see only their `secret/users//` namespace; admins get + free-form path entry across `secret/` plus an **Apps** tab to mint scoped + tokens for external apps (`secret/apps//*`, shown once with copy + + `curl` convention). +- Bumped package version to track the release tag. + +##### Removed +- `nodejs/utils/conf_manager.js` (replaced by `@simpleworkjs/bao-conf`). +- `nodejs/views/vaultwarden.ejs` (renamed `vault.ejs`). + +##### Security +- **Committed-secrets remediation.** `config/sso-secrets.js` (LDAP bind + password, SMTP, `oauth.jwtSecret`) and `nodejs/test_plugins.js` (a + hardcoded Proxmox root API token and a UniFi password) were tracked on + master. They are now untracked + gitignored (`config/*-secrets.js`), and + `test_plugins.js` is deleted; `config/proxy-secrets.js.example` added as a + placeholder template. **The secrets remain in git history — rotation at + the providers is the real remediation and is the operator's to perform.** + OpenBao is now the authoritative store; the local files are seed artifacts + only. + +> Note: sso releases v1.12.0–v1.15.2 were tagged from merge PRs without +> corresponding `CHANGELOG.md` entries or GitHub releases; v1.16.0 resumes +> the changelog. + +#### proxy — [v1.13.1](https://github.com/theta42/proxy/releases/tag/v1.13.1) (via [v1.13.0](https://github.com/theta42/proxy/releases/tag/v1.13.0)) + +##### v1.13.1 — Fixed +- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots + don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset, + which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the + proxy exit at boot in any deployment without an OpenBao sidecar (standalone + Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing token (warn + + continue from `CONF_SECRETS`), matching the documented contract. The + theta-env stack is unaffected (it always sets a scoped `VAULT_TOKEN`). + +##### v1.13.0 — Changed +- **Secrets now load from OpenBao at boot** via + [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which + deep-merges `secret/proxy/conf` over the file-loaded config. The proxy + authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy `proxy` — + read-only on its own path), never the root token. Because the OIDC + `clientSecret` is captured at require time inside `createOidcClient` (during + `require('../models')`, which `require('../app')` triggers transitively), + `bin/www` now defers `require('../app')` until after `bao-conf.init()` + resolves. Fail-soft: if OpenBao is unreachable, boot continues from + `CONF_SECRETS`. The `config/proxy-secrets.js` file is now an operator-edit + seed artifact (gitignored); OpenBao is authoritative. +- Bumped package version to track the release tag. + +#### jump-host — [v1.14.1](https://github.com/theta42/jump-host/releases/tag/v1.14.1) (via [v1.14.0](https://github.com/theta42/jump-host/releases/tag/v1.14.0)) + +##### v1.14.1 — Fixed +- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots + don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset, + which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the + jump host exit at boot in any deployment without an OpenBao sidecar + (standalone Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing + token (warn + continue from `CONF_SECRETS`), matching the documented + contract. The theta-env stack is unaffected (it always sets a scoped + `VAULT_TOKEN`). + +##### v1.14.0 — Changed +- **Secrets now load from OpenBao at boot** via + [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which + deep-merges `secret/jump-host/conf` over the file-loaded config. The jump + host authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy + `jump-host` — read-only on its own path), never the root token. Because the + OIDC `clientSecret` is captured at require time inside `createOidcClient` + (during `require('../models')`), `bin/www` now runs `bao-conf.init()` + **before** `require('../models')`. Fail-soft: if OpenBao is unreachable, + boot continues from `CONF_SECRETS`. The `config/jump-secrets.js` file is now + an operator-edit seed artifact (gitignored); OpenBao is authoritative. +- Bumped package version to track the release tag. + ## [v1.27.2] - 2026-08-01 - Updated `proxy` to v1.12.1 (Dependabot security/maintenance bumps). diff --git a/README.md b/README.md index c239900..55c623c 100644 --- a/README.md +++ b/README.md @@ -181,11 +181,21 @@ operator-owned and `setup.env` is ignored. would 404. Idempotent; skips a host that already exists. 6. Prints your first admin login + the public URLs. -### Configuration — `./config/` (no `.env` files) +### Configuration & secrets — OpenBao + `./config/` -All config and secrets live in a bind-mounted `./config/` directory (gitignored), -read by each app's `@simpleworkjs/conf` via the `CONF_SECRETS` env var, which -the entrypoint points at the mounted file: +Secrets live in **OpenBao** (a Vault fork, container `openbao:8200` on +`theta-net`), the single authoritative store. Each app loads them at boot with +[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which +deep-merges `secret//conf` over the file-loaded config — **fail-soft**, so +if OpenBao is unreachable the app boots from the file fallback. End users get +personal per-user secret storage (`secret/users//*`) in the SSO **Vault** +UI, and admins mint scoped tokens for external apps (`secret/apps//*`). +See **[docs/secrets.md](docs/secrets.md)** for the full architecture, policies, +token model, rotation, and the external-app convention. + +A bind-mounted `./config/` directory (gitignored) holds the operator-edit +seed files and the fail-soft fallback, read by each app's `@simpleworkjs/conf` +via the `CONF_SECRETS` env var: - **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password, user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus @@ -196,11 +206,15 @@ the entrypoint points at the mounted file: same `serviceAccountPass`), `auth` (admin groups/users). `./setup.sh` generates both on first run from `./setup.env` (the one place the -domain is entered — see *Quickstart*) with random secrets. There is **no -`.env` / `proxy.env`** — edit `./config/*.js` directly. Compose only interpolates -port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can override on the -command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See `config.example/` for the -full annotated shape, and each submodule's `secrets.js.example`. +domain is entered — see *Quickstart*) with random secrets, seeds them into +OpenBao, and mints scoped per-app tokens (`SSO_VAULT_TOKEN` / +`PROXY_VAULT_TOKEN` / `JUMP_VAULT_TOKEN`) into `./.env`. There is **no +`.env` / `proxy.env`** for app config — edit `./config/*.js` directly (and +re-seed into OpenBao, or use the SSO Configuration UI for live edits). Compose +only interpolates port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can +override on the command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See +`config.example/` for the full annotated shape, and each submodule's +`secrets.js.example`. > **Migrating from an older `.env`-based deployment?** If `.env` and/or > `proxy.env` exist when you first run `./setup.sh`, it migrates them into @@ -329,8 +343,11 @@ docker compose cp sso-manager:/data/dump.rdb sso-manager.rdb docker compose exec proxy redis-cli BGSAVE docker compose cp proxy:/data/dump.rdb proxy.rdb -# Secrets +# Secrets — ./config/ (the seed/fallback; OpenBao is authoritative) cp -a ./config config-backup && chmod 700 config-backup +# OpenBao (the authoritative secret store — back up its data volume) +docker run --rm -v theta-env_openbao-data:/data -v "$PWD":/backup alpine \ + tar czf /backup/openbao-data.tgz -C /data . ``` ### Restore — full disaster recovery diff --git a/bootstrap/bootstrap.js b/bootstrap/bootstrap.js index 63ba231..cbbc8f0 100644 --- a/bootstrap/bootstrap.js +++ b/bootstrap/bootstrap.js @@ -23,6 +23,13 @@ * into the file (the sso-manager mounts ./config * read-write for this purpose). * + * Generated creds are ALSO written into OpenBao (secret/proxy/conf and, when + * the jump host is enabled, secret/jump-host/conf) so the proxy + jump host + * load them from OpenBao at boot via @simpleworkjs/bao-conf. setup.sh passes + * the root VAULT_TOKEN on this exec for that purpose. The OpenBao write is + * fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the /config + * file remains the fallback and bootstrap does not fail the bring-up over it. + * * Idempotent: re-running converges to the ./config values. The LDAP service * account + admin passwords are reset to the file values on each run; the * OAuth client is created if missing. If proxy-secrets.js already holds a @@ -87,6 +94,44 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_sso_oauth_admin']; const log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n'); const out = (k, v) => process.stdout.write(`${k}=${v}\n`); +// ── OpenBao (Vault) writes ─────────────────────────────────────────────────── +// bootstrap generates the proxy's + jump host's OAuth client creds and writes +// them back into /config/*-secrets.js (the file fallback). It ALSO writes the +// complete conf into OpenBao so the proxy + jump host load it from there at +// boot via @simpleworkjs/bao-conf. setup.sh passes the root VAULT_TOKEN on this +// exec. Fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the write +// is skipped with a warning — the file remains the fallback and bootstrap does +// not fail the bring-up over it. +const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200'; +const VAULT_TOKEN = process.env.VAULT_TOKEN || ''; + +// Re-require a /config module after its file has been rewritten on disk +// (require caches the old contents otherwise). +function freshRequire(p) { + delete require.cache[require.resolve(p)]; + return require(p); +} + +// PUT (replace) the data at secret/data/ with `data`. Warn-only. +async function baoPut(vaultPath, data) { + if (!VAULT_TOKEN) { log('OpenBao: VAULT_TOKEN unset — skipping write of secret/' + vaultPath); return; } + try { + const res = await fetch(`${VAULT_ADDR}/v1/secret/data/${vaultPath}`, { + method: 'POST', + headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + log(`WARNING: OpenBao write secret/${vaultPath} failed (${res.status}) ${text} — app will use its file fallback`); + } else { + log(`OpenBao: wrote secret/${vaultPath}`); + } + } catch (e) { + log(`WARNING: OpenBao write secret/${vaultPath} threw (${e.message}) — app will use its file fallback`); + } +} + // Replicate the SSO's hashPasswordSSHA512 (models/user_ldap.js) exactly so the // directory stores passwords the SSO can verify on bind (pw-sha2 module). function hashPasswordSSHA512(password) { @@ -663,6 +708,14 @@ async function provisionJumpHost(token) { resolvedClientId = id; } + // Mirror the (now-current) proxy-secrets.js into OpenBao so the proxy + // loads it from there at boot via @simpleworkjs/bao-conf. Re-require + // fresh: writeProxyCreds rewrote the file out from under the cached + // `proxy` object. setup.sh's seed already put a placeholder version + // here; this replaces it with the complete file (operator edits + + // generated OAuth creds). Warn-only. + await baoPut('proxy/conf', freshRequire('/config/proxy-secrets.js')); + // Provision the jump host (mint token + write config) when enabled. // Warn-only — never fail the whole bring-up over the optional service. let jumpClientId = null; @@ -670,6 +723,13 @@ async function provisionJumpHost(token) { try { jumpClientId = await provisionJumpHost(token); out('JUMP_HOST_CONFIGURED', '1'); + // Mirror jump-secrets.js (just written by provisionJumpHost) + // into OpenBao so the jump host loads it from there at boot via + // @simpleworkjs/bao-conf. setup.sh's seed may have put a + // placeholder/stale version here; this replaces it with the + // complete file (LDAP bind, minted API token, OAuth client). + // Warn-only. + await baoPut('jump-host/conf', freshRequire(JUMP_SECRETS)); } catch (e) { log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`); } diff --git a/docker-compose.yml b/docker-compose.yml index e1beadf..9130330 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,14 +59,18 @@ services: - "${LDAPS_PORT:-636}:636" # Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS. environment: - # Config (LDAP, OAuth, SMTP, ...) comes from ./config/sso-secrets.js (see - # volumes below), not from env. NODE_ENV/NODE_PORT are the only env the app - # reads that are not part of its conf tree. + # Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from + # ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf + # deep-merges secret/sso-manager/conf from OpenBao over it at boot + # (VAULT_ADDR/VAULT_TOKEN below). NODE_ENV/NODE_PORT are the only other + # env the app reads. VAULT_TOKEN is the scoped SSO_VAULT_TOKEN minted by + # setup.sh (policy sso-broker) — NOT the root token. - NODE_ENV=production - NODE_PORT=3001 - LDAP_SERVER_ID=${LDAP_SERVER_ID:-} - LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-} - - VAULT_TOKEN=${VAULT_TOKEN:-} + - VAULT_ADDR=http://openbao:8200 + - VAULT_TOKEN=${SSO_VAULT_TOKEN:-} # Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at # runtime. See the build args above for the same setting during build. - HTTP_PROXY=${CFG_HTTP_PROXY:-} @@ -116,6 +120,8 @@ services: depends_on: sso-manager: condition: service_healthy + openbao: + condition: service_started ports: - "${HTTP_PORT:-80}:80" - "${HTTPS_PORT:-443}:443" @@ -125,10 +131,17 @@ services: # to lock it to localhost once the proxy fronts it under TLS. - "${MGMT_BIND:-0.0.0.0}:${MGMT_PORT:-3000}:3000" environment: - # oidc/ldap/auth config comes from ./config/proxy-secrets.js (see volumes), - # not from env. NODE_ENV/NODE_PORT are process env the app reads directly. + # oidc/ldap/auth config is loaded by @simpleworkjs/conf from + # ./config/proxy-secrets.js (see volumes), then @simpleworkjs/bao-conf + # deep-merges secret/proxy/conf from OpenBao over it at boot. The OAuth + # clientSecret is consumed at require time, so bao-conf.init() runs + # BEFORE require('../app') in bin/www. NODE_ENV/NODE_PORT are process env + # the app reads directly. VAULT_TOKEN is the scoped PROXY_VAULT_TOKEN + # (policy proxy — read only secret/proxy/conf). - NODE_ENV=production - NODE_PORT=3000 + - VAULT_ADDR=http://openbao:8200 + - VAULT_TOKEN=${PROXY_VAULT_TOKEN:-} # Optional upstream HTTP(S) proxy for outbound calls (ACME/Let's # Encrypt, DNS providers) at runtime. - HTTP_PROXY=${CFG_HTTP_PROXY:-} @@ -137,7 +150,9 @@ services: volumes: # Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy # only reads it; the sso-manager bootstrap writes the OAuth creds. The - # entrypoint points CONF_SECRETS at /config/proxy-secrets.js. + # entrypoint points CONF_SECRETS at /config/proxy-secrets.js. Kept as a + # fail-soft fallback: bao-conf.init() is fail-soft, so if OpenBao is + # unreachable the app boots from this file instead. - ./config:/config:ro # Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local # users, AND the auto-ssl Let's Encrypt certs survive container recreation. @@ -177,11 +192,19 @@ services: depends_on: sso-manager: condition: service_healthy + openbao: + condition: service_started ports: - "${JUMP_SSH_PORT:-2222}:2222" # SSH front door - "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API environment: - NODE_ENV=production + # Secrets are loaded by @simpleworkjs/conf from ./config/jump-secrets.js, + # then @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from + # OpenBao over it at boot. VAULT_TOKEN is the scoped JUMP_VAULT_TOKEN + # (policy jump-host — read only secret/jump-host/conf). + - VAULT_ADDR=http://openbao:8200 + - VAULT_TOKEN=${JUMP_VAULT_TOKEN:-} # Optional upstream HTTP(S) proxy for outbound calls (the directory API # client) at runtime. - HTTP_PROXY=${CFG_HTTP_PROXY:-} diff --git a/docs/_config.yml b/docs/_config.yml index 46b8118..71d5dc1 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -25,6 +25,9 @@ nav: - title: Architecture page: /architecture.html icon: fa-sitemap + - title: Secrets + page: /secrets.html + icon: fa-key - title: Standalone page: /standalone.html icon: fa-puzzle-piece diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000..3393eb8 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,195 @@ +--- +layout: default +title: Secrets (OpenBao) +description: theta-env'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-env keeps **every secret in one place: [OpenBao](https://openbao.org/)** +(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](https://simpleworkjs.github.io/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) + +1. `@simpleworkjs/conf` **synchronously** loads the bind-mounted + `./config/-secrets.js` at require time — the file is the operator-edit + layer and the fail-soft fallback. +2. `@simpleworkjs/bao-conf`'s `init({ path: '', conf })` **deep-merges** + `secret/data//conf` from OpenBao over the live `conf` object. It is + **fail-soft**: if OpenBao is unreachable or the path is absent, boot + continues with the file-loaded config. +3. A few secrets are **captured at require time** (notably the OIDC + `clientSecret`, consumed inside `createOidcClient` during + `require('../models')`). So `init()` must resolve *before* that + `require()`. Each app's `bin/www` handles this: + - **proxy** — defers `require('../app')` (which transitively loads models) + behind `bao-conf.init()`. + - **jump host** — gates the explicit `require('../models')` behind + `bao-conf.init()`. + - **SSO** — swaps the old `conf_manager.init()` call (same position in its + existing `.then()` boot chain) for `bao-conf.init()`; nothing in the SSO + captures a secret at require time, so no reordering was needed. + +`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/*`; `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-` | read/write `secret/users//*` | per-user tokens (minted lazily by the broker) | +| `app-` | read/write `secret/apps//*` | 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.js` after 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//*`, reached +through the SSO UI at **Vault → My Secrets**. The SSO mints a `user-` +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//`. They can list, read, write, and delete secrets there. +- **Admins** (`app_sso_admin` / `app_super_admin`) get free-form access across + all of `secret/` 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//*`, 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-` policy. + +Convention: + +- `secret/apps//conf` for config-style secrets, `secret/apps//*` + for arbitrary keys. +- The app authenticates with the header `X-Vault-Token: ` + against `http://:8200/v1/secret/data/apps//...`. + +Non-Node consumers (curl): + +```bash +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: " -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: " | jq .data.data +``` + +Node consumers can use [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/) +directly: + +```js +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: '...' }); +``` + +## 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: + +```bash +# 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='' smtp.password='' oauth.jwtSecret='' +``` + +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`) writes `secret/sso-manager/conf` +> and updates the live conf immediately, so SMTP/discovery/oauth edits made +> there don't need a manual `bao 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.sh` on 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.js` contents moved in this phase. \ No newline at end of file diff --git a/jump-host b/jump-host index f386a5f..db3333e 160000 --- a/jump-host +++ b/jump-host @@ -1 +1 @@ -Subproject commit f386a5f9c328acbe01ebe6d749247928650d1cf6 +Subproject commit db3333e26d4f501ffbf9f10f03bb9dacb64116ae diff --git a/proxy b/proxy index a44d7ef..4aa9941 160000 --- a/proxy +++ b/proxy @@ -1 +1 @@ -Subproject commit a44d7ef7ab1f79c23fb2711505ec0d0accbba5f5 +Subproject commit 4aa994121aca59d72821672ae9d49249c57083d8 diff --git a/setup.sh b/setup.sh index 42ca693..94ea154 100755 --- a/setup.sh +++ b/setup.sh @@ -703,6 +703,119 @@ if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets list -format=js docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets enable -path=secret kv-v2 >/dev/null fi +# ── 3c. OpenBao policies, token role, per-app tokens ───────────────────────── +# Each app gets a least-privilege scoped token (a policy over only its own +# secret//conf). sso additionally gets the `sso-broker` policy so it can +# mint per-user (user-) and per-app (app-) tokens at runtime through +# the sso-broker token role. The root VAULT_TOKEN stays in .env for +# setup/maintenance ONLY and is never passed to a service container. Everything +# here is idempotent — re-running setup.sh keeps existing policies/tokens. + +# Run a `bao` command inside the openbao container as root. +bao_run() { docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao "$@"; } + +# Write an ACL policy from stdin HCL only if it does not already exist. +ensure_policy() { + local name="$1" + if bao_run policy read "$name" >/dev/null 2>&1; then + info " policy ${name} already exists — keeping." + else + info " writing policy ${name}..." + docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao policy write "$name" - >/dev/null + fi +} + +# Read KEY= from ./.env (empty if absent) — reuse a previously minted token +# instead of minting a fresh one on every setup.sh run. +env_get() { + local key="$1" file=./.env + [[ -f "$file" ]] || return 0 + grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- +} + +# Mint an orphan, renewable token for `policy` and persist it to .env as `key`, +# OR reuse the token already in .env if it is still valid (re-mint on expiry). +ensure_token() { + local key="$1" policy="$2" existing tok + existing="$(env_get "$key")" + if [[ -n "$existing" ]] && docker exec -e BAO_TOKEN="$existing" openbao bao token lookup >/dev/null 2>&1; then + info " ${key} already minted + valid — keeping." + return 0 + fi + info " minting ${key} (policy=${policy})..." + tok="$(bao_run token create -policy="$policy" -orphan=true -field=token)" \ + || die "failed to mint ${key} (policy=${policy})" + env_upsert "$key" "$tok" +} + +# Seed secret/ from a /config/*.js module on first run only +# (skipped if the path already exists). Fail-soft: a seed failure leaves the +# app's file-mounted config as the fallback — boot is not blocked. +seed_app_conf() { + local vault_path="$1" mod="$2" + if bao_run kv get "secret/${vault_path}" >/dev/null 2>&1; then + info " secret/${vault_path} already seeded — keeping." + return 0 + fi + info "Seeding secret/${vault_path} from ${mod}..." + docker exec sso-manager node -e "console.log(JSON.stringify(require('${mod}')))" 2>/dev/null \ + | docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put "secret/${vault_path}" - >/dev/null \ + || warn " could not seed secret/${vault_path} (continuing — app will use its file fallback)" +} + +info "Configuring OpenBao policies..." +# sso-broker — sso's authority to read/write its own conf, mint per-user and +# per-app tokens (auth/token/create/sso-broker), and create the matching +# user- / app- / sso-admin policies. +ensure_policy sso-broker <<'HCL' +path "secret/data/sso-manager/conf" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/sso-manager/conf" { capabilities = ["list", "read", "delete"] } +path "secret/data/users/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/users/*" { capabilities = ["list", "read", "delete"] } +path "secret/data/apps/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] } +path "auth/token/create/sso-broker" { capabilities = ["update"] } +path "sys/policies/acl/user-*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "sys/policies/acl/app-*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "sys/policies/acl/sso-admin" { capabilities = ["create", "read", "update", "delete", "list"] } +HCL +# sso-admin — admin users in the vault UI: read/write/list everything under secret/. +ensure_policy sso-admin <<'HCL' +path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/*" { capabilities = ["list", "read", "delete"] } +HCL +# proxy / jump-host — read only their own boot conf. +ensure_policy proxy <<'HCL' +path "secret/data/proxy/conf" { capabilities = ["read"] } +path "secret/metadata/proxy/conf" { capabilities = ["read", "list"] } +HCL +ensure_policy jump-host <<'HCL' +path "secret/data/jump-host/conf" { capabilities = ["read"] } +path "secret/metadata/jump-host/conf" { capabilities = ["read", "list"] } +HCL + +# sso-broker token role: lets sso mint user-*/app-*/sso-admin tokens. Orphan, +# renewable, 24h period. Wildcards need allowed_policies_glob — allowed_policies +# is exact-match only. +info "Configuring sso-broker token role..." +if ! bao_run read auth/token/roles/sso-broker >/dev/null 2>&1; then + docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/sso-broker - <<'JSON' >/dev/null +{"allowed_policies":["sso-admin"],"allowed_policies_glob":["user-*","app-*"],"orphan":true,"renewable":true,"token_period":"24h"} +JSON +else + info " token role sso-broker already exists — keeping." +fi + +info "Minting per-app OpenBao tokens (stored in .env, passed to containers as VAULT_TOKEN)..." +ensure_token SSO_VAULT_TOKEN sso-broker +ensure_token PROXY_VAULT_TOKEN proxy +ensure_token JUMP_VAULT_TOKEN jump-host + +info "OpenBao secrets configured:" +info " policies: sso-broker, sso-admin, proxy, jump-host (+ per-user/app created lazily by sso)" +info " token role: sso-broker (mints user-*/app-*/sso-admin tokens, 24h period)" +info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env" + # ── 4. Start SSO Manager, wait for health ───────────────────────────────────── # SSO_GIT_COMMIT: sso-manager-node is a git submodule here, so its .git is a # pointer file (not a real repo) -- the image can't resolve its own commit @@ -727,12 +840,15 @@ for i in $(seq 1 60); do sleep 2 done -if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv get secret/sso-manager/conf >/dev/null 2>&1; then - info "Seeding sso-manager/conf into Openbao..." - docker exec sso-manager node -e "console.log(JSON.stringify(require('/config/sso-secrets.js')))" > "$CONFIG_DIR/seed-conf.json" - cat "$CONFIG_DIR/seed-conf.json" | docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put secret/sso-manager/conf - - rm -f "$CONFIG_DIR/seed-conf.json" -fi +info "Seeding app configs into OpenBao (idempotent)..." +# sso-manager/conf holds the operator-set LDAP/SMTP/jwtSecret values — sso has +# no bootstrap-generated creds, so the file is the complete source of truth. +seed_app_conf sso-manager/conf /config/sso-secrets.js +# proxy/conf is seeded from the operator file (placeholder OAuth creds); the +# bootstrap (step 5) then writes the real generated OAuth client creds into +# OpenBao over this. proxy boots at step 6, after bootstrap, so it sees the +# real values. +seed_app_conf proxy/conf /config/proxy-secrets.js # Read the summary values (hosts, admin, base DN) back from ./config via the # running container's node — works whether ./config was generated or pre-existing. @@ -764,7 +880,11 @@ info " Admin uid: ${ADMIN_UID}" # ── 5. Run the bootstrap (writes CLIENT_ID/CLIENT_SECRET/ALREADY_CONFIGURED) ── # The bootstrap reads its inputs from /config/*.js (not env) and writes the -# generated OAuth client creds back into /config/proxy-secrets.js. No -e flags. +# generated OAuth client creds back into /config/proxy-secrets.js AND into +# OpenBao (secret/proxy/conf, secret/jump-host/conf) so the proxy + jump host +# load them from OpenBao at boot. The root VAULT_TOKEN is passed on this one +# exec so bootstrap can write those paths; it is never handed to a service +# container. info "Running bootstrap (creates/updates the LDAP service account, first admin, OAuth client)..." # Host facts for the directory seed — collected HERE (on the host; inside the # container hostname/uname describe the container, not the machine). Same @@ -786,6 +906,8 @@ BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \ -e STACK_HOST_KERNEL="$STACK_HOST_KERNEL" \ -e CFG_JUMP_HOST_ENABLED="${CFG_JUMP_HOST_ENABLED:-}" \ -e CFG_JUMP_HOST="${CFG_JUMP_HOST:-}" \ + -e VAULT_ADDR=http://openbao:8200 \ + -e VAULT_TOKEN="$VAULT_TOKEN" \ sso-manager node /bootstrap/bootstrap.js) \ || die "bootstrap failed:\n${BOOTSTRAP_OUT}" @@ -872,6 +994,11 @@ if [[ "$JUMP_ENABLED" == "1" ]]; then JUMP_GIT_COMMIT="$(git -C jump-host rev-parse --short HEAD 2>/dev/null || echo unknown)" export JUMP_GIT_COMMIT env_upsert JUMP_GIT_COMMIT "$JUMP_GIT_COMMIT" + # Seed jump-host/conf from the file bootstrap just wrote (it mints the API + # token + OAuth client into /config/jump-secrets.js at step 5). bootstrap + # also writes this to OpenBao directly, so this is a fallback for when + # bootstrap's jump provisioning warned-but-continued. + seed_app_conf jump-host/conf /config/jump-secrets.js info "Building + starting jump-host (optional; enabled via CFG_JUMP_HOST_ENABLED)..." "${COMPOSE[@]}" up -d --build jump-host diff --git a/sso-manager-node b/sso-manager-node index 74746e4..ebb5b2c 160000 --- a/sso-manager-node +++ b/sso-manager-node @@ -1 +1 @@ -Subproject commit 74746e409b8827faaf684918b8a56ac2e5a85ac0 +Subproject commit ebb5b2c2a7ae9e78c9c3f2726c6cd32dfd0bc6e5