Compare commits

..

11 Commits

Author SHA1 Message Date
wmantly 69434d06ec Merge pull request #163 from theta42/release/v1.23.0-vault-proxy-fix
fix vault 403 for real (v1.23.0)
2026-08-04 00:19:16 -04:00
wmantly dd24257640 fix vault 403 for real (v1.23.0)
- /api/vault proxy now injects X-Vault-Token: the proxy declared its request
  hook with http-proxy-middleware v3 syntax (on: { proxyReq }), which the
  installed HPM v2 silently ignores — so every vault call reached OpenBao
  unauthenticated (the recurring 403). Rewritten as v2 onProxyReq.
- Header injection ordered before fixRequestBody (the body write flushes
  headers; setting X-Vault-Token after it failed on every POST/PUT).
- initORM add-only schema heal: sequelize.sync() never ALTERs, so newer columns
  (PluginInstance.lastLog) are now added via describeTable + addColumn.
- Long-lived external-app tokens via sso-app role (768h periodic); VaultAppToken
  stores each app token's accessor and renews it at boot + every 6h; re-minting
  revokes the previous token via its accessor.
- Wire-level tests for the vault proxy + app-token accessor lifecycle.
- package.json + lockfile bumped to 1.23.0.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:11:51 -04:00
wmantly b06aeca363 Merge pull request #162 from theta42/feat/agents-page-v1.22.0
feat: Agents page + secure /api/agent REST (v1.22.0)
2026-08-03 23:14:49 -04:00
wmantly ccf3122668 feat: Agents page + secure /api/agent REST (v1.22.0)
- New admin Agents page (nav + /agents route + views/agents.ejs): live list of
  connected theta-agent hosts with CPU/RAM/disk/ZFS/GPU telemetry and online
  status, updated live over socket.io ('agent.telemetry'/'agent.discovery').
- Auth + admin-gate the /api/agent REST router (it was mounted without
  middleware.auth — anyone could list nodes / send commands). The agent
  WebSocket (/api/agent/ws) is unaffected (handled by the raw wss upgrade with
  its own token auth).
- package.json + lockfile bumped to 1.22.0 to match the tag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 23:10:16 -04:00
wmantly 5aad6c13bf Merge pull request #161 from theta42/fix/vault-403-shared-secrets
fix vault 403 + shared secrets (v1.21.0)
2026-08-03 22:23:44 -04:00
wmantly b46b3bed80 fix: use app.messages.confirm instead of native confirm() in vault Shared tab
The no_native_dialogs regression test forbids native alert/confirm/prompt in
views (they block browser events). Replace the native confirm() in deleteShared
with app.messages.confirm().
2026-08-03 22:19:38 -04:00
wmantly 948fef4adc 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/<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>
2026-08-03 22:13:58 -04:00
wmantly 2612b0e3ab Merge pull request #159 from theta42/fix/sync-version-v1.20.2
fix: sync package version to v1.20.2 tag
2026-08-03 21:35:14 -04:00
wmantly bf471c2e19 fix: sync package version to v1.20.2 tag
The v1.20.2 release tag was created but nodejs/package.json (and the
lockfile) were left at 1.20.1, so the deployed app's buildVersion lagged
its own release tag and the update-check banner falsely reported a newer
version. Bump the version fields to match the tag.
2026-08-03 21:26:55 -04:00
wmantly d802c399a3 Merge pull request #157 from theta42/fix/sso-vault-conf-directory-v1.20.2
fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
2026-08-03 15:30:49 -04:00
wmantly d8242b1d53 fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
Pull Request Tests / Run Tests (18.x) (push) Failing after 56s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 15:26:59 -04:00
19 changed files with 1358 additions and 192 deletions
+20
View File
@@ -1,3 +1,23 @@
# v1.23.0
- fix: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (`on: { proxyReq }`), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2 `onProxyReq`.
- fix: vault proxy header injection ordered before `fixRequestBody` — the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed)
- fix: initORM add-only schema heal — `sequelize.sync()` never ALTERs existing tables, so columns added by newer releases (e.g. `PluginInstance.lastLog`, which crashed the scheduler on every boot of an upgraded deployment) are now detected via describeTable and added with addColumn (additive only, per-column fail-soft)
- feat: external-app vault tokens are long-lived and auto-renewed — minted via the new `sso-app` token role (periodic 768h, falls back to sso-broker's 24h role until theta-suite setup.sh is re-run); sso stores each token's accessor (new VaultAppToken model — an accessor can renew/revoke but not authenticate) and renews all of them at boot + every 6h via auth/token/renew-accessor, so a downstream app's credential stays valid as long as sso runs with zero renewal code in the app
- feat: re-minting an app token revokes the app's previous token via its stored accessor — exactly one live credential per app, no zombies
- test: wire-level tests for the vault proxy (real HTTP round-trip asserting token injection, auth-header stripping, path rewrite, and POST body integrity) + app-token accessor lifecycle tests
# v1.22.0
- feat: Agents page — live list of connected theta-agent hosts with telemetry (CPU/RAM/disk/ZFS/GPU) + online status, updating via socket.io
- security: auth + admin-gate the /api/agent REST routes (previously unauthenticated)
# 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
+2
View File
@@ -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.
+7
View File
@@ -60,6 +60,13 @@ models.initORM().then(() => {
initScheduler(conf.discovery).catch(err => {
console.error('Failed to initialize scheduler:', err);
});
// Keep external-app vault tokens alive: renew every stored accessor now and
// on an interval (see vault_broker.startAppTokenRenewal). Only meaningful
// when OpenBao is configured; without VAULT_TOKEN the loop's calls fail soft.
if (process.env.VAULT_TOKEN) {
require('../utils/vault_broker').startAppTokenRenewal();
}
}).catch(err => {
console.error('Failed to initialize ORM:', err);
process.exit(1);
+36
View File
@@ -17,6 +17,9 @@ 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');
const { VaultAppToken } = require('./vault_app_token');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -31,15 +34,48 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] ORM initialized successfully');
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
await healSchema();
} catch (err) {
console.error('[initORM] ORM initialization failed:', err.message);
throw err;
}
}
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
// which creates missing tables but never touches existing ones — so a column
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
// in an upgraded deployment's database and every query on the model fails
// ("no such column"). This walks each Sequelize model and ADDs any attribute
// missing from its table. Strictly additive (never drops or retypes), works on
// any dialect via the query interface, and fail-soft per column so one bad
// attribute can't take the boot down.
async function healSchema() {
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
if (!adapter || !adapter.sequelize) return;
const sequelize = adapter.sequelize;
const qi = sequelize.getQueryInterface();
for (const SM of Object.values(sequelize.models)) {
const table = SM.getTableName();
let existing;
try { existing = await qi.describeTable(table); }
catch (e) { continue; } // no table yet — sync() handles creation
for (const [name, attr] of Object.entries(SM.getAttributes())) {
const col = attr.field || name;
if (existing[col]) continue;
try {
await qi.addColumn(table, col, attr);
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
} catch (e) {
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
}
}
}
}
module.exports.initORM = initORM;
+56
View File
@@ -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 };
+53
View File
@@ -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 };
+43
View File
@@ -0,0 +1,43 @@
'use strict';
// VaultAppToken — the ACCESSOR of an OpenBao token minted for an external app
// from the vault UI (Apps tab), so sso can keep the token alive.
//
// The token itself is shown ONCE at mint and never stored (a stolen accessor
// cannot authenticate — it can only look up, renew, or revoke its token, and
// only the sso broker's policy grants those endpoints). App tokens are minted
// through the sso-app role as PERIODIC tokens: they live forever, but only if
// something renews them inside every period window. That something is sso's
// renewal loop (vault_broker.startAppTokenRenewal), which walks these rows and
// POSTs auth/token/renew-accessor on a timer — so a downstream app's credential
// stays valid as long as sso itself is running, with no renewal code needed in
// the downstream app.
//
// One row per app name: re-minting an app's token revokes the previous token
// via its accessor (no zombie credentials) and replaces the row.
const { Model } = require('@simpleworkjs/orm');
class VaultAppToken extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// The external app's name — also its policy (app-<name>) and KV namespace
// (secret/apps/<name>/). Unique: one live token per app.
name: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// The minted token's accessor (renew/revoke handle, cannot authenticate).
accessor: { type: 'string', isRequired: true, max: 128 },
// Renewal bookkeeping, updated by the renewal loop.
lastRenewedAt: { type: 'integer' },
lastError: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
};
static async getByName(name) {
const rows = await this.list({ where: { name } });
return rows[0] || null;
}
}
module.exports = { VaultAppToken };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.20.1",
"version": "1.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.20.0",
"version": "1.23.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.20.1",
"version": "1.23.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+20 -1
View File
@@ -1,8 +1,12 @@
'use strict';
const express = require('express');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
module.exports = function initAgentWebSockets(app) {
if (!app.wss) {
console.warn("WebSocket server for agents is not initialized.");
@@ -71,8 +75,23 @@ module.exports = function initAgentWebSockets(app) {
} catch (e) {}
});
// REST API routes for Agent Management (mounted under /api/agent)
// REST API routes for Agent Management (mounted under /api/agent). The agent
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
// bin/www with its own ?token= auth — unaffected by the express middleware
// here. These REST routes are admin-facing, so they're auth + admin gated.
const router = express.Router();
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ADMIN_GROUPS);
next();
} catch (err) {
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
return res.status(403).json({ status: 'error', message: 'admin only' });
}
next(err);
}
});
router.get('/nodes', (req, res) => {
res.json({
+208
View File
@@ -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;
+6
View File
@@ -59,6 +59,12 @@ router.get('/overview', function(req, res) {
res.render('overview', {...values});
});
// Connected theta-agent hosts + live telemetry (admin). Data from
// GET /api/agent/nodes; live updates via socket.io 'agent.*' events.
router.get('/agents', function(req, res) {
res.render('agents', {...values});
});
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
+155 -2
View File
@@ -15,8 +15,36 @@ jest.mock('redis', () => ({
})
}));
// In-memory stand-ins for the ORM-backed models so mintAppToken/renewAppTokens
// can run without a database.
jest.mock('../models/shared_secret', () => ({
SharedSecret: { list: jest.fn().mockResolvedValue([]) },
}));
jest.mock('../models/shared_secret_grant', () => ({
SharedSecretGrant: { listForGrantee: jest.fn().mockResolvedValue([]) },
}));
jest.mock('../models/vault_app_token', () => {
const rows = [];
const VaultAppToken = {
_rows: rows,
list: jest.fn(async () => rows),
getByName: jest.fn(async (name) => rows.find(r => r.name === name) || null),
create: jest.fn(async (data) => {
const row = {
...data,
update: jest.fn(async function (patch) { Object.assign(this, patch); }),
delete: jest.fn(async function () { rows.splice(rows.indexOf(this), 1); }),
};
rows.push(row);
return row;
}),
};
return { VaultAppToken };
});
const baoConf = require('@simpleworkjs/bao-conf');
const vaultBroker = require('../utils/vault_broker');
const { VaultAppToken } = require('../models/vault_app_token');
describe('vault_broker admin policy', () => {
beforeEach(() => {
@@ -29,8 +57,8 @@ describe('vault_broker admin policy', () => {
return { status: 404, text: async () => '' };
}
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["list", "read", "delete"] }');
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["list", "read", "delete"] }');
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
return { status: 204, ok: true };
}
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
@@ -49,3 +77,128 @@ describe('vault_broker admin policy', () => {
}));
});
});
describe('app token lifecycle (accessor storage + renewal)', () => {
beforeEach(() => {
baoConf.request.mockReset();
VaultAppToken._rows.length = 0;
});
function mockBao({ mintAccessor = 'acc-1', renewOk = true } = {}) {
baoConf.request.mockImplementation(async (method, path, body) => {
if (path.startsWith('sys/policies/acl/')) {
if (method === 'GET') return { status: 404, text: async () => '' };
return { status: 204, ok: true };
}
if (path === 'auth/token/create/sso-app') {
return { ok: true, json: async () => ({ auth: { client_token: 'app-tok', accessor: mintAccessor, lease_duration: 2764800 } }) };
}
if (path === 'auth/token/renew-accessor') {
return renewOk ? { ok: true, json: async () => ({}) } : { ok: false, status: 400, text: async () => 'invalid accessor' };
}
if (path === 'auth/token/revoke-accessor') {
return { ok: true, status: 204, text: async () => '' };
}
return { status: 200, ok: true, json: async () => ({}) };
});
}
test('mintAppToken stores the accessor; re-mint revokes the old accessor and replaces the row', async () => {
mockBao({ mintAccessor: 'acc-old' });
await vaultBroker.mintAppToken('demo', 'adminuser');
expect(VaultAppToken._rows).toHaveLength(1);
expect(VaultAppToken._rows[0]).toMatchObject({ name: 'demo', accessor: 'acc-old', created_by: 'adminuser' });
mockBao({ mintAccessor: 'acc-new' });
await vaultBroker.mintAppToken('demo', 'adminuser');
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/revoke-accessor', { accessor: 'acc-old' });
expect(VaultAppToken._rows).toHaveLength(1);
expect(VaultAppToken._rows[0].accessor).toBe('acc-new');
});
test('renewAppTokens renews each accessor and stamps lastRenewedAt', async () => {
mockBao();
await vaultBroker.mintAppToken('demo', 'adminuser');
VaultAppToken._rows[0].lastRenewedAt = 0;
await vaultBroker.renewAppTokens();
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/renew-accessor', { accessor: 'acc-1' });
expect(VaultAppToken._rows[0].lastRenewedAt).toBeGreaterThan(0);
expect(VaultAppToken._rows[0].lastError).toBeNull();
});
test('renewAppTokens records the failure on the row without throwing', async () => {
mockBao({ renewOk: false });
await vaultBroker.mintAppToken('demo', 'adminuser');
await vaultBroker.renewAppTokens();
expect(VaultAppToken._rows[0].lastError).toMatch(/renew failed \(400\)/);
});
});
// Real HTTP round-trip through vaultProxy() against an in-process fake OpenBao.
// This exists because the proxy once shipped with a hook shape the installed
// http-proxy-middleware version ignored (v3 `on: { proxyReq }` vs v2
// `onProxyReq`), so NO X-Vault-Token was ever injected and every /api/vault
// request 403'd. A unit test on options can't catch that — only a wire test can.
describe('vaultProxy wire behavior', () => {
const http = require('http');
const express = require('express');
let target; // fake OpenBao
let seen; // last request the fake OpenBao received
let app; // sso app fragment: scopeGuard stub + vaultProxy
let server;
beforeAll((done) => {
target = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
seen = { method: req.method, url: req.url, headers: req.headers, body };
res.setHeader('content-type', 'application/json');
res.end('{"ok":true}');
});
});
target.listen(0, '127.0.0.1', () => {
process.env.VAULT_ADDR = `http://127.0.0.1:${target.address().port}`;
jest.resetModules();
const broker = require('../utils/vault_broker');
app = express();
app.use(express.json());
app.use('/api/vault', (req, res, next) => { req.vaultToken = 'scoped-token-123'; next(); }, broker.vaultProxy());
server = app.listen(0, '127.0.0.1', done);
});
});
afterAll((done) => {
server.close(() => target.close(done));
});
function call(path, opts = {}) {
const port = server.address().port;
return fetch(`http://127.0.0.1:${port}${path}`, opts);
}
test('GET list rewrites /api/vault -> /v1, injects X-Vault-Token, strips sso auth headers', async () => {
const res = await call('/api/vault/secret/metadata/users/alice?list=true', {
headers: { 'auth-token': 'sso-session-token', authorization: 'Bearer sso_x_y', 'content-type': 'application/json' },
});
expect(res.status).toBe(200);
expect(seen.url).toBe('/v1/secret/metadata/users/alice?list=true');
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
expect(seen.headers['auth-token']).toBeUndefined();
expect(seen.headers['authorization']).toBeUndefined();
});
test('POST body survives the express.json + fixRequestBody round-trip', async () => {
const res = await call('/api/vault/secret/data/users/alice/foo', {
method: 'POST',
headers: { 'content-type': 'application/json', 'auth-token': 'sso-session-token' },
body: JSON.stringify({ data: { hello: 'world' } }),
});
expect(res.status).toBe(200);
expect(seen.method).toBe('POST');
expect(seen.url).toBe('/v1/secret/data/users/alice/foo');
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
expect(JSON.parse(seen.body)).toEqual({ data: { hello: 'world' } });
});
});
+1
View File
@@ -46,6 +46,7 @@ module.exports = {
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
// Vault requires login - per-user secrets at secret/users/<uid>/*.
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
{href: '/agents', icon: 'fa-solid fa-microchip', label: 'Agents', groups: ['app_sso_admin', 'admin']},
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
],
};
+239 -62
View File
@@ -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,9 @@ 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 { VaultAppToken } = require('../models/vault_app_token');
const ROLE = 'sso-broker';
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
@@ -54,52 +67,86 @@ 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 });
}
// Mint a token through the sso-broker role with the given policies. Returns
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
async function mintToken(policies) {
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
// Mint a token through a token role with the given policies. Returns
// { token, accessor, ttl } (ttl = lease_duration seconds, falls back to
// DEFAULT_TTL). Roles: sso-broker (24h period — user/admin tokens, re-minted
// from cache) and sso-app (768h period — long-lived external-app credentials,
// kept alive via their stored accessor by the renewal loop below).
async function mintToken(policies, role = ROLE) {
const res = await bao('POST', `auth/token/create/${role}`, { policies });
const json = await res.json();
const token = json && json.auth && json.auth.client_token;
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
return { token, ttl };
return { token, accessor: json.auth.accessor, 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 ──────────────────────────────────────────────────────────
function userPolicyHcl(uid) {
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
// into a policy path, so reject anything but a safe charset.
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
// contents of the namespace: `.../*` covers nested paths but NOT the
// directory itself, so without it the /vault secrets list 403s.
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
async function userPolicyHcl(uid) {
const granted = await sharedPolicyRules('user', uid);
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}
// Mint (or return the cached) per-user token 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;
@@ -107,47 +154,164 @@ async function getOrCreateUserToken(uid) {
// ── Admin token (read/write all of secret/) ─────────────────────────────────
function adminPolicyHcl() {
// The bare `secret/metadata` / `secret/metadata/` grants let an admin LIST
// the KV mount root (the top-level dirs); `secret/metadata/*` covers nested
// paths but NOT the root itself, so without it the /vault secrets list 403s.
return `path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }`;
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
}
async function getOrCreateAdminToken(uid) {
await ensurePolicy('sso-admin', adminPolicyHcl());
const cacheKey = `vault_token:admin:${uid || 'global'}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
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) {
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
async function appPolicyHcl(name) {
const granted = await sharedPolicyRules('app', name);
return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
// (the admin UI shows it with a copy button); it is not stored retrievably, so
// a later compromise of an admin session cannot recover previously-minted app
// tokens. The caller must record it in the external app immediately.
async function mintAppToken(name) {
// 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).
//
// What IS stored is the token's ACCESSOR (VaultAppToken row): an accessor
// cannot authenticate, but it lets the renewal loop below keep the (periodic)
// token alive and lets a re-mint revoke the app's previous token so exactly
// one credential per app is ever live.
async function mintAppToken(name, actorUid) {
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
}
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
const { token, ttl } = await mintToken([`app-${name}`]);
await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
// App tokens are long-lived credentials: mint via the sso-app role (768h
// period) so a renewal inside every 32-day window keeps them alive forever.
// Fall back to the broker's own 24h role on deployments whose setup.sh
// predates the sso-app role (re-running setup.sh creates it).
let minted;
try {
minted = await mintToken([`app-${name}`], 'sso-app');
} catch (e) {
console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
minted = await mintToken([`app-${name}`]);
}
const { token, accessor, ttl } = minted;
// Replace the app's accessor row; revoke the superseded token (best-effort —
// it may already be expired) so re-minting never leaves a zombie credential.
try {
const existing = await VaultAppToken.getByName(name);
if (existing) {
await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
await existing.delete();
}
if (accessor) {
await VaultAppToken.create({
name, accessor,
lastRenewedAt: Date.now(),
created_by: actorUid, created_on: Date.now(),
});
}
} catch (e) {
// Accessor bookkeeping must never block handing the token out; without a
// row the token simply isn't auto-renewed (it still lives one full period).
console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
}
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
}
// ── App-token renewal loop ──────────────────────────────────────────────────
// Walks the stored accessors and renews each token (auth/token/renew-accessor),
// resetting its periodic clock. Runs at boot and then every RENEW_INTERVAL_MS —
// far inside both possible periods (24h fallback and 768h), so a downstream
// app's token stays valid for as long as sso is running. Failures are recorded
// on the row (visible to admins in the DB / future UI) and never throw.
const RENEW_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — several chances per 24h period
let renewTimer;
async function renewAppTokens() {
let rows;
try { rows = await VaultAppToken.list(); }
catch (e) { console.error('vault_broker: app-token renewal: could not list accessors:', e.message); return; }
for (const row of rows) {
try {
const res = await baoConf.request('POST', 'auth/token/renew-accessor', { accessor: row.accessor });
if (res.ok) {
await row.update({ lastRenewedAt: Date.now(), lastError: null });
} else {
const text = await res.text().catch(() => '');
// 400 "invalid accessor" = token expired or was revoked out-of-band;
// keep the row + error so the admin can see the app needs a re-mint.
await row.update({ lastError: `renew failed (${res.status}) ${text}` });
console.warn(`vault_broker: renew of app token '${row.name}' failed (${res.status}) — re-mint it from the vault UI if the app is still in use.`);
}
} catch (e) {
try { await row.update({ lastError: e.message }); } catch (e2) { /* best-effort */ }
console.error(`vault_broker: renew of app token '${row.name}' errored:`, e.message);
}
}
}
// Start the loop (idempotent). unref() so an open handle never blocks exit.
function startAppTokenRenewal() {
if (renewTimer) return renewTimer;
renewAppTokens().catch((e) => console.error('vault_broker: initial app-token renewal failed:', e.message));
renewTimer = setInterval(() => {
renewAppTokens().catch((e) => console.error('vault_broker: app-token renewal failed:', e.message));
}, RENEW_INTERVAL_MS);
if (renewTimer.unref) renewTimer.unref();
return renewTimer;
}
// ── 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
@@ -190,17 +354,13 @@ async function scopeGuard(req, res, next) {
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
}
// Defense-in-depth: confirm the requested path is within the subject's
// namespace. Admins roam all of secret/; users are confined to
// secret/users/<uid>/. (The token's own policy enforces the same at the
// OpenBao layer; this catches a buggy/malicious client early with a clear
// 403 instead of an opaque OpenBao denial.)
const norm = normalizeVaultPath(req.path);
if (norm === null) {
return res.status(403).json({ error: 'vault paths must be under /secret/' });
}
const base = `/secret/users/${uid}`;
const allowed = admin || norm === base || norm.startsWith(base + '/');
const userBase = `/secret/users/${uid}`;
const sharedBase = `/secret/shared`;
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
if (!allowed) {
return res.status(403).json({ error: 'path outside your vault namespace' });
}
@@ -215,15 +375,20 @@ function vaultProxy() {
target: VAULT_ADDR,
changeOrigin: true,
pathRewrite: { '^/api/vault': '/v1' },
on: {
proxyReq(proxyReq, req, res, options) {
fixRequestBody(proxyReq, req, res, options);
// Inject ONLY the server-minted scoped token; strip the client's
// sso session/api auth so it never reaches OpenBao.
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
proxyReq.removeHeader('auth-token');
proxyReq.removeHeader('authorization');
},
// http-proxy-middleware v2 API: hooks are top-level onProxyReq/onError,
// NOT the v3 `on: { proxyReq }` shape. v2 silently ignores an `on` key,
// which shipped this proxy with NO token injection — every /api/vault
// call reached OpenBao unauthenticated and 403'd.
onProxyReq(proxyReq, req, res, options) {
// Header ops MUST precede fixRequestBody: it write()s the parsed body
// onto proxyReq, which flushes headers — setHeader after that throws
// (swallowed upstream), silently dropping the token on every write.
// Inject ONLY the server-minted scoped token; strip the client's
// sso session/api auth so it never reaches OpenBao.
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
proxyReq.removeHeader('auth-token');
proxyReq.removeHeader('authorization');
fixRequestBody(proxyReq, req, res, options);
},
});
}
@@ -237,7 +402,7 @@ mintAppRouter.post('/', async (req, res, next) => {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const name = (req.body && req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name is required' });
const result = await mintAppToken(name);
const result = await mintAppToken(name, req.user && req.user.uid);
res.json(result);
} catch (e) {
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
@@ -253,4 +418,16 @@ module.exports = {
scopeGuard,
vaultProxy,
mintAppRouter,
};
// app-token lifecycle
renewAppTokens,
startAppTokenRenewal,
VaultAppToken,
// sharing
SharedSecret,
SharedSecretGrant,
userPolicyHcl,
appPolicyHcl,
grantSharedSecret,
revokeSharedSecret,
reconcileGrantee,
};
+112
View File
@@ -0,0 +1,112 @@
<%- include('top') %>
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="fa-solid fa-microchip"></i> Theta Agents <small class="text-muted">(connected hosts)</small></h2>
<button class="btn btn-outline-primary" onclick="loadAgents()"><i class="fa-solid fa-rotate"></i> Refresh</button>
</div>
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Connected agents</h5></div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Host</th>
<th>IP</th>
<th>Status</th>
<th style="width:110px">CPU</th>
<th style="width:110px">RAM</th>
<th style="width:110px">Disk</th>
<th>ZFS</th>
<th>GPU</th>
<th>Last seen</th>
</tr>
</thead>
<tbody id="agents-tbody">
<tr><td colspan="9" class="text-center text-muted">Loading agents...</td></tr>
</tbody>
</table>
</div>
</div>
<p class="text-muted small mt-3">
Live data from the theta-agent telemetry stream. An agent reports hostname/IP discovery and
CPU/RAM/disk/ZFS/GPU usage every ~60s over the WebSocket; "Online" means seen in the last 90s.
</p>
</div>
<script type="text/javascript">
app.auth.forceLogin(['app_sso_admin', 'admin']);
let agentsById = {}; // token -> agent record
function bar(val) {
val = Math.max(0, Math.min(100, val || 0));
return `<div class="progress" style="height:8px"><div class="progress-bar" role="progressbar" style="width:${val}%"></div></div>`;
}
function timeAgo(iso) {
if (!iso) return '';
const m = moment(iso);
return m.isValid() ? m.fromNow() : '';
}
function esc(s) {
if (s == null) return '';
return app.util.escapeHtml(String(s));
}
function renderRow(id, a) {
const d = a.discovery || {};
const t = a.telemetry || {};
const online = !!a.isOnline;
const badge = `<span class="badge ${online ? 'bg-success' : 'bg-secondary'}">${online ? 'Online' : 'Offline'}</span>`;
const cpu = t.cpu_usage_percent != null ? t.cpu_usage_percent : 0;
const ram = t.ram_usage_percent != null ? t.ram_usage_percent : 0;
const disk = t.disk_usage_percent != null ? t.disk_usage_percent : 0;
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
return `<tr id="agent-${id}">
<td><strong>${esc(a.hostname || 'unknown')}</strong>${d.location ? `<div class="small text-muted">${esc(d.location)}</div>` : ''}</td>
<td>${esc(a.ipAddress || '')}</td>
<td>${badge}</td>
<td>${cpu}% ${bar(cpu)}</td>
<td>${ram}% ${bar(ram)}</td>
<td>${disk}% ${bar(disk)}</td>
<td>${esc(t.zfs_health || 'N/A')}</td>
<td>${gpu}</td>
<td class="small text-muted">${timeAgo(a.lastSeen)}</td>
</tr>`;
}
async function loadAgents() {
const tbody = document.getElementById('agents-tbody');
try {
const res = await app.api.get('agent/nodes');
const agents = (res && res.agents) || [];
agentsById = {};
agents.forEach(a => { agentsById[a.token] = a; });
tbody.innerHTML = agents.length
? agents.map(a => renderRow(a.token, a)).join('')
: '<tr><td colspan="9" class="text-center text-muted">No agents connected.</td></tr>';
} catch (err) {
tbody.innerHTML = `<tr><td colspan="9" class="text-center text-danger">Error loading agents: ${esc(err && err.message || err)}</td></tr>`;
}
}
// Live updates from the server's agent.* socket.io broadcasts. The app's
// default socket is scoped to P2PSub, so open a dedicated socket here.
const agentSocket = io({ auth: { token: app.auth.getToken() } });
agentSocket.on('agent.telemetry', (msg) => {
const a = agentsById[msg && msg.token];
if (a) { a.telemetry = msg.payload; a.isOnline = true; const row = document.getElementById('agent-' + msg.token); if (row) row.outerHTML = renderRow(msg.token, a); }
});
agentSocket.on('agent.discovery', (msg) => {
const a = agentsById[msg && msg.token];
if (a) { a.discovery = msg.payload; a.hostname = (msg.payload && msg.payload.hostname) || a.hostname; const row = document.getElementById('agent-' + msg.token); if (row) row.outerHTML = renderRow(msg.token, a); }
});
loadAgents();
// Re-fetch periodically to reflect connect/disconnect + isOnline (90s window).
setInterval(loadAgents, 30000);
</script>
<%- include('bottom') %>
+69 -112
View File
@@ -1,4 +1,5 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']);
@@ -48,7 +49,7 @@
async function saveConf() {
const btn = $('#btn-save');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
smtp: {
@@ -76,7 +77,7 @@
try {
await app.api.post('conf', payload);
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
app.messages.toast('Configuration saved successfully!', 'success');
} catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally {
@@ -93,7 +94,7 @@
const btn = $('#btn-test-email');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
@@ -126,7 +127,7 @@
const btn = $('#btn-test-sms');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
@@ -174,7 +175,7 @@
async function saveProxyConf() {
const btn = $('#btn-save-proxy');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
oidc: {
@@ -254,7 +255,7 @@
function renderMessagingPlugins() {
const $list = $('#messaging-plugins-list').empty();
if (messagingPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-3"><i class="fas fa-plug text-secondary mb-2 fs-3"></i><br>No messaging plugins configured.</div>');
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
return;
}
messagingPlugins.forEach(p => {
@@ -303,76 +304,52 @@
}
</script>
<div class="container-fluid py-4 px-md-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4 pb-3 border-bottom">
<div>
<h3 class="fw-bold mb-1"><i class="fas fa-sliders-h text-primary me-2"></i> System Configuration</h3>
<p class="text-muted mb-0 small">
Manage stack configuration (SMTP, OAuth, VoIP.ms, Proxy, Terms of Service, and Messaging Plugins). Secrets are stored in OpenBao.
</p>
</div>
<div>
<button class="btn btn-outline-secondary me-2" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
<div class="row g-4">
<!-- Sidebar Navigation -->
<div class="col-lg-3 col-md-4">
<div class="card shadow-sm border-0 sticky-top" style="top: var(--sw-content-offset, 70px);">
<div class="list-group list-group-flush rounded-3" id="conf-nav-list" role="tablist">
<a class="list-group-item list-group-item-action active d-flex align-items-center py-3" id="tab-nav-oauth" data-bs-toggle="list" href="#pane-oauth" role="tab">
<i class="fas fa-key text-success me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">OAuth & JWT</div>
<div class="small text-muted">Issuer & Token Lifetimes</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-smtp" data-bs-toggle="list" href="#pane-smtp" role="tab">
<i class="fas fa-envelope text-primary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Email (SMTP)</div>
<div class="small text-muted">Mail Delivery & Testing</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-sms" data-bs-toggle="list" href="#pane-sms" role="tab">
<i class="fas fa-comment-sms text-info me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">SMS & Messaging</div>
<div class="small text-muted">VoIP.ms & Webhook Plugins</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-proxy" data-bs-toggle="list" href="#pane-proxy" role="tab">
<i class="fas fa-shield-alt text-warning me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Proxy Secrets</div>
<div class="small text-muted">OpenBao Integration</div>
</div>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-tos" data-bs-toggle="list" href="#pane-tos" role="tab">
<i class="fas fa-file-contract text-secondary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Terms of Service</div>
<div class="small text-muted">User Agreement & Policy</div>
</div>
</a>
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
</button>
</li>
</ul>
<div>
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
</div>
</div>
<!-- Main Content Panes -->
<div class="col-lg-9 col-md-8">
<div class="tab-content" id="conf-tab-content">
<!-- OAuth Pane -->
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
</div>
<div class="card-body p-4">
<div class="card-body p-4">
<div class="tab-content" id="confTabContent">
<!-- OAuth & JWT Tab -->
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
@@ -396,16 +373,11 @@
</div>
</div>
</div>
</div>
</div>
<!-- SMTP Pane -->
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
</div>
<div class="card-body p-4">
<!-- SMTP Tab -->
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">SMTP Host</label>
@@ -449,16 +421,11 @@
<div class="form-text">Saves current SMTP config and sends a test message.</div>
</div>
</div>
</div>
</div>
<!-- SMS & Messaging Pane -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
</div>
<div class="card-body p-4">
<!-- SMS & Messaging Tab -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">API Username</label>
@@ -495,16 +462,10 @@
</div>
<div id="messaging-plugins-list"></div>
</div>
</div>
</div>
<!-- Proxy Secrets Pane -->
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
</div>
<div class="card-body p-4">
<!-- Proxy Secrets Tab -->
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
@@ -537,17 +498,13 @@
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
</div>
</div>
</div>
<!-- Terms of Service Pane -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<span class="small text-muted" id="tos-meta"></span>
</div>
<div class="card-body p-4">
<!-- Terms of Service Tab -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<span class="small text-muted" id="tos-meta"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
@@ -559,9 +516,9 @@
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
</div>
+76 -10
View File
@@ -201,7 +201,10 @@
<h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
<p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
</div>
</div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
@@ -1259,9 +1262,10 @@
function renderDiscoveryTable() {
const search = $('#discovery-search-filter').val().toLowerCase();
const filtered = allDiscoveryResources.filter(r => {
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
const isManaged = !!(r.metadata && r.metadata.managed);
if(isManaged) return false;
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
// Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
return true;
});
@@ -1582,15 +1586,77 @@
}
}
async function deleteDiscoveryPlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this discovery plugin?');
if (!ok) return;
var discoveryPluginTypes = [];
function openNewDiscoveryPluginModal() {
app.api.get('plugins/types', function(err, res) {
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
if (discoveryPluginTypes.length === 0) {
app.messages.toast('No discovery plugin types available', 'warning');
return;
}
const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
const bodyHtml = `
<div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label>
<select id="new-plugin-type" class="form-select shadow-sm">${options}</select>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Instance Name</label>
<input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Slug</label>
<input type="text" id="new-plugin-slug" class="form-control shadow-sm font-monospace" placeholder="e.g. local-subnet-scanner">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Cron Schedule</label>
<input type="text" id="new-plugin-cron" class="form-control shadow-sm font-monospace" value="*/15 * * * *">
<div class="form-text">Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
</div>
`;
app.modal.open({
title: 'Configure New Discovery Plugin',
bodyHtml: bodyHtml,
size: 'md'
});
});
}
async function saveNewDiscoveryPlugin() {
const type = $('#new-plugin-type').val();
const name = $('#new-plugin-name').val().trim();
const slug = $('#new-plugin-slug').val().trim() || name.toLowerCase().replace(/[^a-z0-9]/g, '-');
const cron = $('#new-plugin-cron').val().trim() || '*/15 * * * *';
const enabled = $('#new-plugin-enabled').is(':checked');
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Discovery plugin deleted', 'success');
await app.api.post('plugins', {
pluginType: type,
name,
slug,
cron,
enabled,
config: {}
});
app.messages.toast('Discovery plugin created successfully!', 'success');
app.modal.close();
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
}
}
+252 -2
View File
@@ -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>
@@ -55,6 +56,7 @@
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
<div class="card-body">
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/&lt;name&gt;/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
<p class="text-muted small">The token is periodic: it stays valid as long as the app renews it within its period (<code>POST /v1/auth/token/renew-self</code>). If it lapses, mint a new one here — the app's policy and stored secrets are kept.</p>
<div class="mb-3">
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
@@ -83,6 +85,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>
@@ -134,7 +231,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
// key relative to the subject's namespace (so 'foo' for a user means
// secret/data/users/<uid>/foo).
function vpath(kind, key) {
return `secret/${kind}/${VAULT_BASE}${key}`;
let cleanKey = key || '';
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
if (VAULT_BASE) {
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
}
return `secret/${kind}/${cleanKey}`;
}
function apiCall(method, path, body = null) {
@@ -156,7 +258,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
async function loadSecrets() {
try {
const res = await apiCall('GET', vpath('metadata', '?list=true'));
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
const res = await apiCall('GET', listPath);
const listEl = document.getElementById('secrets-list');
listEl.innerHTML = '';
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
@@ -301,6 +404,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
@@ -314,6 +563,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>