diff --git a/nodejs/tests/vault_broker.test.js b/nodejs/tests/vault_broker.test.js index c8548d7..06bfb74 100644 --- a/nodejs/tests/vault_broker.test.js +++ b/nodejs/tests/vault_broker.test.js @@ -29,8 +29,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') { diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 0ea03f2..19e499f 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -79,17 +79,19 @@ async function mintToken(policies) { return { token, ttl }; } +// ── Per-user token ────────────────────────────────────────────────────────── // ── 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/` 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"] }`; + return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/data/shared" { capabilities = ["read", "list"] } +path "secret/data/shared/*" { capabilities = ["read", "list"] } +path "secret/metadata/shared" { capabilities = ["read", "list"] } +path "secret/metadata/shared/" { capabilities = ["read", "list"] } +path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`; } // Mint (or return the cached) per-user token confined to secret/users//*. @@ -107,13 +109,13 @@ 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) { @@ -128,11 +130,16 @@ async function getOrCreateAdminToken(uid) { // ── Per-app token (minted ONCE, returned to the caller, never cached) ─────── function appPolicyHcl(name) { - // The bare `secret/metadata/apps/` 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"] }`; + return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/data/shared" { capabilities = ["read", "list"] } +path "secret/data/shared/*" { capabilities = ["read", "list"] } +path "secret/metadata/shared" { capabilities = ["read", "list"] } +path "secret/metadata/shared/" { capabilities = ["read", "list"] } +path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`; } // Create the app- policy + mint a token for it. Returns the token ONCE @@ -190,17 +197,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//. (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' }); } diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index e0f78dd..7b9c795 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -1,4 +1,5 @@ <%- include('top') %> + -
- -
-
-

System Configuration

-

- Manage stack configuration (SMTP, OAuth, VoIP.ms, Proxy, Terms of Service, and Messaging Plugins). Secrets are stored in OpenBao. -

-
-
- - -
-
- -
- -
-
-
- - -
-
OAuth & JWT
-
Issuer & Token Lifetimes
-
-
- - -
-
Email (SMTP)
-
Mail Delivery & Testing
-
-
- - -
-
SMS & Messaging
-
VoIP.ms & Webhook Plugins
-
-
- - -
-
Proxy Secrets
-
OpenBao Integration
-
-
- - -
-
Terms of Service
-
User Agreement & Policy
-
-
+
+
+
+
+ +
+ +
+ + +
-
-
- -
-
- - -
-
-
-
OAuth 2.0 & JWT Settings
-
-
+
+
+ + +
+
OAuth 2.0 & JWT Settings
+

Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.

@@ -396,16 +373,11 @@
-
-
- -
-
-
-
SMTP Server Settings
-
-
+ +
+
SMTP Server Settings
+

System mail server credentials for password resets, notifications, and verification emails.

@@ -449,16 +421,11 @@
Saves current SMTP config and sends a test message.
-
-
- -
-
-
-
VoIP.ms SMS Integration
-
-
+ +
+
VoIP.ms SMS Integration
+

Configure VoIP.ms API credentials for delivering SMS 2FA codes.

@@ -495,16 +462,10 @@
-
-
- -
-
-
-
OpenBao Proxy Integration
-
-
+ +
+
OpenBao Proxy Integration

Secrets stored directly in OpenBao (secret/proxy/conf) and consumed by Proxy at boot.

OAuth / OIDC Client
@@ -537,17 +498,13 @@
-
-
- -
-
-
-
Terms of Service Editor
- -
-
+ +
+
+
Terms of Service Editor
+ +
@@ -559,9 +516,9 @@
+
-
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index a452748..dd329cb 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -201,7 +201,10 @@
Discovery Plugins

Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.

- +
+ + +
@@ -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 => ``).join(''); + const bodyHtml = ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)
+
+
+ + +
+
+ + +
+ `; + + 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'); } } diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index e3ae199..b00c393 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -134,7 +134,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" // key relative to the subject's namespace (so 'foo' for a user means // secret/data/users//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 +161,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps//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) {