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-<uid>) /
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/<uid>/, admins get free-form +
Apps mint tab (secret/apps/<name>/*, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||
@@ -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/<uid>/* per-user personal KV (user-<uid> policy)
|
||||
// secret/apps/<name>/* per-external-app namespace (app-<name> 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/<uid>/*.
|
||||
// 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-<name> 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/<uid>/. (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,
|
||||
};
|
||||
Reference in New Issue
Block a user