948fef4adc
- vault_broker: always reconcile policy content before serving a cached token (compare-and-skip), so stale stored policies can't cause a recurring 403 'permission denied'; policy content is parsed live by OpenBao, so edits apply to existing tokens immediately. - Shared secrets: publish to secret/shared/<owner>/<slug>; grant read to users and apps by editing the grantee's policy content (live-applied). New SharedSecret/SharedSecretGrant ORM models, /api/shared-secrets router, and a Shared tab in the vault UI. - package.json + lockfile bumped to 1.21.0 to match the tag. Co-Authored-By: Claude <noreply@anthropic.com>
342 lines
15 KiB
JavaScript
342 lines
15 KiB
JavaScript
'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/shared/<uid>/* user-owned shared KV (user-<uid> policy)
|
|
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
|
// secret/shared/<owner>/<slug> granted read (added to grantee's 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).
|
|
//
|
|
// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT
|
|
// live at token use (only the SET of policy names on a token is fixed at mint),
|
|
// so we ALWAYS reconcile a subject's policy content BEFORE returning any token
|
|
// — cached or freshly minted. That way a stale cached token immediately gains
|
|
// corrected/revoked capabilities, and a new shared-secret grant takes effect for
|
|
// an existing grantee token with no re-mint. The Redis cache only short-circuits
|
|
// token MINTING, never policy reconciliation.
|
|
|
|
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 { SharedSecret } = require('../models/shared_secret');
|
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
|
|
|
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 carries exactly `hcl`. Compare-and-skip: read the current
|
|
// content and only PUT when it differs. `bao policy write` is an idempotent
|
|
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
|
|
// grant) propagate immediately because OpenBao parses policy content at use.
|
|
async function ensurePolicy(name, hcl) {
|
|
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
|
if (existing.status !== 200 && existing.status !== 404) {
|
|
const t = await existing.text().catch(() => '');
|
|
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
|
}
|
|
if (existing.status === 200) {
|
|
const body = await existing.json().catch(() => null);
|
|
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
|
|
}
|
|
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 };
|
|
}
|
|
|
|
// ── Shared-secret policy rules ───────────────────────────────────────────────
|
|
// Returns the HCL rules granting `read` on every shared secret the given
|
|
// grantee (a user uid or an app name) has been granted. Enforcement is
|
|
// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take
|
|
// effect for the grantee's existing token immediately (no re-mint).
|
|
async function sharedPolicyRules(granteeType, granteeId) {
|
|
const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId);
|
|
if (!grants.length) return '';
|
|
const secretIds = [...new Set(grants.map(g => g.secretId))];
|
|
const secrets = secretIds.length
|
|
? await SharedSecret.list({ where: { id: { in: secretIds } } }) : [];
|
|
const byId = new Map(secrets.map(s => [s.id, s]));
|
|
const rules = [];
|
|
for (const g of grants) {
|
|
const sec = byId.get(g.secretId);
|
|
if (!sec) continue;
|
|
const p = sec.path(); // shared/<ownerUid>/<slug>
|
|
rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`);
|
|
rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`);
|
|
}
|
|
return rules.join('\n');
|
|
}
|
|
|
|
// ── Per-user token ──────────────────────────────────────────────────────────
|
|
async function userPolicyHcl(uid) {
|
|
const granted = await sharedPolicyRules('user', uid);
|
|
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
${granted}`.trim();
|
|
}
|
|
|
|
// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
|
|
// (compare-and-skip) before the cache is consulted, so a cached token can never
|
|
// outlive a policy change; the cache only short-circuits re-minting. 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}`);
|
|
await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid));
|
|
const cacheKey = `vault_token:${uid}`;
|
|
const cached = await cacheGet(cacheKey);
|
|
if (cached) return cached;
|
|
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/) ─────────────────────────────────
|
|
function adminPolicyHcl() {
|
|
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
|
|
}
|
|
|
|
async function getOrCreateAdminToken(uid) {
|
|
await ensurePolicy('sso-admin', adminPolicyHcl());
|
|
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) ───────
|
|
async function appPolicyHcl(name) {
|
|
const granted = await sharedPolicyRules('app', name);
|
|
return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
${granted}`.trim();
|
|
}
|
|
|
|
// 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. Later
|
|
// grants to the app edit app-<name> policy content (live-applied to this token).
|
|
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}`, await appPolicyHcl(name));
|
|
const { token, ttl } = await mintToken([`app-${name}`]);
|
|
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
|
}
|
|
|
|
// ── Grant / revoke shared-secret access ─────────────────────────────────────
|
|
// Creating a grant writes the DB row and then edits the grantee's policy content
|
|
// to add read on the shared path; revoking removes both. Because OpenBao parses
|
|
// policy content live, the change applies to the grantee's existing token
|
|
// immediately — no token re-mint, no cache invalidation needed.
|
|
async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) {
|
|
const grant = await SharedSecretGrant.create({
|
|
secretId, granteeType, granteeId, capability: 'read',
|
|
created_by: actorUid, created_on: Date.now(),
|
|
updated_by: actorUid, updated_on: Date.now(),
|
|
});
|
|
await reconcileGrantee(granteeType, granteeId);
|
|
return grant;
|
|
}
|
|
|
|
async function revokeSharedSecret(grantId, actorUid) {
|
|
const grant = await SharedSecretGrant.get(grantId);
|
|
if (!grant) return null;
|
|
const { granteeType, granteeId } = grant;
|
|
await grant.delete();
|
|
await reconcileGrantee(granteeType, granteeId);
|
|
return grant;
|
|
}
|
|
|
|
// Recompute and rewrite a grantee's policy content after a grant/revoke.
|
|
async function reconcileGrantee(granteeType, granteeId) {
|
|
if (granteeType === 'user') {
|
|
await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId));
|
|
} else if (granteeType === 'app') {
|
|
await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId));
|
|
} else {
|
|
throw new Error(`invalid granteeType: ${granteeType}`);
|
|
}
|
|
}
|
|
|
|
// ── /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_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
|
const ADMIN_GROUP = 'app_sso_admin';
|
|
|
|
async function isAdmin(user) {
|
|
try {
|
|
await permission.byGroup(user, ADMIN_GROUPS);
|
|
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 });
|
|
}
|
|
|
|
const norm = normalizeVaultPath(req.path);
|
|
if (norm === null) {
|
|
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
|
}
|
|
const userBase = `/secret/users/${uid}`;
|
|
const sharedBase = `/secret/shared`;
|
|
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
|
|
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: { '^/api/vault': '/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,
|
|
// sharing
|
|
SharedSecret,
|
|
SharedSecretGrant,
|
|
userPolicyHcl,
|
|
appPolicyHcl,
|
|
grantSharedSecret,
|
|
revokeSharedSecret,
|
|
reconcileGrantee,
|
|
};
|