From 59d4b65195b64f29367737917698509d9bbdd2fe Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 1 Aug 2026 12:36:08 -0400 Subject: [PATCH] v1.16.0: OpenBao as central secrets store + vault broker + UI rework - Boot: bao-conf.init('sso-manager') replaces conf_manager; deep-merges secret/sso-manager/conf over file config (fail-soft). Scoped VAULT_TOKEN (policy sso-broker), never root. - /api/vault reworked: middleware.auth -> scopeGuard -> token-injecting proxy. vault_broker.js mints Redis-cached per-user (user-) / per-admin (sso-admin) tokens via the sso-broker role; scopeGuard enforces path prefix on top of the OpenBao policy. Client auth-token stripped. - vault UI renamed (vaultwarden.ejs -> vault.ejs), /vault route auth-gated, role-scoped: users see only secret/users//, admins get free-form + Apps mint tab (secret/apps//*, token shown once). - api_conf.js writes via bao-conf.set('sso-manager', ...). - Remediation: config/*-secrets.js untracked+gitignored, test_plugins.js deleted, proxy-secrets.js.example placeholder added. Secrets remain in git history; provider-side rotation is the real fix. Co-Authored-By: Claude --- .gitignore | 5 + CHANGELOG.md | 52 ++++++ README.md | 23 +++ config/proxy-secrets.js | 6 - config/proxy-secrets.js.example | 18 ++ config/sso-secrets.js | 35 ---- nodejs/app.js | 21 +-- nodejs/bin/www | 7 +- nodejs/package-lock.json | 13 ++ nodejs/package.json | 3 +- nodejs/routes/api_conf.js | 26 ++- nodejs/routes/index.js | 22 ++- nodejs/test_plugins.js | 36 ---- nodejs/utils/conf_manager.js | 53 ------ nodejs/utils/vault_broker.js | 233 +++++++++++++++++++++++ nodejs/views/vault.ejs | 315 ++++++++++++++++++++++++++++++++ nodejs/views/vaultwarden.ejs | 230 ----------------------- 17 files changed, 718 insertions(+), 380 deletions(-) delete mode 100644 config/proxy-secrets.js create mode 100644 config/proxy-secrets.js.example delete mode 100644 config/sso-secrets.js delete mode 100644 nodejs/test_plugins.js delete mode 100644 nodejs/utils/conf_manager.js create mode 100644 nodejs/utils/vault_broker.js create mode 100644 nodejs/views/vault.ejs delete mode 100644 nodejs/views/vaultwarden.ejs diff --git a/.gitignore b/.gitignore index 56c7ca0..4dfa580 100755 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,11 @@ ops/cookbooks/vendor secrets.json secrets.js +# Per-deployment secret files (real LDAP/SMTP/jwtSecret + generated OAuth +# creds). theta-env bind-mounts ./config and generates/fills these at setup; +# they must never be committed. The empty *.example templates ARE tracked. +config/*-secrets.js + # Jekyll build artifact (GitHub Pages builds remotely; ignore locally) docs/_site diff --git a/CHANGELOG.md b/CHANGELOG.md index d82ce42..44bb1f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,58 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. +## [1.16.0] - 2026-08-01 + +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: releases v1.12.0–v1.15.2 were tagged from merge PRs without +> corresponding `CHANGELOG.md` entries or GitHub releases; this entry +> resumes the changelog at v1.16.0. + ## [1.11.0] - 2026-07-31 Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing. diff --git a/README.md b/README.md index 055bef1..1e7366e 100755 --- a/README.md +++ b/README.md @@ -132,6 +132,29 @@ v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*. +## Secrets + +Secrets are loaded 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 (fail-soft: +if OpenBao is unreachable, boot continues from `CONF_SECRETS`). The SSO +authenticates to OpenBao with the scoped `VAULT_TOKEN` (env, policy +`sso-broker`) — never the root token. + +The SSO also acts as the **vault broker** for the whole stack: it mints +per-user (`user-`) and per-admin (`sso-admin`) tokens through the +`sso-broker` token role and exposes the personal-secrets UI at **Vault → My +Secrets** (`secret/users//*`, server-side token injection + path-scope +guard) and an admin **Apps** tab to mint scoped tokens for external apps +(`secret/apps//*`). The old `utils/conf_manager.js` was replaced by +`@simpleworkjs/bao-conf`; the admin **Configuration** UI (`/api/conf`) now +writes `secret/sso-manager/conf` through `bao-conf.set`. + +The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored), +not the authoritative store. For the full architecture, policies, token model, +and rotation procedure, see theta-env's +**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**. + ## Architecture ``` diff --git a/config/proxy-secrets.js b/config/proxy-secrets.js deleted file mode 100644 index fc9f327..0000000 --- a/config/proxy-secrets.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - oidc: { - clientId: '', - clientSecret: '', - }, -}; diff --git a/config/proxy-secrets.js.example b/config/proxy-secrets.js.example new file mode 100644 index 0000000..c223f78 --- /dev/null +++ b/config/proxy-secrets.js.example @@ -0,0 +1,18 @@ +'use strict'; + +// Example proxy secrets file. theta-env generates a real ./config/proxy-secrets.js +// from this shape at setup (with empty clientId/clientSecret), then bootstrap.js +// writes the SSO-generated OAuth client creds into it AND into OpenBao +// (secret/proxy/conf). The proxy loads it via @simpleworkjs/conf, then overlays +// secret/proxy/conf from OpenBao via @simpleworkjs/bao-conf at boot. +// +// The real file is gitignored (config/*-secrets.js) — never commit live creds. +// This .example is tracked to document the expected shape only. +module.exports = { + oidc: { + // The SSO registers the proxy as an OAuth client and writes the real + // values here (and into OpenBao). "set-me" is the bootstrap placeholder. + clientId: 'set-me', + clientSecret: 'set-me', + }, +}; \ No newline at end of file diff --git a/config/sso-secrets.js b/config/sso-secrets.js deleted file mode 100644 index b4a0ef1..0000000 --- a/config/sso-secrets.js +++ /dev/null @@ -1,35 +0,0 @@ -'use strict'; - -// Local per-deployment configuration for this Theta42 instance. -// This file is gitignored — it contains real secrets and per-deployment -// values. The committed conf/base.js now ships generic defaults -// (example.com / localhost); the theta42-specific non-secret values that -// used to live in base.js have been migrated here so this instance keeps -// working. New deployments should put their own values here or in app_* env. -module.exports = { - port: 3001, - name: 'Theta42 SSO', - ldap: { - url: 'ldap://10.2.0.54', - bindDN: 'cn=admin,dc=theta42,dc=com', - bindPassword: 'Tomisgaypalm7', - userBase: 'ou=people,dc=theta42,dc=com', - groupBase: 'ou=groups,dc=theta42,dc=com', - }, - smtp: { - host: 'mail.wgnode.com', - user: 'noreply@users.theta42.com', - // user: '', - pass: 'ZxAsQw!2', - from: 'Theta42 Accounts ', - }, - voipms: { - username: 'wmantly@gmail.com', - password: 'EMjQvAuHhD!d5dm', - did: '9297353350', - }, - oauth: { - issuer: 'https://sso.theta42.com', - jwtSecret: '09e2501a1c93aef4d5d713c7db17c800c6d7d6f5f9e9cf2efbdfa37549021bf9', - }, -}; \ No newline at end of file diff --git a/nodejs/app.js b/nodejs/app.js index 7b856f3..677ae12 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -108,18 +108,17 @@ app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client')); app.get('/.well-known/openid-configuration', discovery); app.use('/api/webhook', require('./routes/webhook')); app.use('/api/plugins', middleware.auth, require('./routes/plugins')); -const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware'); -const vaultApiProxy = createProxyMiddleware({ - target: 'http://openbao:8200', - changeOrigin: true, - pathRewrite: { '^/': '/v1/' }, - on: { - proxyReq: fixRequestBody - } -}); - -app.use('/api/vault', middleware.auth, vaultApiProxy); +// OpenBao vault API. The broker mints a server-side scoped token per user +// (per-user user- or, for admins, sso-admin), enforces the path prefix +// (scopeGuard), and injects ONLY that token into the proxied request — the +// client's sso auth headers are stripped and never reach OpenBao. Non-admins +// are confined to secret/users//*; admins roam all of secret/. The +// admin-only app-token mint route is mounted BEFORE the proxy so it isn't +// shadowed by the catch-all /api/vault proxy. +const vaultBroker = require('./utils/vault_broker'); +app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter); +app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy()); // Catch 404 and forward to error handler. If none of the above routes are // used, this is what will be called. diff --git a/nodejs/bin/www b/nodejs/bin/www index 6b1d2a9..6550c5a 100755 --- a/nodejs/bin/www +++ b/nodejs/bin/www @@ -31,7 +31,12 @@ const models = require('../models'); * Initialize ORM, then Listen on provided port, on all network interfaces. */ models.initORM().then(() => { - return require('../utils/conf_manager').init(); + // Overlay secret/sso-manager/conf from OpenBao over the file-loaded conf. + // Fail-soft: if OpenBao is unreachable, conf keeps the ./config/sso-secrets.js + // values and boot continues. (Same position the old conf_manager held, so + // call-time conf readers — which is how sso consumes its secrets — are + // unaffected; nothing in sso captures a secret at require time.) + return require('@simpleworkjs/bao-conf').init({ path: 'sso-manager', conf }); }).then(() => { server.listen(port); server.on('error', onError); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index d19077e..478d7f6 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -12,6 +12,7 @@ "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", "@simpleworkjs/app-stack": "^1.0.0", + "@simpleworkjs/bao-conf": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.1.0", "@simpleworkjs/frontend": "^0.2.7", @@ -1347,6 +1348,18 @@ "node": ">=18.0.0" } }, + "node_modules/@simpleworkjs/bao-conf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.0.tgz", + "integrity": "sha512-HxB2ohFuDKbwTfNh5dXCot0dd6qoP+3Ebz1xKH0eOhKNVNMjHU1p7XZv2VTe+VnHn+DV22Eh8lAgWxnX/pkXUw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@simpleworkjs/conf": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index a37abb8..db6617d 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.13.0", + "version": "1.16.0", "description": "A very simple LDAP management and SSO system", "author": [ { @@ -24,6 +24,7 @@ "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", "@simpleworkjs/app-stack": "^1.0.0", + "@simpleworkjs/bao-conf": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.1.0", "@simpleworkjs/frontend": "^0.2.7", diff --git a/nodejs/routes/api_conf.js b/nodejs/routes/api_conf.js index 217da3f..ba21072 100644 --- a/nodejs/routes/api_conf.js +++ b/nodejs/routes/api_conf.js @@ -1,5 +1,5 @@ const router = require('express').Router(); -const confManager = require('../utils/conf_manager'); +const baoConf = require('@simpleworkjs/bao-conf'); const permission = require('../utils/permission'); const conf = require('@simpleworkjs/conf'); @@ -21,9 +21,23 @@ router.get('/', async (req, res) => { res.json(editable); }); +// Shallow-per-key merge of `src` into the live conf object (matches the old +// conf_manager.applyConf behaviour: nested objects are spread, not deep-merged, +// so call-time conf readers see saved values without a restart). +function applyToLiveConf(src) { + if (!src) return; + for (const key of Object.keys(src)) { + if (typeof src[key] === 'object' && src[key] !== null && !Array.isArray(src[key])) { + conf[key] = { ...(conf[key] || {}), ...src[key] }; + } else { + conf[key] = src[key]; + } + } +} + router.post('/', async (req, res, next) => { try { - const existing = await confManager.getVaultConf() || {}; + const existing = await baoConf.get('sso-manager/conf') || {}; // Deep merge req.body into existing for (const key of Object.keys(req.body)) { if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) { @@ -32,11 +46,15 @@ router.post('/', async (req, res, next) => { existing[key] = req.body[key]; } } - await confManager.setVaultConf(existing); + await baoConf.set('sso-manager/conf', existing); + // Reflect the saved values in the live conf immediately (the next boot's + // bao-conf.init() would pick them up too, but this keeps running readers + // current without a restart, as the old conf_manager did). + applyToLiveConf(existing); res.json({ success: true }); } catch(err) { next(err); } }); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 1dfa413..fbb2050 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -11,6 +11,8 @@ const {Tos} = require('../models/tos'); const conf = require('@simpleworkjs/conf'); const buildInfo = require('../utils/build_info'); const { mountStaticModules } = require('@simpleworkjs/app-stack'); +const middleware = require('../middleware/auth'); +const permission = require('../utils/permission'); const values ={ title: conf.environment !== 'production' ? `dev` : '', @@ -65,7 +67,6 @@ router.get('/dashboard', (req, res) => res.redirect(301, '/overview')); router.get('/executive', (req, res) => res.redirect(301, '/overview')); router.get('/conf', async function(req, res, next) { - const permission = require('../utils/permission'); try { await permission.byGroup(req.user, ['app_sso_admin']); res.render('conf', {...values}); @@ -86,8 +87,23 @@ router.get('/plugins', function(req, res, next) { res.redirect('/directory'); }); -router.get('/vault', function(req, res, next) { - res.render('vaultwarden', {...values}); +router.get('/vault', middleware.auth, async function(req, res, next) { + // Personal per-user secrets (secret/users//*) for everyone; admins get + // free-form access across all of secret/ plus an Apps tab to mint scoped + // tokens for external apps. The /api/vault proxy enforces the same scoping + // server-side (scopeGuard + the token's own OpenBao policy). + let isAdmin = false; + try { + await permission.byGroup(req.user, ['app_sso_admin']); + isAdmin = true; + } catch (e) { /* non-admin: personal namespace only */ } + res.render('vault', { + ...values, + vaultUid: req.user.uid, + vaultIsAdmin: isAdmin, + vaultBase: isAdmin ? '' : `users/${req.user.uid}/`, + vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200', + }); }); // Linkable deep-link to a single resource's modal, e.g. from the resource diff --git a/nodejs/test_plugins.js b/nodejs/test_plugins.js deleted file mode 100644 index 691968d..0000000 --- a/nodejs/test_plugins.js +++ /dev/null @@ -1,36 +0,0 @@ -const proxmox = require('./plugins/discovery/proxmox'); -const unifi = require('./plugins/discovery/unifi'); - -async function test() { - console.log("=== Running Proxmox Plugin ==="); - try { - const pveData = await proxmox.discover({ - url: 'https://dl380-0.internal.718it.biz:8006', - tokenId: 'root@pam!agy', - tokenSecret: '1e7c0e31-6767-4295-bcda-d7acf5df1d9a' - }); - console.log(`Found ${pveData.resources.length} resources and ${pveData.edges.length} edges.`); - console.log("Sample resource:"); - console.log(JSON.stringify(pveData.resources[0], null, 2)); - console.log("Sample edge:"); - console.log(JSON.stringify(pveData.edges[0], null, 2)); - } catch (e) { - console.error("Proxmox failed:", e.message); - } - - console.log("\n=== Running Unifi Plugin ==="); - try { - const unifiData = await unifi.discover({ - url: 'https://unifi.718it.biz', - user: 'agy', - password: 'MyPassword!23' - }); - console.log(`Found ${unifiData.resources.length} resources and ${unifiData.edges.length} edges.`); - console.log("Sample resource:"); - console.log(JSON.stringify(unifiData.resources.find(r => r.kind === 'network_device'), null, 2)); - } catch (e) { - console.error("Unifi failed:", e.message); - } -} - -test(); diff --git a/nodejs/utils/conf_manager.js b/nodejs/utils/conf_manager.js deleted file mode 100644 index 17ad2f3..0000000 --- a/nodejs/utils/conf_manager.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; -const conf = require('@simpleworkjs/conf'); -const VAULT_URL = process.env.VAULT_ADDR || 'http://openbao:8200'; -const VAULT_TOKEN = process.env.VAULT_TOKEN || ('ro' + 'ot'); - -async function getVaultConf() { - try { - const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, { - headers: { 'X-Vault-Token': VAULT_TOKEN } - }); - if (res.status === 200) { - const json = await res.json(); - return json.data.data; - } - } catch (err) { - console.error('Error fetching conf from Vault:', err); - } - return null; -} - -async function setVaultConf(newConf) { - const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, { - method: 'POST', - headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' }, - body: JSON.stringify({ data: newConf }) - }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Vault API error: ${res.status} ${text}`); - } - applyConf(newConf); -} - -function applyConf(newConf) { - if (!newConf) return; - // Deep merge into conf - for (const key of Object.keys(newConf)) { - if (typeof newConf[key] === 'object' && newConf[key] !== null && !Array.isArray(newConf[key])) { - conf[key] = { ...conf[key], ...newConf[key] }; - } else { - conf[key] = newConf[key]; - } - } -} - -async function init() { - const vaultConf = await getVaultConf(); - if (vaultConf) { - applyConf(vaultConf); - } -} - -module.exports = { getVaultConf, setVaultConf, init }; diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js new file mode 100644 index 0000000..821af74 --- /dev/null +++ b/nodejs/utils/vault_broker.js @@ -0,0 +1,233 @@ +'use strict'; + +// Vault broker — mints scoped OpenBao tokens for end users, admins, and +// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the +// `sso-broker` token role created by theta-env/setup.sh. +// +// secret/users//* per-user personal KV (user- policy) +// secret/apps//* per-external-app namespace (app- policy) +// secret/* admin UI sessions (sso-admin policy) +// +// The sso-broker policy grants update on auth/token/create/sso-broker and on +// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to +// create the per-subject policies and mint their tokens. Per-user/admin tokens +// are cached in Redis for the token's lifetime and re-minted on miss; per-app +// tokens are returned ONCE (displayed in the UI, never stored retrievably). + +const baoConf = require('@simpleworkjs/bao-conf'); +const { createClient } = require('redis'); +const express = require('express'); +const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware'); +const conf = require('@simpleworkjs/conf'); +const permission = require('./permission'); + +const ROLE = 'sso-broker'; +const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h) + +let redisClient; +async function getRedis() { + if (!redisClient) { + const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis + : (conf.redis && conf.redis.url) ? conf.redis.url : undefined; + redisClient = createClient({ url }); + redisClient.on('error', (err) => console.error('Redis vault_broker error', err)); + await redisClient.connect(); + } + return redisClient; +} + +async function cacheGet(key) { + try { return await (await getRedis()).get(key); } catch (e) { return null; } +} +async function cacheSet(key, value, ttl) { + try { await (await getRedis()).set(key, value, { EX: ttl }); } catch (e) { /* best-effort */ } +} + +// Low-level OpenBao call via @simpleworkjs/bao-conf.request (authenticates with +// SSO_VAULT_TOKEN). Throws on non-2xx. +async function bao(method, path, body) { + const res = await baoConf.request(method, path, body); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`OpenBao ${method} ${path} failed (${res.status}) ${text}`); + } + return res; +} + +// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create. +async function ensurePolicy(name, hcl) { + const existing = await baoConf.request('GET', `sys/policies/acl/${name}`); + if (existing.status === 200) return; + if (existing.status !== 404) { + const t = await existing.text().catch(() => ''); + throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`); + } + await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl }); +} + +// Mint a token through the sso-broker role with the given policies. Returns +// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL). +async function mintToken(policies) { + const res = await bao('POST', 'auth/token/create/sso-broker', { policies }); + const json = await res.json(); + const token = json && json.auth && json.auth.client_token; + if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`); + const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL; + return { token, ttl }; +} + +// ── Per-user token ────────────────────────────────────────────────────────── +function userPolicyHcl(uid) { + // uid is an LDAP uid (alphanumeric + a few separators); it is interpolated + // into a policy path, so reject anything but a safe charset. + return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`; +} + +// Mint (or return the cached) per-user token confined to secret/users//*. +// Re-minted when the cache entry expires (a little before the token's own TTL). +async function getOrCreateUserToken(uid) { + if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`); + const cacheKey = `vault_token:${uid}`; + const cached = await cacheGet(cacheKey); + if (cached) return cached; + await ensurePolicy(`user-${uid}`, userPolicyHcl(uid)); + const { token, ttl } = await mintToken([`user-${uid}`]); + await cacheSet(cacheKey, token, Math.max(ttl - 60, 60)); + return token; +} + +// ── Admin token (read/write all of secret/) ───────────────────────────────── +async function getOrCreateAdminToken(uid) { + const cacheKey = `vault_token:admin:${uid || 'global'}`; + const cached = await cacheGet(cacheKey); + if (cached) return cached; + const { token, ttl } = await mintToken(['sso-admin']); + await cacheSet(cacheKey, token, Math.max(ttl - 60, 60)); + return token; +} + +// ── Per-app token (minted ONCE, returned to the caller, never cached) ─────── +function appPolicyHcl(name) { + return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`; +} + +// Create the app- policy + mint a token for it. Returns the token ONCE +// (the admin UI shows it with a copy button); it is not stored retrievably, so +// a later compromise of an admin session cannot recover previously-minted app +// tokens. The caller must record it in the external app immediately. +async function mintAppToken(name) { + if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) { + throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)'); + } + await ensurePolicy(`app-${name}`, appPolicyHcl(name)); + const { token, ttl } = await mintToken([`app-${name}`]); + return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` }; +} + +// ── /api/vault proxy: scope guard + token-injecting proxy ─────────────────── +// Replaces the old bare pass-through (which sent no X-Vault-Token and gated +// nothing). The guard mints a server-side token for the user (per-user or +// admin) and enforces the path prefix as defense-in-depth on top of the +// token's own policy; the proxy injects ONLY that token and strips the +// client's sso auth headers so OpenBao never sees them. + +const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200'; +const ADMIN_GROUP = 'app_sso_admin'; + +async function isAdmin(user) { + try { + await permission.byGroup(user, [ADMIN_GROUP]); + return true; + } catch (e) { + return false; + } +} + +// Normalize a KV-v2 request path by stripping the data/metadata segment so the +// prefix check works on the logical path: /secret/data/users/alice/foo -> +// /secret/users/alice/foo. Returns null if the path isn't under /secret/. +function normalizeVaultPath(p) { + const norm = p.replace(/^\/secret\/(data|metadata)\//, '/secret/'); + if (norm !== '/secret' && !norm.startsWith('/secret/')) return null; + return norm; +} + +async function scopeGuard(req, res, next) { + if (!req.user || req.user.isMachine) { + return res.status(403).json({ error: 'machine tokens cannot use the vault API' }); + } + const uid = req.user.uid; + const admin = await isAdmin(req.user); + let token; + try { + token = admin ? await getOrCreateAdminToken(uid) : await getOrCreateUserToken(uid); + } catch (e) { + return res.status(503).json({ error: 'vault broker unavailable', detail: e.message }); + } + + // Defense-in-depth: confirm the requested path is within the subject's + // namespace. Admins roam all of secret/; users are confined to + // secret/users//. (The token's own policy enforces the same at the + // OpenBao layer; this catches a buggy/malicious client early with a clear + // 403 instead of an opaque OpenBao denial.) + const norm = normalizeVaultPath(req.path); + if (norm === null) { + return res.status(403).json({ error: 'vault paths must be under /secret/' }); + } + const base = `/secret/users/${uid}`; + const allowed = admin || norm === base || norm.startsWith(base + '/'); + if (!allowed) { + return res.status(403).json({ error: 'path outside your vault namespace' }); + } + + req.vaultToken = token; + req.vaultIsAdmin = admin; + next(); +} + +function vaultProxy() { + return createProxyMiddleware({ + target: VAULT_ADDR, + changeOrigin: true, + pathRewrite: { '^/': '/v1/' }, + on: { + proxyReq(proxyReq, req, res, options) { + fixRequestBody(proxyReq, req, res, options); + // Inject ONLY the server-minted scoped token; strip the client's + // sso session/api auth so it never reaches OpenBao. + proxyReq.setHeader('X-Vault-Token', req.vaultToken); + proxyReq.removeHeader('auth-token'); + proxyReq.removeHeader('authorization'); + }, + }, + }); +} + +// Admin-only: mint a one-time token for an external app. POST /api/vault/apps +// { name } -> { token, ttl, policy, path }. The token is returned ONCE and is +// not cached/stored retrievably. Mount BEFORE the /api/vault proxy. +const mintAppRouter = express.Router(); +mintAppRouter.post('/', async (req, res, next) => { + try { + await permission.byGroup(req.user, [ADMIN_GROUP]); + const name = (req.body && req.body.name || '').trim(); + if (!name) return res.status(400).json({ error: 'name is required' }); + const result = await mintAppToken(name); + res.json(result); + } catch (e) { + if (e.status === 401) return res.status(403).json({ error: 'admin only' }); + next(e); + } +}); + +module.exports = { + getOrCreateUserToken, + getOrCreateAdminToken, + mintAppToken, + ensurePolicy, + scopeGuard, + vaultProxy, + mintAppRouter, +}; \ No newline at end of file diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs new file mode 100644 index 0000000..aa58c3e --- /dev/null +++ b/nodejs/views/vault.ejs @@ -0,0 +1,315 @@ +<%- include('top') %> + +
+
+

+ <% if (vaultIsAdmin) { %> Vault Secrets (admin — all of secret/) + <% } else { %> My Secrets (personal namespace)<% } %> +

+ +
+ +
+ +
+
+ +
+
+
+
+
Secrets List
+
+
Loading...
+
+
+
+
+ +
+ +

Select a secret to view its details

+
+
+
+
+ + + <% if (vaultIsAdmin) { %> +
+
+
+
+
Mint an app token
+
+

Mints a scoped OpenBao token confined to secret/apps/<name>/* for an external app. The token is shown once — record it in the app immediately; it cannot be recovered later.

+
+ + +
+ +
+
+
+
+
+
+
+
App token
+ +
+
+

Give the external app this token (header X-Vault-Token) and the path convention below.

+

+              
Connection convention
+
VAULT_ADDR=<%- vaultAddr %>
+path=secret/apps/<name>/conf
+curl "$VAULT_ADDR/v1/secret/data/apps//conf" \
+  -H "X-Vault-Token: <token above>"
+
+
+
+
+
+ <% } %> +
+
+ + + + + + +<%- include('bottom') %> \ No newline at end of file diff --git a/nodejs/views/vaultwarden.ejs b/nodejs/views/vaultwarden.ejs deleted file mode 100644 index 26eb8d8..0000000 --- a/nodejs/views/vaultwarden.ejs +++ /dev/null @@ -1,230 +0,0 @@ -<%- include('top') %> - -
-
-

Vault Secrets

- -
- -
-
-
-
-
Secrets List
-
-
-
Loading...
-
-
-
-
- - -
- -

Select a secret to view its details

-
-
-
-
- - - - - - -<%- include('bottom') %>