v1.16.0: OpenBao as central secrets store + vault broker + UI rework
- Boot: bao-conf.init('sso-manager') replaces conf_manager; deep-merges
secret/sso-manager/conf over file config (fail-soft). Scoped VAULT_TOKEN
(policy sso-broker), never root.
- /api/vault reworked: middleware.auth -> scopeGuard -> token-injecting
proxy. vault_broker.js mints Redis-cached per-user (user-<uid>) /
per-admin (sso-admin) tokens via the sso-broker role; scopeGuard enforces
path prefix on top of the OpenBao policy. Client auth-token stripped.
- vault UI renamed (vaultwarden.ejs -> vault.ejs), /vault route auth-gated,
role-scoped: users see only secret/users/<uid>/, admins get free-form +
Apps mint tab (secret/apps/<name>/*, token shown once).
- api_conf.js writes via bao-conf.set('sso-manager', ...).
- Remediation: config/*-secrets.js untracked+gitignored, test_plugins.js
deleted, proxy-secrets.js.example placeholder added. Secrets remain in
git history; provider-side rotation is the real fix.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,11 @@ ops/cookbooks/vendor
|
|||||||
secrets.json
|
secrets.json
|
||||||
secrets.js
|
secrets.js
|
||||||
|
|
||||||
|
# Per-deployment secret files (real LDAP/SMTP/jwtSecret + generated OAuth
|
||||||
|
# creds). theta-env bind-mounts ./config and generates/fills these at setup;
|
||||||
|
# they must never be committed. The empty *.example templates ARE tracked.
|
||||||
|
config/*-secrets.js
|
||||||
|
|
||||||
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
||||||
docs/_site
|
docs/_site
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,58 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.16.0] - 2026-08-01
|
||||||
|
|
||||||
|
OpenBao becomes the central secrets store for the theta42 stack, and the SSO
|
||||||
|
Manager becomes its broker. This is the SSO's half of the move: it loads its
|
||||||
|
own secrets from OpenBao, mints scoped tokens for users and external apps,
|
||||||
|
and exposes a fixed, role-scoped personal-secrets UI.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Secrets now load from OpenBao at boot** via
|
||||||
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
|
deep-merges `secret/sso-manager/conf` over the file-loaded config
|
||||||
|
(replacing the old `utils/conf_manager.js`, which did a shallow-per-key
|
||||||
|
merge). `bin/www` runs `bao-conf.init()` after `models.initORM()` and
|
||||||
|
before `listen`. Fail-soft: if OpenBao is unreachable, boot continues from
|
||||||
|
`CONF_SECRETS`. The SSO authenticates with a scoped `VAULT_TOKEN` (policy
|
||||||
|
`sso-broker`), never the root token. The admin **Configuration** UI
|
||||||
|
(`/api/conf`) now writes through `bao-conf.set('sso-manager', …)`.
|
||||||
|
- **`/api/vault` proxy reworked** — the old endpoint was an ungated
|
||||||
|
pass-through that never injected an `X-Vault-Token` (so the UI was both
|
||||||
|
ungated *and* broken). It is now `middleware.auth` → `scopeGuard` → a
|
||||||
|
token-injecting proxy. `scopeGuard` resolves a per-user (`user-<uid>`) or
|
||||||
|
per-admin (`sso-admin`) token via the new `utils/vault_broker.js`
|
||||||
|
(Redis-cached, minted through the `sso-broker` token role) and enforces a
|
||||||
|
path prefix as a second layer on top of the OpenBao policy. The client
|
||||||
|
`auth-token` is stripped; only the server-minted token reaches OpenBao.
|
||||||
|
- **Vault UI reworked and renamed** (`views/vaultwarden.ejs` →
|
||||||
|
`views/vault.ejs`; the `/vault` route is now `middleware.auth`-gated).
|
||||||
|
Non-admin users see only their `secret/users/<uid>/` namespace; admins get
|
||||||
|
free-form path entry across `secret/` plus an **Apps** tab to mint scoped
|
||||||
|
tokens for external apps (`secret/apps/<name>/*`, shown once with copy +
|
||||||
|
`curl` convention).
|
||||||
|
- Bumped package version to track the release tag.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `nodejs/utils/conf_manager.js` (replaced by `@simpleworkjs/bao-conf`).
|
||||||
|
- `nodejs/views/vaultwarden.ejs` (renamed `vault.ejs`).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Committed-secrets remediation.** `config/sso-secrets.js` (LDAP bind
|
||||||
|
password, SMTP, `oauth.jwtSecret`) and `nodejs/test_plugins.js` (a
|
||||||
|
hardcoded Proxmox root API token and a UniFi password) were tracked on
|
||||||
|
master. They are now untracked + gitignored (`config/*-secrets.js`), and
|
||||||
|
`test_plugins.js` is deleted; `config/proxy-secrets.js.example` added as a
|
||||||
|
placeholder template. **The secrets remain in git history — rotation at
|
||||||
|
the providers is the real remediation and is the operator's to perform.**
|
||||||
|
OpenBao is now the authoritative store; the local files are seed artifacts
|
||||||
|
only.
|
||||||
|
|
||||||
|
> Note: releases v1.12.0–v1.15.2 were tagged from merge PRs without
|
||||||
|
> corresponding `CHANGELOG.md` entries or GitHub releases; this entry
|
||||||
|
> resumes the changelog at v1.16.0.
|
||||||
|
|
||||||
## [1.11.0] - 2026-07-31
|
## [1.11.0] - 2026-07-31
|
||||||
|
|
||||||
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
|
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
|
|||||||
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Secrets are loaded from **OpenBao** at boot via
|
||||||
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
|
deep-merges `secret/sso-manager/conf` over the file-loaded config (fail-soft:
|
||||||
|
if OpenBao is unreachable, boot continues from `CONF_SECRETS`). The SSO
|
||||||
|
authenticates to OpenBao with the scoped `VAULT_TOKEN` (env, policy
|
||||||
|
`sso-broker`) — never the root token.
|
||||||
|
|
||||||
|
The SSO also acts as the **vault broker** for the whole stack: it mints
|
||||||
|
per-user (`user-<uid>`) and per-admin (`sso-admin`) tokens through the
|
||||||
|
`sso-broker` token role and exposes the personal-secrets UI at **Vault → My
|
||||||
|
Secrets** (`secret/users/<uid>/*`, server-side token injection + path-scope
|
||||||
|
guard) and an admin **Apps** tab to mint scoped tokens for external apps
|
||||||
|
(`secret/apps/<name>/*`). The old `utils/conf_manager.js` was replaced by
|
||||||
|
`@simpleworkjs/bao-conf`; the admin **Configuration** UI (`/api/conf`) now
|
||||||
|
writes `secret/sso-manager/conf` through `bao-conf.set`.
|
||||||
|
|
||||||
|
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
|
||||||
|
not the authoritative store. For the full architecture, policies, token model,
|
||||||
|
and rotation procedure, see theta-env's
|
||||||
|
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
oidc: {
|
|
||||||
clientId: '',
|
|
||||||
clientSecret: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Example proxy secrets file. theta-env generates a real ./config/proxy-secrets.js
|
||||||
|
// from this shape at setup (with empty clientId/clientSecret), then bootstrap.js
|
||||||
|
// writes the SSO-generated OAuth client creds into it AND into OpenBao
|
||||||
|
// (secret/proxy/conf). The proxy loads it via @simpleworkjs/conf, then overlays
|
||||||
|
// secret/proxy/conf from OpenBao via @simpleworkjs/bao-conf at boot.
|
||||||
|
//
|
||||||
|
// The real file is gitignored (config/*-secrets.js) — never commit live creds.
|
||||||
|
// This .example is tracked to document the expected shape only.
|
||||||
|
module.exports = {
|
||||||
|
oidc: {
|
||||||
|
// The SSO registers the proxy as an OAuth client and writes the real
|
||||||
|
// values here (and into OpenBao). "set-me" is the bootstrap placeholder.
|
||||||
|
clientId: 'set-me',
|
||||||
|
clientSecret: 'set-me',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Local per-deployment configuration for this Theta42 instance.
|
|
||||||
// This file is gitignored — it contains real secrets and per-deployment
|
|
||||||
// values. The committed conf/base.js now ships generic defaults
|
|
||||||
// (example.com / localhost); the theta42-specific non-secret values that
|
|
||||||
// used to live in base.js have been migrated here so this instance keeps
|
|
||||||
// working. New deployments should put their own values here or in app_* env.
|
|
||||||
module.exports = {
|
|
||||||
port: 3001,
|
|
||||||
name: 'Theta42 SSO',
|
|
||||||
ldap: {
|
|
||||||
url: 'ldap://10.2.0.54',
|
|
||||||
bindDN: 'cn=admin,dc=theta42,dc=com',
|
|
||||||
bindPassword: 'Tomisgaypalm7',
|
|
||||||
userBase: 'ou=people,dc=theta42,dc=com',
|
|
||||||
groupBase: 'ou=groups,dc=theta42,dc=com',
|
|
||||||
},
|
|
||||||
smtp: {
|
|
||||||
host: 'mail.wgnode.com',
|
|
||||||
user: 'noreply@users.theta42.com',
|
|
||||||
// user: '',
|
|
||||||
pass: 'ZxAsQw!2',
|
|
||||||
from: 'Theta42 Accounts <noreply@users.theta42.com>',
|
|
||||||
},
|
|
||||||
voipms: {
|
|
||||||
username: 'wmantly@gmail.com',
|
|
||||||
password: 'EMjQvAuHhD!d5dm',
|
|
||||||
did: '9297353350',
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
issuer: 'https://sso.theta42.com',
|
|
||||||
jwtSecret: '09e2501a1c93aef4d5d713c7db17c800c6d7d6f5f9e9cf2efbdfa37549021bf9',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
+10
-11
@@ -108,18 +108,17 @@ app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
|||||||
app.get('/.well-known/openid-configuration', discovery);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
app.use('/api/webhook', require('./routes/webhook'));
|
app.use('/api/webhook', require('./routes/webhook'));
|
||||||
app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
|
app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
|
||||||
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
|
||||||
|
|
||||||
const vaultApiProxy = createProxyMiddleware({
|
// OpenBao vault API. The broker mints a server-side scoped token per user
|
||||||
target: 'http://openbao:8200',
|
// (per-user user-<uid> or, for admins, sso-admin), enforces the path prefix
|
||||||
changeOrigin: true,
|
// (scopeGuard), and injects ONLY that token into the proxied request — the
|
||||||
pathRewrite: { '^/': '/v1/' },
|
// client's sso auth headers are stripped and never reach OpenBao. Non-admins
|
||||||
on: {
|
// are confined to secret/users/<uid>/*; admins roam all of secret/. The
|
||||||
proxyReq: fixRequestBody
|
// admin-only app-token mint route is mounted BEFORE the proxy so it isn't
|
||||||
}
|
// shadowed by the catch-all /api/vault proxy.
|
||||||
});
|
const vaultBroker = require('./utils/vault_broker');
|
||||||
|
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||||
app.use('/api/vault', middleware.auth, vaultApiProxy);
|
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
||||||
|
|
||||||
// Catch 404 and forward to error handler. If none of the above routes are
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
|
|||||||
+6
-1
@@ -31,7 +31,12 @@ const models = require('../models');
|
|||||||
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||||
*/
|
*/
|
||||||
models.initORM().then(() => {
|
models.initORM().then(() => {
|
||||||
return require('../utils/conf_manager').init();
|
// Overlay secret/sso-manager/conf from OpenBao over the file-loaded conf.
|
||||||
|
// Fail-soft: if OpenBao is unreachable, conf keeps the ./config/sso-secrets.js
|
||||||
|
// values and boot continues. (Same position the old conf_manager held, so
|
||||||
|
// call-time conf readers — which is how sso consumes its secrets — are
|
||||||
|
// unaffected; nothing in sso captures a secret at require time.)
|
||||||
|
return require('@simpleworkjs/bao-conf').init({ path: 'sso-manager', conf });
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
server.listen(port);
|
server.listen(port);
|
||||||
server.on('error', onError);
|
server.on('error', onError);
|
||||||
|
|||||||
Generated
+13
@@ -12,6 +12,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
@@ -1347,6 +1348,18 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/bao-conf": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-HxB2ohFuDKbwTfNh5dXCot0dd6qoP+3Ebz1xKH0eOhKNVNMjHU1p7XZv2VTe+VnHn+DV22Eh8lAgWxnX/pkXUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.13.0",
|
"version": "1.16.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const confManager = require('../utils/conf_manager');
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
@@ -21,9 +21,23 @@ router.get('/', async (req, res) => {
|
|||||||
res.json(editable);
|
res.json(editable);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Shallow-per-key merge of `src` into the live conf object (matches the old
|
||||||
|
// conf_manager.applyConf behaviour: nested objects are spread, not deep-merged,
|
||||||
|
// so call-time conf readers see saved values without a restart).
|
||||||
|
function applyToLiveConf(src) {
|
||||||
|
if (!src) return;
|
||||||
|
for (const key of Object.keys(src)) {
|
||||||
|
if (typeof src[key] === 'object' && src[key] !== null && !Array.isArray(src[key])) {
|
||||||
|
conf[key] = { ...(conf[key] || {}), ...src[key] };
|
||||||
|
} else {
|
||||||
|
conf[key] = src[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/', async (req, res, next) => {
|
router.post('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const existing = await confManager.getVaultConf() || {};
|
const existing = await baoConf.get('sso-manager/conf') || {};
|
||||||
// Deep merge req.body into existing
|
// Deep merge req.body into existing
|
||||||
for (const key of Object.keys(req.body)) {
|
for (const key of Object.keys(req.body)) {
|
||||||
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
|
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
|
||||||
@@ -32,11 +46,15 @@ router.post('/', async (req, res, next) => {
|
|||||||
existing[key] = req.body[key];
|
existing[key] = req.body[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await confManager.setVaultConf(existing);
|
await baoConf.set('sso-manager/conf', existing);
|
||||||
|
// Reflect the saved values in the live conf immediately (the next boot's
|
||||||
|
// bao-conf.init() would pick them up too, but this keeps running readers
|
||||||
|
// current without a restart, as the old conf_manager did).
|
||||||
|
applyToLiveConf(existing);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
+19
-3
@@ -11,6 +11,8 @@ const {Tos} = require('../models/tos');
|
|||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
@@ -65,7 +67,6 @@ router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
|||||||
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
|
||||||
router.get('/conf', async function(req, res, next) {
|
router.get('/conf', async function(req, res, next) {
|
||||||
const permission = require('../utils/permission');
|
|
||||||
try {
|
try {
|
||||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
res.render('conf', {...values});
|
res.render('conf', {...values});
|
||||||
@@ -86,8 +87,23 @@ router.get('/plugins', function(req, res, next) {
|
|||||||
res.redirect('/directory');
|
res.redirect('/directory');
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/vault', function(req, res, next) {
|
router.get('/vault', middleware.auth, async function(req, res, next) {
|
||||||
res.render('vaultwarden', {...values});
|
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
|
||||||
|
// free-form access across all of secret/ plus an Apps tab to mint scoped
|
||||||
|
// tokens for external apps. The /api/vault proxy enforces the same scoping
|
||||||
|
// server-side (scopeGuard + the token's own OpenBao policy).
|
||||||
|
let isAdmin = false;
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
isAdmin = true;
|
||||||
|
} catch (e) { /* non-admin: personal namespace only */ }
|
||||||
|
res.render('vault', {
|
||||||
|
...values,
|
||||||
|
vaultUid: req.user.uid,
|
||||||
|
vaultIsAdmin: isAdmin,
|
||||||
|
vaultBase: isAdmin ? '' : `users/${req.user.uid}/`,
|
||||||
|
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
const proxmox = require('./plugins/discovery/proxmox');
|
|
||||||
const unifi = require('./plugins/discovery/unifi');
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
console.log("=== Running Proxmox Plugin ===");
|
|
||||||
try {
|
|
||||||
const pveData = await proxmox.discover({
|
|
||||||
url: 'https://dl380-0.internal.718it.biz:8006',
|
|
||||||
tokenId: 'root@pam!agy',
|
|
||||||
tokenSecret: '1e7c0e31-6767-4295-bcda-d7acf5df1d9a'
|
|
||||||
});
|
|
||||||
console.log(`Found ${pveData.resources.length} resources and ${pveData.edges.length} edges.`);
|
|
||||||
console.log("Sample resource:");
|
|
||||||
console.log(JSON.stringify(pveData.resources[0], null, 2));
|
|
||||||
console.log("Sample edge:");
|
|
||||||
console.log(JSON.stringify(pveData.edges[0], null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Proxmox failed:", e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n=== Running Unifi Plugin ===");
|
|
||||||
try {
|
|
||||||
const unifiData = await unifi.discover({
|
|
||||||
url: 'https://unifi.718it.biz',
|
|
||||||
user: 'agy',
|
|
||||||
password: 'MyPassword!23'
|
|
||||||
});
|
|
||||||
console.log(`Found ${unifiData.resources.length} resources and ${unifiData.edges.length} edges.`);
|
|
||||||
console.log("Sample resource:");
|
|
||||||
console.log(JSON.stringify(unifiData.resources.find(r => r.kind === 'network_device'), null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Unifi failed:", e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test();
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
const VAULT_URL = process.env.VAULT_ADDR || 'http://openbao:8200';
|
|
||||||
const VAULT_TOKEN = process.env.VAULT_TOKEN || ('ro' + 'ot');
|
|
||||||
|
|
||||||
async function getVaultConf() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, {
|
|
||||||
headers: { 'X-Vault-Token': VAULT_TOKEN }
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
const json = await res.json();
|
|
||||||
return json.data.data;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching conf from Vault:', err);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setVaultConf(newConf) {
|
|
||||||
const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ data: newConf })
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`Vault API error: ${res.status} ${text}`);
|
|
||||||
}
|
|
||||||
applyConf(newConf);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyConf(newConf) {
|
|
||||||
if (!newConf) return;
|
|
||||||
// Deep merge into conf
|
|
||||||
for (const key of Object.keys(newConf)) {
|
|
||||||
if (typeof newConf[key] === 'object' && newConf[key] !== null && !Array.isArray(newConf[key])) {
|
|
||||||
conf[key] = { ...conf[key], ...newConf[key] };
|
|
||||||
} else {
|
|
||||||
conf[key] = newConf[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const vaultConf = await getVaultConf();
|
|
||||||
if (vaultConf) {
|
|
||||||
applyConf(vaultConf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { getVaultConf, setVaultConf, init };
|
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Vault broker — mints scoped OpenBao tokens for end users, admins, and
|
||||||
|
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
||||||
|
// `sso-broker` token role created by theta-env/setup.sh.
|
||||||
|
//
|
||||||
|
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
||||||
|
// secret/apps/<name>/* per-external-app namespace (app-<name> 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).
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const { createClient } = require('redis');
|
||||||
|
const express = require('express');
|
||||||
|
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const permission = require('./permission');
|
||||||
|
|
||||||
|
const ROLE = 'sso-broker';
|
||||||
|
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
||||||
|
|
||||||
|
let redisClient;
|
||||||
|
async function getRedis() {
|
||||||
|
if (!redisClient) {
|
||||||
|
const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis
|
||||||
|
: (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
|
||||||
|
redisClient = createClient({ url });
|
||||||
|
redisClient.on('error', (err) => console.error('Redis vault_broker error', err));
|
||||||
|
await redisClient.connect();
|
||||||
|
}
|
||||||
|
return redisClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheGet(key) {
|
||||||
|
try { return await (await getRedis()).get(key); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
async function cacheSet(key, value, ttl) {
|
||||||
|
try { await (await getRedis()).set(key, value, { EX: ttl }); } catch (e) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-level OpenBao call via @simpleworkjs/bao-conf.request (authenticates with
|
||||||
|
// SSO_VAULT_TOKEN). Throws on non-2xx.
|
||||||
|
async function bao(method, path, body) {
|
||||||
|
const res = await baoConf.request(method, path, body);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`OpenBao ${method} ${path} failed (${res.status}) ${text}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
|
||||||
|
async function ensurePolicy(name, hcl) {
|
||||||
|
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||||
|
if (existing.status === 200) return;
|
||||||
|
if (existing.status !== 404) {
|
||||||
|
const t = await existing.text().catch(() => '');
|
||||||
|
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
||||||
|
}
|
||||||
|
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a token through the sso-broker role with the given policies. Returns
|
||||||
|
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
|
||||||
|
async function mintToken(policies) {
|
||||||
|
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
|
||||||
|
const json = await res.json();
|
||||||
|
const token = json && json.auth && json.auth.client_token;
|
||||||
|
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
||||||
|
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
||||||
|
return { token, ttl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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.
|
||||||
|
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
async function getOrCreateUserToken(uid) {
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
||||||
|
async function getOrCreateAdminToken(uid) {
|
||||||
|
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
||||||
|
const cached = await cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
const { token, ttl } = await mintToken(['sso-admin']);
|
||||||
|
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||||
|
function appPolicyHcl(name) {
|
||||||
|
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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}`]);
|
||||||
|
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
|
||||||
|
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
|
||||||
|
// nothing). The guard mints a server-side token for the user (per-user or
|
||||||
|
// admin) and enforces the path prefix as defense-in-depth on top of the
|
||||||
|
// token's own policy; the proxy injects ONLY that token and strips the
|
||||||
|
// client's sso auth headers so OpenBao never sees them.
|
||||||
|
|
||||||
|
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||||
|
const ADMIN_GROUP = 'app_sso_admin';
|
||||||
|
|
||||||
|
async function isAdmin(user) {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(user, [ADMIN_GROUP]);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a KV-v2 request path by stripping the data/metadata segment so the
|
||||||
|
// prefix check works on the logical path: /secret/data/users/alice/foo ->
|
||||||
|
// /secret/users/alice/foo. Returns null if the path isn't under /secret/.
|
||||||
|
function normalizeVaultPath(p) {
|
||||||
|
const norm = p.replace(/^\/secret\/(data|metadata)\//, '/secret/');
|
||||||
|
if (norm !== '/secret' && !norm.startsWith('/secret/')) return null;
|
||||||
|
return norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scopeGuard(req, res, next) {
|
||||||
|
if (!req.user || req.user.isMachine) {
|
||||||
|
return res.status(403).json({ error: 'machine tokens cannot use the vault API' });
|
||||||
|
}
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const admin = await isAdmin(req.user);
|
||||||
|
let token;
|
||||||
|
try {
|
||||||
|
token = admin ? await getOrCreateAdminToken(uid) : await getOrCreateUserToken(uid);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 + '/');
|
||||||
|
if (!allowed) {
|
||||||
|
return res.status(403).json({ error: 'path outside your vault namespace' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.vaultToken = token;
|
||||||
|
req.vaultIsAdmin = admin;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultProxy() {
|
||||||
|
return createProxyMiddleware({
|
||||||
|
target: VAULT_ADDR,
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: { '^/': '/v1/' },
|
||||||
|
on: {
|
||||||
|
proxyReq(proxyReq, req, res, options) {
|
||||||
|
fixRequestBody(proxyReq, req, res, options);
|
||||||
|
// Inject ONLY the server-minted scoped token; strip the client's
|
||||||
|
// sso session/api auth so it never reaches OpenBao.
|
||||||
|
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
||||||
|
proxyReq.removeHeader('auth-token');
|
||||||
|
proxyReq.removeHeader('authorization');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-only: mint a one-time token for an external app. POST /api/vault/apps
|
||||||
|
// { name } -> { token, ttl, policy, path }. The token is returned ONCE and is
|
||||||
|
// not cached/stored retrievably. Mount BEFORE the /api/vault proxy.
|
||||||
|
const mintAppRouter = express.Router();
|
||||||
|
mintAppRouter.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
const name = (req.body && req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
const result = await mintAppToken(name);
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOrCreateUserToken,
|
||||||
|
getOrCreateAdminToken,
|
||||||
|
mintAppToken,
|
||||||
|
ensurePolicy,
|
||||||
|
scopeGuard,
|
||||||
|
vaultProxy,
|
||||||
|
mintAppRouter,
|
||||||
|
};
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2><i class="fas fa-lock"></i>
|
||||||
|
<% if (vaultIsAdmin) { %> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>
|
||||||
|
<% } else { %> My Secrets <small class="text-muted">(personal namespace)</small><% } %>
|
||||||
|
</h2>
|
||||||
|
<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>
|
||||||
|
<% if (vaultIsAdmin) { %>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="tab-pane fade show active" id="tab-secrets">
|
||||||
|
<div class="d-flex justify-content-end mb-3">
|
||||||
|
<button class="btn btn-primary" onclick="showCreateModal()">
|
||||||
|
<i class="fas fa-plus"></i> New Secret
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
|
||||||
|
<div class="list-group list-group-flush" id="secrets-list">
|
||||||
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteCurrentSecret()"><i class="fas fa-trash"></i> Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<pre id="secret-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="no-secret-selected" class="text-center text-muted mt-5">
|
||||||
|
<i class="fas fa-key fa-4x mb-3 text-secondary"></i>
|
||||||
|
<h4>Select a secret to view its details</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Apps tab (admin only) ───────────────────────────────────────── -->
|
||||||
|
<% if (vaultIsAdmin) { %>
|
||||||
|
<div class="tab-pane fade" id="tab-apps">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<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/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</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">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="mintApp()"><i class="fas fa-key"></i> Mint token</button>
|
||||||
|
<div class="alert alert-danger d-none mt-3" id="app-error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm d-none" id="app-result-card">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">App token</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-muted">Give the external app this token (header <code>X-Vault-Token</code>) and the path convention below.</p>
|
||||||
|
<pre id="app-token" class="bg-dark text-light p-3 rounded"></pre>
|
||||||
|
<h6 class="mt-3">Connection convention</h6>
|
||||||
|
<pre class="bg-light p-2 rounded small">VAULT_ADDR=<%- vaultAddr %>
|
||||||
|
path=secret/apps/<name>/conf
|
||||||
|
curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf" \
|
||||||
|
-H "X-Vault-Token: <token above>"</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Secret Modal -->
|
||||||
|
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="secretModalTitle">Create 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">
|
||||||
|
<% if (vaultIsAdmin) { %>Secret path (under secret/)<% } else { %>Secret name (in your personal namespace)<% } %>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" id="secret-path-input" placeholder="<% if (vaultIsAdmin) { %>e.g. apps/my-service/conf<% } else { %>e.g. database-creds<% } %>">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Secret Data (JSON)</label>
|
||||||
|
<textarea class="form-control" id="secret-data-input" rows="8" style="font-family: monospace;">{
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger d-none" id="secret-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="saveSecret()">Save Secret</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin();
|
||||||
|
|
||||||
|
// Server-derived scoping. VAULT_BASE is '' for admins (free-form under
|
||||||
|
// secret/) or 'users/<uid>/' for everyone else (confined to their personal
|
||||||
|
// namespace). The /api/vault proxy enforces the same server-side; these only
|
||||||
|
// drive the UI.
|
||||||
|
const VAULT_BASE = <%- JSON.stringify(vaultBase) %>;
|
||||||
|
const IS_ADMIN = <%- JSON.stringify(vaultIsAdmin) %>;
|
||||||
|
|
||||||
|
let currentSecretPath = null;
|
||||||
|
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||||
|
|
||||||
|
// Build a vault API path. kind is 'data' or 'metadata'; key is the logical
|
||||||
|
// 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiCall(method, path, 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/vault/' + path, opts).then(async res => {
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Vault API error: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecrets() {
|
||||||
|
try {
|
||||||
|
const res = await apiCall('GET', vpath('metadata', '?list=true'));
|
||||||
|
const listEl = document.getElementById('secrets-list');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="list-group-item text-center text-muted">No secrets found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.data.keys.forEach(key => {
|
||||||
|
// KV list returns dir entries with a trailing slash; admins can still
|
||||||
|
// open them by typing the full path in the modal. Skip dirs in the list
|
||||||
|
// for non-admins (their namespace is flat).
|
||||||
|
if (!IS_ADMIN && key.endsWith('/')) return;
|
||||||
|
const item = document.createElement('a');
|
||||||
|
item.href = '#';
|
||||||
|
item.className = 'list-group-item list-group-item-action d-flex align-items-center';
|
||||||
|
item.innerHTML = `<i class="fas fa-file-alt text-secondary me-3"></i> <span>${key}</span>`;
|
||||||
|
item.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelectorAll('#secrets-list .active').forEach(el => el.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
loadSecretDetails(key);
|
||||||
|
};
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('secrets-list').innerHTML =
|
||||||
|
`<div class="list-group-item text-danger"><i class="fas fa-exclamation-triangle"></i> Error loading secrets: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecretDetails(key) {
|
||||||
|
try {
|
||||||
|
currentSecretPath = key;
|
||||||
|
document.getElementById('no-secret-selected').style.display = 'none';
|
||||||
|
document.getElementById('secret-details-card').style.display = 'block';
|
||||||
|
document.getElementById('secret-title').textContent = key;
|
||||||
|
document.getElementById('secret-content').textContent = 'Loading...';
|
||||||
|
const res = await apiCall('GET', vpath('data', key));
|
||||||
|
if (!res || !res.data || !res.data.data) {
|
||||||
|
document.getElementById('secret-content').textContent = 'No data found.';
|
||||||
|
} else {
|
||||||
|
document.getElementById('secret-content').textContent = JSON.stringify(res.data.data, null, 2);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('secret-content').textContent = `Error: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateModal() {
|
||||||
|
currentSecretPath = null;
|
||||||
|
document.getElementById('secretModalTitle').textContent = 'Create Secret';
|
||||||
|
document.getElementById('secret-path-input').value = '';
|
||||||
|
document.getElementById('secret-path-input').disabled = false;
|
||||||
|
document.getElementById('secret-data-input').value = '{\n "key": "value"\n}';
|
||||||
|
document.getElementById('secret-error').classList.add('d-none');
|
||||||
|
secretModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCurrentSecret() {
|
||||||
|
if (!currentSecretPath) return;
|
||||||
|
document.getElementById('secretModalTitle').textContent = 'Edit Secret';
|
||||||
|
document.getElementById('secret-path-input').value = currentSecretPath;
|
||||||
|
document.getElementById('secret-path-input').disabled = true;
|
||||||
|
document.getElementById('secret-data-input').value = document.getElementById('secret-content').textContent;
|
||||||
|
document.getElementById('secret-error').classList.add('d-none');
|
||||||
|
secretModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSecret() {
|
||||||
|
const errorEl = document.getElementById('secret-error');
|
||||||
|
errorEl.classList.add('d-none');
|
||||||
|
const path = document.getElementById('secret-path-input').value.trim();
|
||||||
|
if (!path) {
|
||||||
|
errorEl.textContent = 'Secret path is required';
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(document.getElementById('secret-data-input').value);
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Invalid JSON: ' + err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiCall('POST', vpath('data', path), { data });
|
||||||
|
secretModal.hide();
|
||||||
|
await loadSecrets();
|
||||||
|
if (currentSecretPath === path || !currentSecretPath) {
|
||||||
|
await loadSecretDetails(path);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentSecret() {
|
||||||
|
if (!currentSecretPath) return;
|
||||||
|
try {
|
||||||
|
await apiCall('DELETE', vpath('metadata', currentSecretPath));
|
||||||
|
currentSecretPath = null;
|
||||||
|
document.getElementById('no-secret-selected').style.display = 'block';
|
||||||
|
document.getElementById('secret-details-card').style.display = 'none';
|
||||||
|
await loadSecrets();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast('Error deleting secret: ' + err.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apps tab (admin) ───────────────────────────────────────────────────
|
||||||
|
async function mintApp() {
|
||||||
|
const errorEl = document.getElementById('app-error');
|
||||||
|
errorEl.classList.add('d-none');
|
||||||
|
const name = document.getElementById('app-name-input').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = 'App name is required';
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/vault/apps', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
document.getElementById('app-token').textContent = result.token;
|
||||||
|
document.getElementById('app-name-display').textContent = name;
|
||||||
|
document.getElementById('app-result-card').classList.remove('d-none');
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSecrets();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<div class="container-fluid py-4">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h2><i class="fas fa-lock"></i> Vault Secrets</h2>
|
|
||||||
<button class="btn btn-primary" onclick="showCreateModal()">
|
|
||||||
<i class="fas fa-plus"></i> New Secret
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card shadow-sm">
|
|
||||||
<div class="card-header bg-light">
|
|
||||||
<h5 class="card-title mb-0">Secrets List</h5>
|
|
||||||
</div>
|
|
||||||
<div class="list-group list-group-flush" id="secrets-list">
|
|
||||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
|
||||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
|
||||||
<div>
|
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()">
|
|
||||||
<i class="fas fa-edit"></i> Edit
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteCurrentSecret()">
|
|
||||||
<i class="fas fa-trash"></i> Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<pre id="secret-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="no-secret-selected" class="text-center text-muted mt-5">
|
|
||||||
<i class="fas fa-key fa-4x mb-3 text-secondary"></i>
|
|
||||||
<h4>Select a secret to view its details</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create/Edit Secret Modal -->
|
|
||||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="secretModalTitle">Create 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">Secret Path (Name)</label>
|
|
||||||
<input type="text" class="form-control" id="secret-path-input" placeholder="e.g. database-creds">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Secret Data (JSON)</label>
|
|
||||||
<textarea class="form-control" id="secret-data-input" rows="8" style="font-family: monospace;">{
|
|
||||||
"username": "",
|
|
||||||
"password": ""
|
|
||||||
}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="alert alert-danger d-none" id="secret-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="saveSecret()">Save Secret</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
app.auth.forceLogin();
|
|
||||||
|
|
||||||
let currentSecretPath = null;
|
|
||||||
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
|
||||||
|
|
||||||
function apiCall(method, path, 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/vault/' + path, opts).then(async res => {
|
|
||||||
if (res.status === 404) return null;
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`Vault API error: ${res.status} ${text}`);
|
|
||||||
}
|
|
||||||
if (res.status === 204) return null;
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSecrets() {
|
|
||||||
try {
|
|
||||||
// In dev mode, v2 kv engine is mounted at secret/
|
|
||||||
const res = await apiCall('GET', 'secret/metadata/?list=true');
|
|
||||||
const listEl = document.getElementById('secrets-list');
|
|
||||||
listEl.innerHTML = '';
|
|
||||||
|
|
||||||
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
|
||||||
listEl.innerHTML = '<div class="list-group-item text-center text-muted">No secrets found</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.data.keys.forEach(key => {
|
|
||||||
const item = document.createElement('a');
|
|
||||||
item.href = '#';
|
|
||||||
item.className = 'list-group-item list-group-item-action d-flex align-items-center';
|
|
||||||
item.innerHTML = `<i class="fas fa-file-alt text-secondary me-3"></i> <span>${key}</span>`;
|
|
||||||
item.onclick = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Update active state
|
|
||||||
document.querySelectorAll('#secrets-list .active').forEach(el => el.classList.remove('active'));
|
|
||||||
item.classList.add('active');
|
|
||||||
|
|
||||||
loadSecretDetails(key);
|
|
||||||
};
|
|
||||||
listEl.appendChild(item);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
document.getElementById('secrets-list').innerHTML =
|
|
||||||
`<div class="list-group-item text-danger"><i class="fas fa-exclamation-triangle"></i> Error loading secrets: ${err.message}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSecretDetails(key) {
|
|
||||||
try {
|
|
||||||
currentSecretPath = key;
|
|
||||||
document.getElementById('no-secret-selected').style.display = 'none';
|
|
||||||
document.getElementById('secret-details-card').style.display = 'block';
|
|
||||||
document.getElementById('secret-title').textContent = key;
|
|
||||||
document.getElementById('secret-content').textContent = 'Loading...';
|
|
||||||
|
|
||||||
const res = await apiCall('GET', `secret/data/${key}`);
|
|
||||||
if (!res || !res.data || !res.data.data) {
|
|
||||||
document.getElementById('secret-content').textContent = 'No data found.';
|
|
||||||
} else {
|
|
||||||
document.getElementById('secret-content').textContent = JSON.stringify(res.data.data, null, 2);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
document.getElementById('secret-content').textContent = `Error: ${err.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCreateModal() {
|
|
||||||
currentSecretPath = null;
|
|
||||||
document.getElementById('secretModalTitle').textContent = 'Create Secret';
|
|
||||||
document.getElementById('secret-path-input').value = '';
|
|
||||||
document.getElementById('secret-path-input').disabled = false;
|
|
||||||
document.getElementById('secret-data-input').value = '{\n "key": "value"\n}';
|
|
||||||
document.getElementById('secret-error').classList.add('d-none');
|
|
||||||
secretModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function editCurrentSecret() {
|
|
||||||
if (!currentSecretPath) return;
|
|
||||||
document.getElementById('secretModalTitle').textContent = 'Edit Secret';
|
|
||||||
document.getElementById('secret-path-input').value = currentSecretPath;
|
|
||||||
document.getElementById('secret-path-input').disabled = true;
|
|
||||||
document.getElementById('secret-data-input').value = document.getElementById('secret-content').textContent;
|
|
||||||
document.getElementById('secret-error').classList.add('d-none');
|
|
||||||
secretModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSecret() {
|
|
||||||
const errorEl = document.getElementById('secret-error');
|
|
||||||
errorEl.classList.add('d-none');
|
|
||||||
|
|
||||||
const path = document.getElementById('secret-path-input').value.trim();
|
|
||||||
if (!path) {
|
|
||||||
errorEl.textContent = 'Secret path is required';
|
|
||||||
errorEl.classList.remove('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(document.getElementById('secret-data-input').value);
|
|
||||||
} catch (err) {
|
|
||||||
errorEl.textContent = 'Invalid JSON: ' + err.message;
|
|
||||||
errorEl.classList.remove('d-none');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiCall('POST', `secret/data/${path}`, { data });
|
|
||||||
secretModal.hide();
|
|
||||||
await loadSecrets();
|
|
||||||
if (currentSecretPath === path || !currentSecretPath) {
|
|
||||||
await loadSecretDetails(path);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
errorEl.textContent = err.message;
|
|
||||||
errorEl.classList.remove('d-none');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCurrentSecret() {
|
|
||||||
if (!currentSecretPath) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiCall('DELETE', `secret/metadata/${currentSecretPath}`);
|
|
||||||
currentSecretPath = null;
|
|
||||||
document.getElementById('no-secret-selected').style.display = 'block';
|
|
||||||
document.getElementById('secret-details-card').style.display = 'none';
|
|
||||||
await loadSecrets();
|
|
||||||
} catch (err) {
|
|
||||||
app.messages.toast('Error deleting secret: ' + err.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init
|
|
||||||
loadSecrets();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<%- include('bottom') %>
|
|
||||||
Reference in New Issue
Block a user