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>
209 lines
11 KiB
JavaScript
209 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
// Shared-secrets API.
|
|
//
|
|
// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with
|
|
// its DATA in OpenBao at secret/shared/<ownerUid>/<slug> (KV-v2). The owner has
|
|
// full R/W/list on their own secret/shared/<ownerUid>/* subtree; each grantee's
|
|
// OpenBao policy content is edited to add read on the exact shared path (see
|
|
// vault_broker.js grantSharedSecret/revokeSharedSecret). Enforcement is entirely
|
|
// the OpenBao ACL — the broker's policy reconciliation makes a grant effective
|
|
// immediately, with no token re-mint.
|
|
//
|
|
// Reads of the secret DATA are intentionally NOT proxied here: the UI fetches
|
|
// them through the existing /api/vault proxy using the requester's own session
|
|
// token, so OpenBao ACL enforces read access per-request. This router handles
|
|
// metadata CRUD + grant management; KV writes (create/update/delete) are made
|
|
// server-side using the acting user's scoped token.
|
|
|
|
const express = require('express');
|
|
const baoConf = require('@simpleworkjs/bao-conf');
|
|
const permission = require('../utils/permission');
|
|
const { SharedSecret } = require('../models/shared_secret');
|
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
|
const vaultBroker = require('../utils/vault_broker');
|
|
|
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
|
|
const router = express.Router();
|
|
|
|
// Machine/service tokens cannot manage shared secrets (mirrors scopeGuard on the
|
|
// /api/vault proxy — personal, per-user secret management only).
|
|
router.use((req, res, next) => {
|
|
if (req.user && req.user.isMachine) {
|
|
return res.status(403).json({ error: 'machine tokens cannot manage shared secrets' });
|
|
}
|
|
next();
|
|
});
|
|
|
|
async function isAdmin(user) {
|
|
try { await permission.byGroup(user, ADMIN_GROUPS); return true; }
|
|
catch (e) { return false; }
|
|
}
|
|
|
|
// Scoped OpenBao token for an actor, used for server-side KV writes. Owner uses
|
|
// their own token (R/W on secret/shared/<ownerUid>/*); an admin uses the
|
|
// sso-admin token (R/W on secret/*).
|
|
async function actorToken(user, ownerUid) {
|
|
if (user.uid === ownerUid) return vaultBroker.getOrCreateUserToken(ownerUid);
|
|
if (await isAdmin(user)) return vaultBroker.getOrCreateAdminToken(user.uid);
|
|
return null;
|
|
}
|
|
|
|
// Does this user manage the given shared secret? Owner or admin.
|
|
async function canManage(user, secret) {
|
|
if (user.uid === secret.ownerUid) return true;
|
|
return isAdmin(user);
|
|
}
|
|
|
|
async function loadSecret(req, res) {
|
|
const secret = await SharedSecret.get(req.params.id);
|
|
if (!secret) { res.status(404).json({ error: 'not found' }); return null; }
|
|
return secret;
|
|
}
|
|
|
|
// ── List: mine + shared-with-me ─────────────────────────────────────────────
|
|
router.get('/', async (req, res, next) => {
|
|
try {
|
|
const uid = req.user.uid;
|
|
const mine = await SharedSecret.list({ where: { ownerUid: uid } });
|
|
const grants = await SharedSecretGrant.listForGrantee('user', uid);
|
|
const granteeSecretIds = [...new Set(grants.map(g => g.secretId))];
|
|
const granted = granteeSecretIds.length
|
|
? await SharedSecret.list({ where: { id: { in: granteeSecretIds } } }) : [];
|
|
const byId = new Map(mine.map(s => [s.id, { role: 'owner', ...s }]));
|
|
for (const g of granted) {
|
|
if (byId.has(g.id)) continue; // already owner
|
|
byId.set(g.id, { role: 'grantee', ...g });
|
|
}
|
|
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ── Create ──────────────────────────────────────────────────────────────────
|
|
router.post('/', async (req, res, next) => {
|
|
try {
|
|
const uid = req.user.uid;
|
|
const slug = String(req.body.slug || '').trim().toLowerCase();
|
|
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' });
|
|
const description = String(req.body.description || '').trim();
|
|
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
|
|
|
if (await SharedSecret.getBySlug(slug)) {
|
|
return res.status(409).json({ error: `a shared secret named '${slug}' already exists` });
|
|
}
|
|
const token = await actorToken(req.user, uid);
|
|
if (!token) return res.status(403).json({ error: 'not allowed' });
|
|
const path = SharedSecret.pathFor(uid, slug);
|
|
await baoConf.set(path, data, { token });
|
|
|
|
const secret = await SharedSecret.create({
|
|
slug, ownerUid: uid, description,
|
|
created_by: uid, created_on: Date.now(), updated_by: uid, updated_on: Date.now(),
|
|
});
|
|
res.status(201).json({ id: secret.id, slug, ownerUid: uid, description, path, role: 'owner' });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ── Detail (metadata; data is read via /api/vault proxy) ────────────────────
|
|
router.get('/:id', async (req, res, next) => {
|
|
try {
|
|
const secret = await loadSecret(req, res);
|
|
if (!secret) return;
|
|
const uid = req.user.uid;
|
|
const admin = await isAdmin(req.user);
|
|
const grantee = (await SharedSecretGrant.listForGrantee('user', uid)).some(g => g.secretId === secret.id);
|
|
if (!admin && uid !== secret.ownerUid && !grantee) return res.status(403).json({ error: 'not shared with you' });
|
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
|
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path(), role: uid === secret.ownerUid ? 'owner' : (admin ? 'admin' : 'grantee'), grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ── Update data / description ───────────────────────────────────────────────
|
|
router.put('/:id', async (req, res, next) => {
|
|
try {
|
|
const secret = await loadSecret(req, res);
|
|
if (!secret) return;
|
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can edit a shared secret' });
|
|
const token = await actorToken(req.user, secret.ownerUid);
|
|
const update = {};
|
|
if (req.body && typeof req.body.data === 'object') {
|
|
await baoConf.set(secret.path(), req.body.data, { token });
|
|
}
|
|
if (req.body && req.body.description !== undefined) {
|
|
update.description = String(req.body.description).trim();
|
|
}
|
|
if (Object.keys(update).length) {
|
|
update.updated_by = req.user.uid;
|
|
update.updated_on = Date.now();
|
|
await secret.update(update);
|
|
}
|
|
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path() });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ── Delete (KV + DB row + all grants) ───────────────────────────────────────
|
|
router.delete('/:id', async (req, res, next) => {
|
|
try {
|
|
const secret = await loadSecret(req, res);
|
|
if (!secret) return;
|
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can delete a shared secret' });
|
|
const token = await actorToken(req.user, secret.ownerUid);
|
|
// Revoke all grants first so grantees' policies drop the path.
|
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
|
for (const g of grants) await vaultBroker.revokeSharedSecret(g.id, req.user.uid);
|
|
// Delete the KV data (metadata delete removes all versions), then the row.
|
|
try { await baoConf.request('DELETE', `secret/metadata/${secret.path()}`, undefined, { token }); } catch (e) { /* best-effort */ }
|
|
await secret.delete();
|
|
res.status(204).end();
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ── Grants: list ────────────────────────────────────────────────────────────
|
|
router.get('/:id/grants', async (req, res, next) => {
|
|
try {
|
|
const secret = await loadSecret(req, res);
|
|
if (!secret) return;
|
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
|
res.json({ grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ── Grants: create ──────────────────────────────────────────────────────────
|
|
router.post('/:id/grants', async (req, res, next) => {
|
|
try {
|
|
const secret = await loadSecret(req, res);
|
|
if (!secret) return;
|
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
|
const granteeType = String(req.body.granteeType || '').trim();
|
|
const granteeId = String(req.body.granteeId || '').trim();
|
|
if (!['user', 'app'].includes(granteeType)) return res.status(400).json({ error: 'granteeType must be user or app' });
|
|
if (!granteeId) return res.status(400).json({ error: 'granteeId is required' });
|
|
if (granteeId === secret.ownerUid && granteeType === 'user') {
|
|
return res.status(400).json({ error: 'the owner already has access' });
|
|
}
|
|
// Idempotent: skip if the grant already exists.
|
|
const existing = (await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType, granteeId } }))[0];
|
|
if (existing) return res.json({ id: existing.id, granteeType, granteeId, capability: existing.capability });
|
|
const grant = await vaultBroker.grantSharedSecret(secret.id, granteeType, granteeId, req.user.uid);
|
|
res.status(201).json({ id: grant.id, granteeType, granteeId, capability: grant.capability });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ── Grants: revoke ──────────────────────────────────────────────────────────
|
|
router.delete('/:id/grants/:grantId', async (req, res, next) => {
|
|
try {
|
|
const secret = await loadSecret(req, res);
|
|
if (!secret) return;
|
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
|
const grant = await SharedSecretGrant.get(req.params.grantId);
|
|
if (!grant || grant.secretId !== secret.id) return res.status(404).json({ error: 'grant not found' });
|
|
await vaultBroker.revokeSharedSecret(grant.id, req.user.uid);
|
|
res.status(204).end();
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
module.exports = router;
|