Merge pull request #161 from theta42/fix/vault-403-shared-secrets
fix vault 403 + shared secrets (v1.21.0)
This commit is contained in:
@@ -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/<owner>/<slug> 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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/<ownerUid>/<slug>` (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-<uid>` policy grants full
|
||||
// R/W on `secret/shared/<ownerUid>/*`, 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/<ownerUid>/<slug>.
|
||||
// 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 };
|
||||
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
|
||||
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
|
||||
// <secretId>".
|
||||
//
|
||||
// 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-<uid>` or `app-<name>`) 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-<uid>` policy is edited
|
||||
// 'app' — an app name → grantee's `app-<name>` 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 };
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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/<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;
|
||||
+110
-28
@@ -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/<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)
|
||||
// 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');
|
||||
@@ -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/<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 ──────────────────────────────────────────────────────────
|
||||
// ── 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/<uid>/*.
|
||||
// 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-<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.
|
||||
// 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}`, 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,
|
||||
};
|
||||
// sharing
|
||||
SharedSecret,
|
||||
SharedSecretGrant,
|
||||
userPolicyHcl,
|
||||
appPolicyHcl,
|
||||
grantSharedSecret,
|
||||
revokeSharedSecret,
|
||||
reconcileGrantee,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<ul class="nav nav-pills" id="vault-tabs">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
|
||||
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-shared" type="button">Shared</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +84,101 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="tab-shared">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">My shared secrets</h5>
|
||||
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
|
||||
</div>
|
||||
<div class="list-group list-group-flush" id="shared-mine-list">
|
||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Shared with me</h5></div>
|
||||
<div class="list-group list-group-flush" id="shared-granted-list">
|
||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Shared Secret Modal -->
|
||||
<div class="modal fade" id="sharedCreateModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">New Shared Secret</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name (slug)</label>
|
||||
<input type="text" class="form-control" id="shared-slug-input" placeholder="e.g. db-creds">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-control" id="shared-desc-input" placeholder="optional">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Secret Data (JSON)</label>
|
||||
<textarea class="form-control" id="shared-data-input" rows="6" style="font-family: monospace;">{
|
||||
"key": "value"
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="alert alert-danger d-none" id="shared-create-error"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSharedSecret()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Grants Modal -->
|
||||
<div class="modal fade" id="sharedGrantsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Share</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-4"><select class="form-select" id="grant-type-input"><option value="user">User</option><option value="app">App</option></select></div>
|
||||
<div class="col-5"><input class="form-control" id="grant-id-input" placeholder="uid or app name"></div>
|
||||
<div class="col-3"><button class="btn btn-primary w-100" onclick="addGrant()">Grant</button></div>
|
||||
</div>
|
||||
<div class="alert alert-danger d-none" id="grants-error"></div>
|
||||
<div class="list-group" id="grants-list"><div class="list-group-item text-muted">No grants yet.</div></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Shared Secret Modal -->
|
||||
<div class="modal fade" id="sharedViewModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="shared-view-title">Secret</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body"><pre id="shared-view-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,6 +403,152 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/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 =
|
||||
`<div class="list-group-item text-danger">Error: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSharedMine(items) {
|
||||
const el = document.getElementById('shared-mine-list');
|
||||
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">No shared secrets yet</div>'; 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 = `<div><i class="fas fa-share-alt text-secondary me-2"></i><strong>${s.slug}</strong><div class="small text-muted">${s.path}</div></div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="openGrants('${s.id}')"><i class="fas fa-users"></i> Share</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteShared('${s.id}')"><i class="fas fa-trash"></i></button>
|
||||
</div>`;
|
||||
el.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSharedGranted(items) {
|
||||
const el = document.getElementById('shared-granted-list');
|
||||
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">Nothing shared with you yet</div>'; 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 = `<i class="fas fa-key text-secondary me-3"></i><span>${s.slug}</span><small class="text-muted ms-auto">by ${s.ownerUid}</small>`;
|
||||
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 = '<div class="list-group-item text-muted">No grants yet.</div>';
|
||||
grants.forEach(g => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
row.innerHTML = `<span><span class="badge bg-secondary me-2">${g.granteeType}</span>${g.granteeId}</span>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="revokeGrant('${g.id}')"><i class="fas fa-times"></i></button>`;
|
||||
el.appendChild(row);
|
||||
});
|
||||
} catch (e) {
|
||||
document.getElementById('grants-list').innerHTML = `<div class="list-group-item text-danger">${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const confirmed = await app.messages.confirm('Delete this shared secret? Grantees will immediately lose access.', $('#shared-mine-list'), 'warning');
|
||||
if (!confirmed) 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 +562,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
||||
}
|
||||
loadSecrets();
|
||||
loadShared();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user