From 948fef4adc31f1dccada960230e273280efb9c9f Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 3 Aug 2026 22:13:58 -0400 Subject: [PATCH] fix vault 403 + shared secrets (v1.21.0) - 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//; 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 --- CHANGELOG.md | 8 + nodejs/app.js | 2 + nodejs/models/index.js | 3 + nodejs/models/shared_secret.js | 56 +++++++ nodejs/models/shared_secret_grant.js | 53 ++++++ nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/routes/api_shared_secrets.js | 208 +++++++++++++++++++++++ nodejs/utils/vault_broker.js | 138 +++++++++++---- nodejs/views/vault.ejs | 242 +++++++++++++++++++++++++++ 10 files changed, 685 insertions(+), 31 deletions(-) create mode 100644 nodejs/models/shared_secret.js create mode 100644 nodejs/models/shared_secret_grant.js create mode 100644 nodejs/routes/api_shared_secrets.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a512211..ec628fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# v1.21.0 +- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied" +- feat: shared secrets — users can publish secrets to secret/shared// and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live) +- feat: shared-secrets API + Shared tab in the vault UI + +# v1.20.0 +- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal) + ## v1.19.0 - Added WebSocket endpoint for theta-agent C2 diff --git a/nodejs/app.js b/nodejs/app.js index ed612b4..20c679e 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -126,6 +126,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/api_plugins')); 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()); +// Shared secrets (metadata + grants; data reads go through /api/vault proxy). +app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets')); // 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/models/index.js b/nodejs/models/index.js index d80dc86..2729920 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -17,6 +17,8 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource'); const { AccessRequest } = require('./access_request'); const { Webhook } = require('./webhook'); const { PluginInstance } = require('./plugin_instance'); +const { SharedSecret } = require('./shared_secret'); +const { SharedSecretGrant } = require('./shared_secret_grant'); async function initORM() { const ormConf = conf.orm || { dialect: 'sqlite', @@ -31,6 +33,7 @@ async function initORM() { conf: { orm: ormConf }, models: [ Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance, + SharedSecret, SharedSecretGrant, Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken ] }); diff --git a/nodejs/models/shared_secret.js b/nodejs/models/shared_secret.js new file mode 100644 index 0000000..f692626 --- /dev/null +++ b/nodejs/models/shared_secret.js @@ -0,0 +1,56 @@ +'use strict'; + +// SharedSecret — a secret the owner has published to the shared namespace so it +// can be shared with other users and/or downstream apps. +// +// The secret DATA lives in OpenBao at `secret/shared//` (KV-v2), +// never in the DB. This row is metadata only (owner + slug + description) and is +// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced +// entirely by OpenBao ACL policies: the owner's `user-` policy grants full +// R/W on `secret/shared//*`, and each grantee's policy content is +// edited to add `read` on the exact shared path (see vault_broker.js — policy +// content is parsed live at token use, so a grant takes effect immediately with +// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row. +// +// `slug` is unique and immutable in practice — it is embedded in the shared path +// and in grantee policy rules, so changing it would require rewriting policies. +// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp +// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the +// ORM on create. + +const { Model } = require('@simpleworkjs/orm'); + +class SharedSecret extends Model { + static fields = { + id: { type: 'uuid', primaryKey: true }, + // Human slug embedded in the OpenBao path: secret/shared//. + // Unique so two owners can't collide on the same shared path. + slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 }, + // The publishing user's uid — also the shared path's namespace segment. + ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 }, + // Optional human description shown in the Shared tab. + description: { type: 'text' }, + // Audit stamps (set by the route handler, not by an ORM hook). + created_by: { type: 'string' }, + created_on: { type: 'integer' }, + updated_by: { type: 'string' }, + updated_on: { type: 'integer' }, + }; + + // Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata). + static pathFor(ownerUid, slug) { + return `shared/${ownerUid}/${slug}`; + } + + path() { + return SharedSecret.pathFor(this.ownerUid, this.slug); + } + + // Look up by slug (unique). Returns the row or null. + static async getBySlug(slug) { + const rows = await this.list({ where: { slug } }); + return rows[0] || null; + } +} + +module.exports = { SharedSecret }; diff --git a/nodejs/models/shared_secret_grant.js b/nodejs/models/shared_secret_grant.js new file mode 100644 index 0000000..1d5c0a3 --- /dev/null +++ b/nodejs/models/shared_secret_grant.js @@ -0,0 +1,53 @@ +'use strict'; + +// SharedSecretGrant — who can read a shared secret. Each row says "grantee +// (a user uid or an app name) has on the shared secret +// ". +// +// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives +// in OpenBao ACL policy content: when a grant is created, vault_broker.js +// recomputes the grantee's policy HCL (`user-` or `app-`) to include +// `read` on the exact shared path and rewrites it. Because OpenBao parses policy +// content live at token use, the grant applies to the grantee's existing token +// immediately (no re-mint). Revoking removes the rule and rewrites the policy. +// +// granteeType distinguishes the two principal kinds: +// 'user' — a user uid → grantee's `user-` policy is edited +// 'app' — an app name → grantee's `app-` policy is edited (downstream apps) +// capability is currently always 'read' (grantees are read-only); the column is +// a string so later capabilities could be added without a migration. +// +// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on. +// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants. + +const { Model } = require('@simpleworkjs/orm'); + +const GRANTEE_TYPES = ['user', 'app']; +const CAPABILITIES = ['read']; + +class SharedSecretGrant extends Model { + static fields = { + id: { type: 'uuid', primaryKey: true }, + // FK to SharedSecret.id. + secretId: { type: 'string', isRequired: true, min: 1 }, + // 'user' (a uid) or 'app' (an app name) — which policy to edit. + granteeType: { type: 'string', isRequired: true, min: 1 }, + // The grantee's uid (for 'user') or app name (for 'app'). + granteeId: { type: 'string', isRequired: true, min: 1, max: 64 }, + // Access level — 'read' today. + capability: { type: 'string', isRequired: true, default: 'read' }, + // Audit stamps (set by the route handler, not by an ORM hook). + created_by: { type: 'string' }, + created_on: { type: 'integer' }, + updated_by: { type: 'string' }, + updated_on: { type: 'integer' }, + }; + + // All grants for a given grantee (user uid or app name). Used to rebuild the + // grantee's policy content so every granted shared path is present/absent. + static async listForGrantee(granteeType, granteeId) { + return this.list({ where: { granteeType, granteeId } }); + } +} + +module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES }; diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index bdedd2c..ec5fde9 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.20.2", + "version": "1.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.20.2", + "version": "1.21.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 243a2ba..81774f7 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.20.2", + "version": "1.21.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/api_shared_secrets.js b/nodejs/routes/api_shared_secrets.js new file mode 100644 index 0000000..67ff73b --- /dev/null +++ b/nodejs/routes/api_shared_secrets.js @@ -0,0 +1,208 @@ +'use strict'; + +// Shared-secrets API. +// +// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with +// its DATA in OpenBao at secret/shared// (KV-v2). The owner has +// full R/W/list on their own secret/shared//* 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//*); 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; diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 19e499f..cd295a6 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -4,15 +4,25 @@ // 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) +// secret/users//* per-user personal KV (user- policy) +// secret/shared//* user-owned shared KV (user- policy) +// secret/apps//* per-external-app namespace (app- policy) +// secret/shared// 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'); @@ -20,6 +30,8 @@ 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) @@ -54,17 +66,20 @@ async function bao(method, path, body) { return res; } -// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes — -// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding -// a list grant on a directory path) propagate on the next vault-page visit -// without an operator re-running setup.sh. Skipping on an existing policy -// would strand the old, narrower HCL forever. +// 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 }); } @@ -79,29 +94,55 @@ async function mintToken(policies) { 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// + rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`); + rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`); + } + return rules.join('\n'); +} + // ── Per-user token ────────────────────────────────────────────────────────── -// ── Per-user token ────────────────────────────────────────────────────────── -function userPolicyHcl(uid) { +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" { capabilities = ["read", "list"] } -path "secret/data/shared/*" { capabilities = ["read", "list"] } -path "secret/metadata/shared" { capabilities = ["read", "list"] } -path "secret/metadata/shared/" { capabilities = ["read", "list"] } -path "secret/metadata/shared/*" { capabilities = ["read", "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 confined to secret/users//*. -// Re-minted when the cache entry expires (a little before the token's own TTL). +// 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; - await ensurePolicy(`user-${uid}`, userPolicyHcl(uid)); const { token, ttl } = await mintToken([`user-${uid}`]); await cacheSet(cacheKey, token, Math.max(ttl - 60, 60)); return token; @@ -119,42 +160,75 @@ path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", } 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; - await ensurePolicy('sso-admin', adminPolicyHcl()); 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) { +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"] } -path "secret/data/shared" { capabilities = ["read", "list"] } -path "secret/data/shared/*" { capabilities = ["read", "list"] } -path "secret/metadata/shared" { capabilities = ["read", "list"] } -path "secret/metadata/shared/" { capabilities = ["read", "list"] } -path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`; +${granted}`.trim(); } // 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. +// tokens. The caller must record it in the external app immediately. Later +// grants to the app edit app- 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}`, appPolicyHcl(name)); + 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 @@ -256,4 +330,12 @@ module.exports = { scopeGuard, vaultProxy, mintAppRouter, -}; \ No newline at end of file + // sharing + SharedSecret, + SharedSecretGrant, + userPolicyHcl, + appPolicyHcl, + grantSharedSecret, + revokeSharedSecret, + reconcileGrantee, +}; diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index b00c393..54afd78 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -6,6 +6,7 @@ @@ -83,6 +84,101 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" + + +
+
+
+
+
+
My shared secrets
+ +
+
+
Loading...
+
+
+
+
+
+
Shared with me
+
+
Loading...
+
+
+
+
+
+ + + + + + + + + + + @@ -307,6 +403,151 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success')); } + // ── Shared secrets tab ────────────────────────────────────────────── + let currentShared = null; + const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal')); + const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal')); + const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal')); + + function sharedApi(path, method = 'GET', body = null) { + const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } }; + if (body) opts.body = JSON.stringify(body); + return fetch('/api/shared-secrets' + path, opts).then(async res => { + if (res.status === 404) return null; + if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); } + if (res.status === 204) return null; + return res.json(); + }); + } + + async function loadShared() { + try { + const res = await sharedApi('/'); + const items = (res && res.items) || []; + renderSharedMine(items.filter(i => i.role === 'owner')); + renderSharedGranted(items.filter(i => i.role === 'grantee')); + } catch (err) { + document.getElementById('shared-mine-list').innerHTML = + `
Error: ${err.message}
`; + } + } + + function renderSharedMine(items) { + const el = document.getElementById('shared-mine-list'); + if (!items.length) { el.innerHTML = '
No shared secrets yet
'; return; } + el.innerHTML = ''; + items.forEach(s => { + const row = document.createElement('div'); + row.className = 'list-group-item d-flex justify-content-between align-items-center'; + row.innerHTML = `
${s.slug}
${s.path}
+
+ + +
`; + el.appendChild(row); + }); + } + + function renderSharedGranted(items) { + const el = document.getElementById('shared-granted-list'); + if (!items.length) { el.innerHTML = '
Nothing shared with you yet
'; return; } + el.innerHTML = ''; + items.forEach(s => { + const row = document.createElement('a'); + row.href = '#'; + row.className = 'list-group-item list-group-item-action d-flex align-items-center'; + row.innerHTML = `${s.slug}by ${s.ownerUid}`; + row.onclick = (e) => { e.preventDefault(); viewShared(s); }; + el.appendChild(row); + }); + } + + function showCreateSharedModal() { + currentShared = null; + document.getElementById('shared-slug-input').value = ''; + document.getElementById('shared-desc-input').value = ''; + document.getElementById('shared-data-input').value = '{\n "key": "value"\n}'; + document.getElementById('shared-create-error').classList.add('d-none'); + sharedCreateModal.show(); + } + + async function saveSharedSecret() { + const err = document.getElementById('shared-create-error'); + err.classList.add('d-none'); + let data; + try { data = JSON.parse(document.getElementById('shared-data-input').value); } + catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; } + try { + await sharedApi('/', 'POST', { + slug: document.getElementById('shared-slug-input').value.trim(), + description: document.getElementById('shared-desc-input').value.trim(), + data + }); + sharedCreateModal.hide(); + await loadShared(); + } catch (e) { err.textContent = e.message; err.classList.remove('d-none'); } + } + + async function viewShared(s) { + document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')'; + document.getElementById('shared-view-content').textContent = 'Loading...'; + sharedViewModal.show(); + try { + const res = await apiCall('GET', 'secret/data/' + s.path); + document.getElementById('shared-view-content').textContent = + (res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.'; + } catch (e) { + document.getElementById('shared-view-content').textContent = 'Error: ' + e.message; + } + } + + async function openGrants(id) { + currentShared = id; + document.getElementById('grants-error').classList.add('d-none'); + document.getElementById('grant-id-input').value = ''; + sharedGrantsModal.show(); + try { + const res = await sharedApi('/' + id + '/grants'); + const grants = (res && res.grants) || []; + const el = document.getElementById('grants-list'); + el.innerHTML = ''; + if (!grants.length) el.innerHTML = '
No grants yet.
'; + grants.forEach(g => { + const row = document.createElement('div'); + row.className = 'list-group-item d-flex justify-content-between align-items-center'; + row.innerHTML = `${g.granteeType}${g.granteeId} + `; + el.appendChild(row); + }); + } catch (e) { + document.getElementById('grants-list').innerHTML = `
${e.message}
`; + } + } + + async function addGrant() { + const err = document.getElementById('grants-error'); + err.classList.add('d-none'); + try { + await sharedApi('/' + currentShared + '/grants', 'POST', { + granteeType: document.getElementById('grant-type-input').value, + granteeId: document.getElementById('grant-id-input').value.trim() + }); + document.getElementById('grant-id-input').value = ''; + openGrants(currentShared); + } catch (e) { err.textContent = e.message; err.classList.remove('d-none'); } + } + + async function revokeGrant(grantId) { + try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); } + catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); } + } + + async function deleteShared(id) { + if (!confirm('Delete this shared secret? Grantees will immediately lose access.')) return; + try { await sharedApi('/' + id, 'DELETE'); await loadShared(); } + catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); } + } + (async function init() { const user = await app.auth.forceLogin(); if (!user) return; // not logged in — forceLogin redirected to /login @@ -320,6 +561,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf'; } loadSecrets(); + loadShared(); })();