From 70b76c6ed51d4794de2e9f69a858ad8cbf1aa1dc Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 3 Aug 2026 13:54:53 -0400 Subject: [PATCH 01/26] fix(sso): Directory graph live refresh, discovery reconciler, conf layout, vault broker admin roles, and move plugins to directory/conf --- nodejs/config/inventory.sqlite | Bin 61440 -> 61440 bytes nodejs/package-lock.json | 2 +- nodejs/package.json | 2 +- nodejs/routes/discovery.js | 2 +- nodejs/routes/index.js | 9 +- nodejs/services/discovery_reconciler.js | 44 +- nodejs/utils/ui.js | 1 - nodejs/utils/vault_broker.js | 3 +- nodejs/views/conf.ejs | 571 +++++++++++++----------- nodejs/views/directory.ejs | 100 ++++- 10 files changed, 450 insertions(+), 284 deletions(-) diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index 9fe0fc79f641eecf4f0a52764865ad854fdc7e64..b01c0edb82751781e8286c2d29e9ccdde7ec172b 100644 GIT binary patch delta 907 zcma))&1w`u5XX021&P8${0OK6K?e+&ie25+Uwah~A|6CMuRSx}B81ICl7n8%&6A+? z1Bmzl@#B!QXS10X@UY+m=w)@ugul`m2t9C}6ol)ogjdt@d5NCZIC0Vq@kEb$u1I~{6a(5kr6T#FC)uUyntkz!o-HbRJ>n$=aYjJhkS%hZ}s za``S}UB)=$3YY=*Z#4ec&kI7Yovu6>t{e~#B^aCF6*|<$6Gvg=pt%~ChLl4CfZDHc;2n0@+YOYN`ZjP?gI2g#1hUJzUC8XyvP?>)S7;h=OJj1x0s| z7wJ#qH90`RlbfZj!3j=KVkLrAwG1^o>TmDhl%4hVUlxObROod0!rec>e4{QsZCC+>^BSJ|7whP_Zohw0^ z3y|Us__OHRwM}vZE&^UaZ^22WO|=-lndNy8&pGdVb|$TzN$dTMX8XgFw(F(|+o5FBM1s8Uf-<0%h34aQf~;_&i?8Ls~? z4UL&Y%hmL5Saz5S++h_!nIHj;^`M1AAUiUUXxDj(Ov+6=4zjmWhVDa?N|8}W;t9Y~A zdeW?MLF8O+|5PXZvsw7MPHwF%eDtu6-)FLNxsFp`CmY3sBd4Y}KArmQR(QG98{~_4 e(eF{Eg_1!?b~oH_sV;QGRGtNjf~?(WS1 diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 08af081..bc72476 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.20.0", + "version": "1.20.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/nodejs/package.json b/nodejs/package.json index 71f1626..4950cb6 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.20.0", + "version": "1.20.1", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/discovery.js b/nodejs/routes/discovery.js index 6185aa2..ea57436 100644 --- a/nodejs/routes/discovery.js +++ b/nodejs/routes/discovery.js @@ -186,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => { const meta = resource.metadata || {}; meta.managed = true; - await resource.update({ metadata: meta }); + await Resource.update(resource.id, { metadata: meta }); res.json(envelope({ success: true, groups: [accessGroup, adminGroup] })); } catch (err) { next(err); } diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index f021c89..144a4a3 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -84,14 +84,7 @@ router.get('/discovery', function(req, res, next) { }); router.get('/plugins', function(req, res, next) { - // Plugin instances page — loadable/unloadable, configurable plugin copies - // with per-instance secrets in OpenBao. Renders the shell for anyone; the - // client gates with app.auth.forceLogin(['app_sso_admin', - // 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce - // the same server-side. Same header-vs-navigation auth model as /conf and - // /vault (auth-token is a client-set header, not a cookie). - const registry = require('../services/plugin_registry'); - res.render('plugins', {...values, pluginTypes: registry.types }); + res.redirect('/directory'); }); router.get('/vault', function(req, res) { diff --git a/nodejs/services/discovery_reconciler.js b/nodejs/services/discovery_reconciler.js index cfa98e6..c1a30a9 100644 --- a/nodejs/services/discovery_reconciler.js +++ b/nodejs/services/discovery_reconciler.js @@ -12,32 +12,39 @@ class DiscoveryReconciler { res._originalSlug = res.slug; // Keep track for edge mapping let existing = null; - - // Attempt matching by MAC if available (case-insensitive) + const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, ''); + const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim(); + + const allRes = await Resource.list(); + + // 1. Attempt matching by MAC (highest precision) if (res.metadata.interfaces && res.metadata.interfaces.length > 0) { - const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m); + const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12); if (macs.length > 0) { - const allRes = await Resource.list(); existing = allRes.find(r => - r.metadata && r.metadata.interfaces && - r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase())) + r.metadata && ( + (r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) || + (r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac)))) + ) ); } } - - // Fallback matching by IP if no MAC match (weaker) + + // 2. Fallback matching by IP address let ipsToMatch = []; if (res.metadata.interfaces) { ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i); } + if (res.metadata.ip) ipsToMatch.push(res.metadata.ip); if (res.metadata.address) { res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim())); } - + ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))]; + if (!existing && ipsToMatch.length > 0) { - const allRes = await Resource.list(); existing = allRes.find(r => { if (!r.metadata) return false; + if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true; if (r.metadata.address) { const addrs = r.metadata.address.split(',').map(a => a.trim()); if (addrs.some(a => ipsToMatch.includes(a))) return true; @@ -46,14 +53,17 @@ class DiscoveryReconciler { return false; }); } - - // Fallback matching by Slug or Name + + // 3. Fallback matching by Slug, Name, or Base Hostname if (!existing && (res.slug || res.name)) { - const allRes = await Resource.list(); - existing = allRes.find(r => - (res.slug && r.slug === res.slug) || - (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) - ); + const inputName = normalizeHost(res.name || res.slug); + existing = allRes.find(r => { + if (res.slug && r.slug === res.slug) return true; + if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true; + if (inputName && r.name && normalizeHost(r.name) === inputName) return true; + if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true; + return false; + }); } if (existing) { diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 6f27c75..4128e3d 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -44,7 +44,6 @@ module.exports = { {href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, - {href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, // Vault requires login - per-user secrets at secret/users//*. {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 927f41b..0ea03f2 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -156,11 +156,12 @@ async function mintAppToken(name) { // client's sso auth headers so OpenBao never sees them. const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200'; +const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; const ADMIN_GROUP = 'app_sso_admin'; async function isAdmin(user) { try { - await permission.byGroup(user, [ADMIN_GROUP]); + await permission.byGroup(user, ADMIN_GROUPS); return true; } catch (e) { return false; diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index a95939b..e0f78dd 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -2,10 +2,14 @@ -
-
-
-
-

System Configuration

-

- Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service - settings. These are stored securely in OpenBao and take effect immediately. - Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password) - are masked — leave them unchanged to keep the stored value. -

-
-
- - -
+
+ +
+
+

System Configuration

+

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

+
+
+ +
- - -
- -
-
-
-
SMTP Settings
+
+ +
+
+ -
-
- - -
-
- - -
-
- - -
-
- -
- - +
+
+ + +
+
+ + +
+
+
+
OAuth 2.0 & JWT Settings
-
Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.
-
-
- -
- - -
-
Send a test SMS to verify your VoIP.ms configuration is working.
-
-
-
-
- -
- - -
-
Send a test SMS to verify your VoIP.ms configuration is working.
-
-
- - -
-
- - -
-
-
- -
- - +
+
+ + +
+
+ +
+ + +
+
Stored in OpenBao. Leave unchanged to preserve stored value.
+
+
+
+ + +
+
+ + +
-
Send a test email to verify your SMTP configuration is working.
-
-
-
-
- - -
-
-
-
OAuth & JWT Settings
-
-
-
- - -
-
- -
- - -
-
Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
SMS (VoIP.ms)
-
-
-

Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.

-
- - -
-
- - -
-
- -
- - -
-
Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.
-
-
- -
- - -
-
Send a test SMS to verify your VoIP.ms configuration is working.
-
-
-
-
- - -
-
-
-
Proxy Secrets (OpenBao)
-
-
-

These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.

- -
OAuth / OIDC Integration
-
- - -
-
- - -
-
- -
- -
+
-
LDAP Integration
-
- -
- - + +
+
+
+
SMTP Server Settings
-
Password for the Proxy's LDAP service account.
-
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
- +
+
Send Test Email
+
+ + +
+
Saves current SMTP config and sends a test message.
+
+
+
-
-
- -
-
-
-
Terms of Service
- -
-
-
- - + +
+
+
+
VoIP.ms SMS Integration
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ +
+
Send Test SMS
+
+ + +
+
+ +
+ +
+
Messaging Plugins & Webhooks
+ +
+
+
-
- - -
- -
+ + +
+
+
+
OpenBao Proxy Integration
+
+
+

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

+ +
OAuth / OIDC Client
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
LDAP Bind Account
+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+
+
Terms of Service Editor
+ +
+
+
+ + +
+
+ + +
+ + +
+
+
+
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 4afd120..a452748 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -13,7 +13,12 @@ + @@ -187,6 +192,20 @@
+ + +
+
+
+
+
Discovery Plugins
+

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

+
+ +
+
+
+
@@ -1188,6 +1207,7 @@ allEdges.push(res.results); refreshEdgesUI(resourceId); $('#new-edge-target').val(''); + await loadData(); } catch (err) { console.error(err); app.messages.action('Failed to add edge', app.modal.body(), 'danger'); @@ -1199,6 +1219,7 @@ await app.api.delete('directory-admin/edges/' + id); allEdges = allEdges.filter(e => e.id !== id); refreshEdgesUI($('#res-id').val()); + await loadData(); } catch (err) { console.error(err); app.messages.action('Failed to remove edge', app.modal.body(), 'danger'); @@ -1493,7 +1514,7 @@ `; app.modal.open({ - title: ' Install Theta Agent', + title: 'Install Theta Agent', bodyHtml: bodyHtml, size: 'lg' }); @@ -1501,12 +1522,81 @@ updateAgentCommands(); } - // Plugin scheduling moved to the dedicated /plugins page (the Agents & - // Scheduler tab here was its old home). Discovery inventory + the discovery - // results table remain on this page. + var discoveryPlugins = []; + + function loadDiscoveryPlugins() { + app.api.get('plugins', function(err, res) { + if (err) return; + discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery'); + renderDiscoveryPlugins(); + }); + } + + function renderDiscoveryPlugins() { + const $list = $('#discovery-plugins-list').empty(); + if (discoveryPlugins.length === 0) { + $list.append('

No discovery plugins configured.
'); + return; + } + discoveryPlugins.forEach(p => { + const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary'; + const statusText = p.enabled ? 'Loaded' : 'Unloaded'; + const card = ` +
+
+
+
${p.name} ${p.pluginType}
+
${p.slug} | Schedule: ${p.cron}
+
+
+ ${statusText} + + + +
+
+
+ `; + $list.append(card); + }); + } + + async function toggleDiscoveryPlugin(id, state) { + const endpoint = state ? 'load' : 'unload'; + try { + await app.api.post(`plugins/${id}/${endpoint}`, {}); + app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success'); + loadDiscoveryPlugins(); + } catch (e) { + app.messages.toast('Error toggling plugin: ' + e.message, 'danger'); + } + } + + async function runDiscoveryPluginNow(id) { + try { + await app.api.post(`plugins/${id}/run`, {}); + app.messages.toast('Enqueued discovery plugin run', 'success'); + loadDiscoveryPlugins(); + } catch (e) { + app.messages.toast('Error running plugin: ' + e.message, 'danger'); + } + } + + async function deleteDiscoveryPlugin(id) { + const ok = await app.messages.confirm('Are you sure you want to delete this discovery plugin?'); + if (!ok) return; + try { + await app.api.delete(`plugins/${id}`); + app.messages.toast('Discovery plugin deleted', 'success'); + loadDiscoveryPlugins(); + } catch (e) { + app.messages.toast('Error deleting plugin: ' + e.message, 'danger'); + } + } $(document).ready(function(){ loadDiscoveryResources(); + loadDiscoveryPlugins(); }); From d8242b1d538feb25e8c6e74e7aa4d43ab37fb610 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 3 Aug 2026 15:26:59 -0400 Subject: [PATCH 02/26] fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2 --- nodejs/tests/vault_broker.test.js | 4 +- nodejs/utils/vault_broker.js | 59 +++++----- nodejs/views/conf.ejs | 181 ++++++++++++------------------ nodejs/views/directory.ejs | 86 ++++++++++++-- nodejs/views/vault.ejs | 10 +- 5 files changed, 186 insertions(+), 154 deletions(-) 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) { From bf471c2e193512227acf4e6b29cddb1bb97aabf5 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 3 Aug 2026 21:26:55 -0400 Subject: [PATCH 03/26] 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. --- nodejs/package-lock.json | 4 ++-- nodejs/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index bc72476..bdedd2c 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.20.1", + "version": "1.20.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.20.0", + "version": "1.20.2", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 4950cb6..243a2ba 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.20.1", + "version": "1.20.2", "description": "A very simple LDAP management and SSO system", "author": [ { From 948fef4adc31f1dccada960230e273280efb9c9f Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 3 Aug 2026 22:13:58 -0400 Subject: [PATCH 04/26] fix vault 403 + shared secrets (v1.21.0) - vault_broker: always reconcile policy content before serving a cached token (compare-and-skip), so stale stored policies can't cause a recurring 403 'permission denied'; policy content is parsed live by OpenBao, so edits apply to existing tokens immediately. - Shared secrets: publish to secret/shared//; grant read to users and apps by editing the grantee's policy content (live-applied). New SharedSecret/SharedSecretGrant ORM models, /api/shared-secrets router, and a Shared tab in the vault UI. - package.json + lockfile bumped to 1.21.0 to match the tag. Co-Authored-By: Claude --- CHANGELOG.md | 8 + nodejs/app.js | 2 + nodejs/models/index.js | 3 + nodejs/models/shared_secret.js | 56 +++++++ nodejs/models/shared_secret_grant.js | 53 ++++++ nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/routes/api_shared_secrets.js | 208 +++++++++++++++++++++++ nodejs/utils/vault_broker.js | 138 +++++++++++---- nodejs/views/vault.ejs | 242 +++++++++++++++++++++++++++ 10 files changed, 685 insertions(+), 31 deletions(-) create mode 100644 nodejs/models/shared_secret.js create mode 100644 nodejs/models/shared_secret_grant.js create mode 100644 nodejs/routes/api_shared_secrets.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a512211..ec628fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# v1.21.0 +- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied" +- feat: shared secrets — users can publish secrets to secret/shared// and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live) +- feat: shared-secrets API + Shared tab in the vault UI + +# v1.20.0 +- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal) + ## v1.19.0 - Added WebSocket endpoint for theta-agent C2 diff --git a/nodejs/app.js b/nodejs/app.js index ed612b4..20c679e 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -126,6 +126,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/api_plugins')); const vaultBroker = require('./utils/vault_broker'); app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter); app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy()); +// Shared secrets (metadata + grants; data reads go through /api/vault proxy). +app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets')); // Catch 404 and forward to error handler. If none of the above routes are // used, this is what will be called. diff --git a/nodejs/models/index.js b/nodejs/models/index.js index d80dc86..2729920 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -17,6 +17,8 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource'); const { AccessRequest } = require('./access_request'); const { Webhook } = require('./webhook'); const { PluginInstance } = require('./plugin_instance'); +const { SharedSecret } = require('./shared_secret'); +const { SharedSecretGrant } = require('./shared_secret_grant'); async function initORM() { const ormConf = conf.orm || { dialect: 'sqlite', @@ -31,6 +33,7 @@ async function initORM() { conf: { orm: ormConf }, models: [ Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance, + SharedSecret, SharedSecretGrant, Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken ] }); diff --git a/nodejs/models/shared_secret.js b/nodejs/models/shared_secret.js new file mode 100644 index 0000000..f692626 --- /dev/null +++ b/nodejs/models/shared_secret.js @@ -0,0 +1,56 @@ +'use strict'; + +// SharedSecret — a secret the owner has published to the shared namespace so it +// can be shared with other users and/or downstream apps. +// +// The secret DATA lives in OpenBao at `secret/shared//` (KV-v2), +// never in the DB. This row is metadata only (owner + slug + description) and is +// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced +// entirely by OpenBao ACL policies: the owner's `user-` policy grants full +// R/W on `secret/shared//*`, and each grantee's policy content is +// edited to add `read` on the exact shared path (see vault_broker.js — policy +// content is parsed live at token use, so a grant takes effect immediately with +// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row. +// +// `slug` is unique and immutable in practice — it is embedded in the shared path +// and in grantee policy rules, so changing it would require rewriting policies. +// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp +// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the +// ORM on create. + +const { Model } = require('@simpleworkjs/orm'); + +class SharedSecret extends Model { + static fields = { + id: { type: 'uuid', primaryKey: true }, + // Human slug embedded in the OpenBao path: secret/shared//. + // Unique so two owners can't collide on the same shared path. + slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 }, + // The publishing user's uid — also the shared path's namespace segment. + ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 }, + // Optional human description shown in the Shared tab. + description: { type: 'text' }, + // Audit stamps (set by the route handler, not by an ORM hook). + created_by: { type: 'string' }, + created_on: { type: 'integer' }, + updated_by: { type: 'string' }, + updated_on: { type: 'integer' }, + }; + + // Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata). + static pathFor(ownerUid, slug) { + return `shared/${ownerUid}/${slug}`; + } + + path() { + return SharedSecret.pathFor(this.ownerUid, this.slug); + } + + // Look up by slug (unique). Returns the row or null. + static async getBySlug(slug) { + const rows = await this.list({ where: { slug } }); + return rows[0] || null; + } +} + +module.exports = { SharedSecret }; diff --git a/nodejs/models/shared_secret_grant.js b/nodejs/models/shared_secret_grant.js new file mode 100644 index 0000000..1d5c0a3 --- /dev/null +++ b/nodejs/models/shared_secret_grant.js @@ -0,0 +1,53 @@ +'use strict'; + +// SharedSecretGrant — who can read a shared secret. Each row says "grantee +// (a user uid or an app name) has on the shared secret +// ". +// +// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives +// in OpenBao ACL policy content: when a grant is created, vault_broker.js +// recomputes the grantee's policy HCL (`user-` or `app-`) to include +// `read` on the exact shared path and rewrites it. Because OpenBao parses policy +// content live at token use, the grant applies to the grantee's existing token +// immediately (no re-mint). Revoking removes the rule and rewrites the policy. +// +// granteeType distinguishes the two principal kinds: +// 'user' — a user uid → grantee's `user-` policy is edited +// 'app' — an app name → grantee's `app-` policy is edited (downstream apps) +// capability is currently always 'read' (grantees are read-only); the column is +// a string so later capabilities could be added without a migration. +// +// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on. +// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants. + +const { Model } = require('@simpleworkjs/orm'); + +const GRANTEE_TYPES = ['user', 'app']; +const CAPABILITIES = ['read']; + +class SharedSecretGrant extends Model { + static fields = { + id: { type: 'uuid', primaryKey: true }, + // FK to SharedSecret.id. + secretId: { type: 'string', isRequired: true, min: 1 }, + // 'user' (a uid) or 'app' (an app name) — which policy to edit. + granteeType: { type: 'string', isRequired: true, min: 1 }, + // The grantee's uid (for 'user') or app name (for 'app'). + granteeId: { type: 'string', isRequired: true, min: 1, max: 64 }, + // Access level — 'read' today. + capability: { type: 'string', isRequired: true, default: 'read' }, + // Audit stamps (set by the route handler, not by an ORM hook). + created_by: { type: 'string' }, + created_on: { type: 'integer' }, + updated_by: { type: 'string' }, + updated_on: { type: 'integer' }, + }; + + // All grants for a given grantee (user uid or app name). Used to rebuild the + // grantee's policy content so every granted shared path is present/absent. + static async listForGrantee(granteeType, granteeId) { + return this.list({ where: { granteeType, granteeId } }); + } +} + +module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES }; diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index bdedd2c..ec5fde9 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.20.2", + "version": "1.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.20.2", + "version": "1.21.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 243a2ba..81774f7 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.20.2", + "version": "1.21.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/api_shared_secrets.js b/nodejs/routes/api_shared_secrets.js new file mode 100644 index 0000000..67ff73b --- /dev/null +++ b/nodejs/routes/api_shared_secrets.js @@ -0,0 +1,208 @@ +'use strict'; + +// Shared-secrets API. +// +// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with +// its DATA in OpenBao at secret/shared// (KV-v2). The owner has +// full R/W/list on their own secret/shared//* subtree; each grantee's +// OpenBao policy content is edited to add read on the exact shared path (see +// vault_broker.js grantSharedSecret/revokeSharedSecret). Enforcement is entirely +// the OpenBao ACL — the broker's policy reconciliation makes a grant effective +// immediately, with no token re-mint. +// +// Reads of the secret DATA are intentionally NOT proxied here: the UI fetches +// them through the existing /api/vault proxy using the requester's own session +// token, so OpenBao ACL enforces read access per-request. This router handles +// metadata CRUD + grant management; KV writes (create/update/delete) are made +// server-side using the acting user's scoped token. + +const express = require('express'); +const baoConf = require('@simpleworkjs/bao-conf'); +const permission = require('../utils/permission'); +const { SharedSecret } = require('../models/shared_secret'); +const { SharedSecretGrant } = require('../models/shared_secret_grant'); +const vaultBroker = require('../utils/vault_broker'); + +const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; +const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; + +const router = express.Router(); + +// Machine/service tokens cannot manage shared secrets (mirrors scopeGuard on the +// /api/vault proxy — personal, per-user secret management only). +router.use((req, res, next) => { + if (req.user && req.user.isMachine) { + return res.status(403).json({ error: 'machine tokens cannot manage shared secrets' }); + } + next(); +}); + +async function isAdmin(user) { + try { await permission.byGroup(user, ADMIN_GROUPS); return true; } + catch (e) { return false; } +} + +// Scoped OpenBao token for an actor, used for server-side KV writes. Owner uses +// their own token (R/W on secret/shared//*); an admin uses the +// sso-admin token (R/W on secret/*). +async function actorToken(user, ownerUid) { + if (user.uid === ownerUid) return vaultBroker.getOrCreateUserToken(ownerUid); + if (await isAdmin(user)) return vaultBroker.getOrCreateAdminToken(user.uid); + return null; +} + +// Does this user manage the given shared secret? Owner or admin. +async function canManage(user, secret) { + if (user.uid === secret.ownerUid) return true; + return isAdmin(user); +} + +async function loadSecret(req, res) { + const secret = await SharedSecret.get(req.params.id); + if (!secret) { res.status(404).json({ error: 'not found' }); return null; } + return secret; +} + +// ── List: mine + shared-with-me ───────────────────────────────────────────── +router.get('/', async (req, res, next) => { + try { + const uid = req.user.uid; + const mine = await SharedSecret.list({ where: { ownerUid: uid } }); + const grants = await SharedSecretGrant.listForGrantee('user', uid); + const granteeSecretIds = [...new Set(grants.map(g => g.secretId))]; + const granted = granteeSecretIds.length + ? await SharedSecret.list({ where: { id: { in: granteeSecretIds } } }) : []; + const byId = new Map(mine.map(s => [s.id, { role: 'owner', ...s }])); + for (const g of granted) { + if (byId.has(g.id)) continue; // already owner + byId.set(g.id, { role: 'grantee', ...g }); + } + res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) }); + } catch (e) { next(e); } +}); + +// ── Create ────────────────────────────────────────────────────────────────── +router.post('/', async (req, res, next) => { + try { + const uid = req.user.uid; + const slug = String(req.body.slug || '').trim().toLowerCase(); + if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' }); + const description = String(req.body.description || '').trim(); + const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {}; + + if (await SharedSecret.getBySlug(slug)) { + return res.status(409).json({ error: `a shared secret named '${slug}' already exists` }); + } + const token = await actorToken(req.user, uid); + if (!token) return res.status(403).json({ error: 'not allowed' }); + const path = SharedSecret.pathFor(uid, slug); + await baoConf.set(path, data, { token }); + + const secret = await SharedSecret.create({ + slug, ownerUid: uid, description, + created_by: uid, created_on: Date.now(), updated_by: uid, updated_on: Date.now(), + }); + res.status(201).json({ id: secret.id, slug, ownerUid: uid, description, path, role: 'owner' }); + } catch (e) { next(e); } +}); + +// ── Detail (metadata; data is read via /api/vault proxy) ──────────────────── +router.get('/:id', async (req, res, next) => { + try { + const secret = await loadSecret(req, res); + if (!secret) return; + const uid = req.user.uid; + const admin = await isAdmin(req.user); + const grantee = (await SharedSecretGrant.listForGrantee('user', uid)).some(g => g.secretId === secret.id); + if (!admin && uid !== secret.ownerUid && !grantee) return res.status(403).json({ error: 'not shared with you' }); + const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } }); + res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path(), role: uid === secret.ownerUid ? 'owner' : (admin ? 'admin' : 'grantee'), grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) }); + } catch (e) { next(e); } +}); + +// ── Update data / description ─────────────────────────────────────────────── +router.put('/:id', async (req, res, next) => { + try { + const secret = await loadSecret(req, res); + if (!secret) return; + if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can edit a shared secret' }); + const token = await actorToken(req.user, secret.ownerUid); + const update = {}; + if (req.body && typeof req.body.data === 'object') { + await baoConf.set(secret.path(), req.body.data, { token }); + } + if (req.body && req.body.description !== undefined) { + update.description = String(req.body.description).trim(); + } + if (Object.keys(update).length) { + update.updated_by = req.user.uid; + update.updated_on = Date.now(); + await secret.update(update); + } + res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path() }); + } catch (e) { next(e); } +}); + +// ── Delete (KV + DB row + all grants) ─────────────────────────────────────── +router.delete('/:id', async (req, res, next) => { + try { + const secret = await loadSecret(req, res); + if (!secret) return; + if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can delete a shared secret' }); + const token = await actorToken(req.user, secret.ownerUid); + // Revoke all grants first so grantees' policies drop the path. + const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } }); + for (const g of grants) await vaultBroker.revokeSharedSecret(g.id, req.user.uid); + // Delete the KV data (metadata delete removes all versions), then the row. + try { await baoConf.request('DELETE', `secret/metadata/${secret.path()}`, undefined, { token }); } catch (e) { /* best-effort */ } + await secret.delete(); + res.status(204).end(); + } catch (e) { next(e); } +}); + +// ── Grants: list ──────────────────────────────────────────────────────────── +router.get('/:id/grants', async (req, res, next) => { + try { + const secret = await loadSecret(req, res); + if (!secret) return; + if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' }); + const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } }); + res.json({ grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) }); + } catch (e) { next(e); } +}); + +// ── Grants: create ────────────────────────────────────────────────────────── +router.post('/:id/grants', async (req, res, next) => { + try { + const secret = await loadSecret(req, res); + if (!secret) return; + if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' }); + const granteeType = String(req.body.granteeType || '').trim(); + const granteeId = String(req.body.granteeId || '').trim(); + if (!['user', 'app'].includes(granteeType)) return res.status(400).json({ error: 'granteeType must be user or app' }); + if (!granteeId) return res.status(400).json({ error: 'granteeId is required' }); + if (granteeId === secret.ownerUid && granteeType === 'user') { + return res.status(400).json({ error: 'the owner already has access' }); + } + // Idempotent: skip if the grant already exists. + const existing = (await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType, granteeId } }))[0]; + if (existing) return res.json({ id: existing.id, granteeType, granteeId, capability: existing.capability }); + const grant = await vaultBroker.grantSharedSecret(secret.id, granteeType, granteeId, req.user.uid); + res.status(201).json({ id: grant.id, granteeType, granteeId, capability: grant.capability }); + } catch (e) { next(e); } +}); + +// ── Grants: revoke ────────────────────────────────────────────────────────── +router.delete('/:id/grants/:grantId', async (req, res, next) => { + try { + const secret = await loadSecret(req, res); + if (!secret) return; + if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' }); + const grant = await SharedSecretGrant.get(req.params.grantId); + if (!grant || grant.secretId !== secret.id) return res.status(404).json({ error: 'grant not found' }); + await vaultBroker.revokeSharedSecret(grant.id, req.user.uid); + res.status(204).end(); + } catch (e) { next(e); } +}); + +module.exports = router; diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 19e499f..cd295a6 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -4,15 +4,25 @@ // external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the // `sso-broker` token role created by theta-env/setup.sh. // -// secret/users//* per-user personal KV (user- policy) -// secret/apps//* per-external-app namespace (app- policy) -// secret/* admin UI sessions (sso-admin policy) +// secret/users//* per-user personal KV (user- policy) +// secret/shared//* user-owned shared KV (user- policy) +// secret/apps//* per-external-app namespace (app- policy) +// secret/shared// granted read (added to grantee's policy) +// secret/* admin UI sessions (sso-admin policy) // // The sso-broker policy grants update on auth/token/create/sso-broker and on // sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to // create the per-subject policies and mint their tokens. Per-user/admin tokens // are cached in Redis for the token's lifetime and re-minted on miss; per-app // tokens are returned ONCE (displayed in the UI, never stored retrievably). +// +// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT +// live at token use (only the SET of policy names on a token is fixed at mint), +// so we ALWAYS reconcile a subject's policy content BEFORE returning any token +// — cached or freshly minted. That way a stale cached token immediately gains +// corrected/revoked capabilities, and a new shared-secret grant takes effect for +// an existing grantee token with no re-mint. The Redis cache only short-circuits +// token MINTING, never policy reconciliation. const baoConf = require('@simpleworkjs/bao-conf'); const { createClient } = require('redis'); @@ -20,6 +30,8 @@ const express = require('express'); const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware'); const conf = require('@simpleworkjs/conf'); const permission = require('./permission'); +const { SharedSecret } = require('../models/shared_secret'); +const { SharedSecretGrant } = require('../models/shared_secret_grant'); const ROLE = 'sso-broker'; const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h) @@ -54,17 +66,20 @@ async function bao(method, path, body) { return res; } -// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes — -// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding -// a list grant on a directory path) propagate on the next vault-page visit -// without an operator re-running setup.sh. Skipping on an existing policy -// would strand the old, narrower HCL forever. +// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current +// content and only PUT when it differs. `bao policy write` is an idempotent +// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a +// grant) propagate immediately because OpenBao parses policy content at use. async function ensurePolicy(name, hcl) { const existing = await baoConf.request('GET', `sys/policies/acl/${name}`); if (existing.status !== 200 && existing.status !== 404) { const t = await existing.text().catch(() => ''); throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`); } + if (existing.status === 200) { + const body = await existing.json().catch(() => null); + if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged + } await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl }); } @@ -79,29 +94,55 @@ async function mintToken(policies) { return { token, ttl }; } +// ── Shared-secret policy rules ─────────────────────────────────────────────── +// Returns the HCL rules granting `read` on every shared secret the given +// grantee (a user uid or an app name) has been granted. Enforcement is +// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take +// effect for the grantee's existing token immediately (no re-mint). +async function sharedPolicyRules(granteeType, granteeId) { + const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId); + if (!grants.length) return ''; + const secretIds = [...new Set(grants.map(g => g.secretId))]; + const secrets = secretIds.length + ? await SharedSecret.list({ where: { id: { in: secretIds } } }) : []; + const byId = new Map(secrets.map(s => [s.id, s])); + const rules = []; + for (const g of grants) { + const sec = byId.get(g.secretId); + if (!sec) continue; + const p = sec.path(); // shared// + rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`); + rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`); + } + return rules.join('\n'); +} + // ── Per-user token ────────────────────────────────────────────────────────── -// ── Per-user token ────────────────────────────────────────────────────────── -function userPolicyHcl(uid) { +async function userPolicyHcl(uid) { + const granted = await sharedPolicyRules('user', uid); return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } -path "secret/data/shared" { capabilities = ["read", "list"] } -path "secret/data/shared/*" { capabilities = ["read", "list"] } -path "secret/metadata/shared" { capabilities = ["read", "list"] } -path "secret/metadata/shared/" { capabilities = ["read", "list"] } -path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`; +path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] } +path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } +${granted}`.trim(); } -// Mint (or return the cached) per-user token confined to secret/users//*. -// Re-minted when the cache entry expires (a little before the token's own TTL). +// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled +// (compare-and-skip) before the cache is consulted, so a cached token can never +// outlive a policy change; the cache only short-circuits re-minting. Re-minted +// when the cache entry expires (a little before the token's own TTL). async function getOrCreateUserToken(uid) { if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`); + await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid)); const cacheKey = `vault_token:${uid}`; const cached = await cacheGet(cacheKey); if (cached) return cached; - await ensurePolicy(`user-${uid}`, userPolicyHcl(uid)); const { token, ttl } = await mintToken([`user-${uid}`]); await cacheSet(cacheKey, token, Math.max(ttl - 60, 60)); return token; @@ -119,42 +160,75 @@ path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", } async function getOrCreateAdminToken(uid) { + await ensurePolicy('sso-admin', adminPolicyHcl()); const cacheKey = `vault_token:admin:${uid || 'global'}`; const cached = await cacheGet(cacheKey); if (cached) return cached; - await ensurePolicy('sso-admin', adminPolicyHcl()); const { token, ttl } = await mintToken(['sso-admin']); await cacheSet(cacheKey, token, Math.max(ttl - 60, 60)); return token; } // ── Per-app token (minted ONCE, returned to the caller, never cached) ─────── -function appPolicyHcl(name) { +async function appPolicyHcl(name) { + const granted = await sharedPolicyRules('app', name); return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] } -path "secret/data/shared" { capabilities = ["read", "list"] } -path "secret/data/shared/*" { capabilities = ["read", "list"] } -path "secret/metadata/shared" { capabilities = ["read", "list"] } -path "secret/metadata/shared/" { capabilities = ["read", "list"] } -path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`; +${granted}`.trim(); } // Create the app- policy + mint a token for it. Returns the token ONCE // (the admin UI shows it with a copy button); it is not stored retrievably, so // a later compromise of an admin session cannot recover previously-minted app -// tokens. The caller must record it in the external app immediately. +// tokens. The caller must record it in the external app immediately. Later +// grants to the app edit app- policy content (live-applied to this token). async function mintAppToken(name) { if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) { throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)'); } - await ensurePolicy(`app-${name}`, appPolicyHcl(name)); + await ensurePolicy(`app-${name}`, await appPolicyHcl(name)); const { token, ttl } = await mintToken([`app-${name}`]); return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` }; } +// ── Grant / revoke shared-secret access ───────────────────────────────────── +// Creating a grant writes the DB row and then edits the grantee's policy content +// to add read on the shared path; revoking removes both. Because OpenBao parses +// policy content live, the change applies to the grantee's existing token +// immediately — no token re-mint, no cache invalidation needed. +async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) { + const grant = await SharedSecretGrant.create({ + secretId, granteeType, granteeId, capability: 'read', + created_by: actorUid, created_on: Date.now(), + updated_by: actorUid, updated_on: Date.now(), + }); + await reconcileGrantee(granteeType, granteeId); + return grant; +} + +async function revokeSharedSecret(grantId, actorUid) { + const grant = await SharedSecretGrant.get(grantId); + if (!grant) return null; + const { granteeType, granteeId } = grant; + await grant.delete(); + await reconcileGrantee(granteeType, granteeId); + return grant; +} + +// Recompute and rewrite a grantee's policy content after a grant/revoke. +async function reconcileGrantee(granteeType, granteeId) { + if (granteeType === 'user') { + await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId)); + } else if (granteeType === 'app') { + await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId)); + } else { + throw new Error(`invalid granteeType: ${granteeType}`); + } +} + // ── /api/vault proxy: scope guard + token-injecting proxy ─────────────────── // Replaces the old bare pass-through (which sent no X-Vault-Token and gated // nothing). The guard mints a server-side token for the user (per-user or @@ -256,4 +330,12 @@ module.exports = { scopeGuard, vaultProxy, mintAppRouter, -}; \ No newline at end of file + // sharing + SharedSecret, + SharedSecretGrant, + userPolicyHcl, + appPolicyHcl, + grantSharedSecret, + revokeSharedSecret, + reconcileGrantee, +}; diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index b00c393..54afd78 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -6,6 +6,7 @@
@@ -83,6 +84,101 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf"
+ + +
+
+
+
+
+
My shared secrets
+ +
+
+
Loading...
+
+
+
+
+
+
Shared with me
+
+
Loading...
+
+
+
+
+
+
+
+ + + + + + + + + @@ -307,6 +403,151 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success')); } + // ── Shared secrets tab ────────────────────────────────────────────── + let currentShared = null; + const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal')); + const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal')); + const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal')); + + function sharedApi(path, method = 'GET', body = null) { + const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } }; + if (body) opts.body = JSON.stringify(body); + return fetch('/api/shared-secrets' + path, opts).then(async res => { + if (res.status === 404) return null; + if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); } + if (res.status === 204) return null; + return res.json(); + }); + } + + async function loadShared() { + try { + const res = await sharedApi('/'); + const items = (res && res.items) || []; + renderSharedMine(items.filter(i => i.role === 'owner')); + renderSharedGranted(items.filter(i => i.role === 'grantee')); + } catch (err) { + document.getElementById('shared-mine-list').innerHTML = + `
Error: ${err.message}
`; + } + } + + function renderSharedMine(items) { + const el = document.getElementById('shared-mine-list'); + if (!items.length) { el.innerHTML = '
No shared secrets yet
'; return; } + el.innerHTML = ''; + items.forEach(s => { + const row = document.createElement('div'); + row.className = 'list-group-item d-flex justify-content-between align-items-center'; + row.innerHTML = `
${s.slug}
${s.path}
+
+ + +
`; + el.appendChild(row); + }); + } + + function renderSharedGranted(items) { + const el = document.getElementById('shared-granted-list'); + if (!items.length) { el.innerHTML = '
Nothing shared with you yet
'; return; } + el.innerHTML = ''; + items.forEach(s => { + const row = document.createElement('a'); + row.href = '#'; + row.className = 'list-group-item list-group-item-action d-flex align-items-center'; + row.innerHTML = `${s.slug}by ${s.ownerUid}`; + row.onclick = (e) => { e.preventDefault(); viewShared(s); }; + el.appendChild(row); + }); + } + + function showCreateSharedModal() { + currentShared = null; + document.getElementById('shared-slug-input').value = ''; + document.getElementById('shared-desc-input').value = ''; + document.getElementById('shared-data-input').value = '{\n "key": "value"\n}'; + document.getElementById('shared-create-error').classList.add('d-none'); + sharedCreateModal.show(); + } + + async function saveSharedSecret() { + const err = document.getElementById('shared-create-error'); + err.classList.add('d-none'); + let data; + try { data = JSON.parse(document.getElementById('shared-data-input').value); } + catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; } + try { + await sharedApi('/', 'POST', { + slug: document.getElementById('shared-slug-input').value.trim(), + description: document.getElementById('shared-desc-input').value.trim(), + data + }); + sharedCreateModal.hide(); + await loadShared(); + } catch (e) { err.textContent = e.message; err.classList.remove('d-none'); } + } + + async function viewShared(s) { + document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')'; + document.getElementById('shared-view-content').textContent = 'Loading...'; + sharedViewModal.show(); + try { + const res = await apiCall('GET', 'secret/data/' + s.path); + document.getElementById('shared-view-content').textContent = + (res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.'; + } catch (e) { + document.getElementById('shared-view-content').textContent = 'Error: ' + e.message; + } + } + + async function openGrants(id) { + currentShared = id; + document.getElementById('grants-error').classList.add('d-none'); + document.getElementById('grant-id-input').value = ''; + sharedGrantsModal.show(); + try { + const res = await sharedApi('/' + id + '/grants'); + const grants = (res && res.grants) || []; + const el = document.getElementById('grants-list'); + el.innerHTML = ''; + if (!grants.length) el.innerHTML = '
No grants yet.
'; + grants.forEach(g => { + const row = document.createElement('div'); + row.className = 'list-group-item d-flex justify-content-between align-items-center'; + row.innerHTML = `${g.granteeType}${g.granteeId} + `; + el.appendChild(row); + }); + } catch (e) { + document.getElementById('grants-list').innerHTML = `
${e.message}
`; + } + } + + async function addGrant() { + const err = document.getElementById('grants-error'); + err.classList.add('d-none'); + try { + await sharedApi('/' + currentShared + '/grants', 'POST', { + granteeType: document.getElementById('grant-type-input').value, + granteeId: document.getElementById('grant-id-input').value.trim() + }); + document.getElementById('grant-id-input').value = ''; + openGrants(currentShared); + } catch (e) { err.textContent = e.message; err.classList.remove('d-none'); } + } + + async function revokeGrant(grantId) { + try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); } + catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); } + } + + async function deleteShared(id) { + if (!confirm('Delete this shared secret? Grantees will immediately lose access.')) return; + try { await sharedApi('/' + id, 'DELETE'); await loadShared(); } + catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); } + } + (async function init() { const user = await app.auth.forceLogin(); if (!user) return; // not logged in — forceLogin redirected to /login @@ -320,6 +561,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf'; } loadSecrets(); + loadShared(); })(); From b46b3bed80b7dbef85f9595419f8037653ff429a Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 3 Aug 2026 22:19:38 -0400 Subject: [PATCH 05/26] 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(). --- nodejs/views/vault.ejs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index 54afd78..03fdc8f 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -543,7 +543,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf" } async function deleteShared(id) { - if (!confirm('Delete this shared secret? Grantees will immediately lose access.')) return; + 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'); } } From ccf3122668601194ff9943b7d386d4320e6ebe38 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 3 Aug 2026 23:10:16 -0400 Subject: [PATCH 06/26] feat: Agents page + secure /api/agent REST (v1.22.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 4 ++ nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/routes/api_agent.js | 21 ++++++- nodejs/routes/index.js | 6 ++ nodejs/utils/ui.js | 1 + nodejs/views/agents.ejs | 112 +++++++++++++++++++++++++++++++++++++ 7 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 nodejs/views/agents.ejs diff --git a/CHANGELOG.md b/CHANGELOG.md index ec628fc..d770c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 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// and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index ec5fde9..36d3b61 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.21.0", + "version": "1.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.21.0", + "version": "1.22.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 81774f7..2b65c56 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.21.0", + "version": "1.22.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index 9a600a7..72ec137 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -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({ diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 144a4a3..0f07641 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -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')); diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 4128e3d..d35af61 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -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//*. {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']}, ], }; diff --git a/nodejs/views/agents.ejs b/nodejs/views/agents.ejs new file mode 100644 index 0000000..2f8662d --- /dev/null +++ b/nodejs/views/agents.ejs @@ -0,0 +1,112 @@ +<%- include('top') %> + +
+
+

Theta Agents (connected hosts)

+ +
+ +
+
Connected agents
+
+ + + + + + + + + + + + + + + + + +
HostIPStatusCPURAMDiskZFSGPULast seen
Loading agents...
+
+
+ +

+ 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. +

+
+ + + +<%- include('bottom') %> From dd24257640c55bca388298432ed9c2ba6ff71fab Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 4 Aug 2026 00:11:51 -0400 Subject: [PATCH 07/26] fix vault 403 for real (v1.23.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- CHANGELOG.md | 8 ++ nodejs/bin/www | 7 ++ nodejs/models/index.js | 35 ++++++- nodejs/models/vault_app_token.js | 43 +++++++++ nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/tests/vault_broker.test.js | 153 ++++++++++++++++++++++++++++++ nodejs/utils/vault_broker.js | 126 ++++++++++++++++++++---- nodejs/views/vault.ejs | 1 + 9 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 nodejs/models/vault_app_token.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d770c25..5d240ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 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) diff --git a/nodejs/bin/www b/nodejs/bin/www index ecd75dd..5128074 100755 --- a/nodejs/bin/www +++ b/nodejs/bin/www @@ -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); diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 2729920..678378f 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -19,6 +19,7 @@ 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', @@ -33,16 +34,48 @@ async function initORM() { conf: { orm: ormConf }, models: [ Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance, - SharedSecret, SharedSecretGrant, + 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; diff --git a/nodejs/models/vault_app_token.js b/nodejs/models/vault_app_token.js new file mode 100644 index 0000000..8be30b3 --- /dev/null +++ b/nodejs/models/vault_app_token.js @@ -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-) and KV namespace + // (secret/apps//). 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 }; diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 36d3b61..ce8b4aa 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.22.0", + "version": "1.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.22.0", + "version": "1.23.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 2b65c56..2ca42d9 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.22.0", + "version": "1.23.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/tests/vault_broker.test.js b/nodejs/tests/vault_broker.test.js index 06bfb74..2f8e446 100644 --- a/nodejs/tests/vault_broker.test.js +++ b/nodejs/tests/vault_broker.test.js @@ -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(() => { @@ -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' } }); + }); +}); diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index cd295a6..99879fd 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -32,6 +32,7 @@ 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) @@ -83,15 +84,18 @@ async function ensurePolicy(name, hcl) { 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 ─────────────────────────────────────────────── @@ -185,15 +189,94 @@ ${granted}`.trim(); // a later compromise of an admin session cannot recover previously-minted app // tokens. The caller must record it in the external app immediately. Later // grants to the app edit app- policy content (live-applied to this token). -async function mintAppToken(name) { +// +// 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}`, await appPolicyHcl(name)); - const { token, ttl } = await mintToken([`app-${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 @@ -292,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); }, }); } @@ -314,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' }); @@ -330,6 +418,10 @@ module.exports = { scopeGuard, vaultProxy, mintAppRouter, + // app-token lifecycle + renewAppTokens, + startAppTokenRenewal, + VaultAppToken, // sharing SharedSecret, SharedSecretGrant, diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index 03fdc8f..aa9a7e4 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -56,6 +56,7 @@
Mint an app token

Mints a scoped OpenBao token confined to secret/apps/<name>/* for an external app. The token is shown once — record it in the app immediately; it cannot be recovered later.

+

The token is periodic: it stays valid as long as the app renews it within its period (POST /v1/auth/token/renew-self). If it lapses, mint a new one here — the app's policy and stored secrets are kept.

From c618e75a229c5f917f9d9d6db8b1308d4f003e25 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:21:35 +0000 Subject: [PATCH 08/26] chore(deps): bump ip-address from 10.2.0 to 10.4.0 in /nodejs Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0. - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.4.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- nodejs/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index ce8b4aa..687edcc 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -4294,9 +4294,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" From 9a438bd30edeb966c0520a8a87d4e60c25b24187 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:21:41 +0000 Subject: [PATCH 09/26] chore(deps): bump undici from 6.27.0 to 6.28.0 in /nodejs Bumps [undici](https://github.com/nodejs/undici) from 6.27.0 to 6.28.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v6.27.0...v6.28.0) --- updated-dependencies: - dependency-name: undici dependency-version: 6.28.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- nodejs/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index ce8b4aa..3c7ee9b 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -7918,9 +7918,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "optional": true, "engines": { From 93c47751db2a2a32b78bf1cd6500504ea73ce2f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:21:47 +0000 Subject: [PATCH 10/26] chore(deps): bump brace-expansion in /nodejs Bumps and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together. Updates `brace-expansion` from 2.1.2 to 2.1.4 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4) Updates `brace-expansion` from 1.1.16 to 1.1.18 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4) Updates `brace-expansion` from 5.0.7 to 5.0.9 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.18 dependency-type: indirect - dependency-name: brace-expansion dependency-version: 2.1.4 dependency-type: indirect - dependency-name: brace-expansion dependency-version: 5.0.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- nodejs/package-lock.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index ce8b4aa..e28f3bb 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -2344,9 +2344,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5966,16 +5966,16 @@ } }, "node_modules/nodemon/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/nodemon/node_modules/debug": { @@ -7726,9 +7726,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { From bbcc235b68d1f12c0954ded6f9beff279cb2101f Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 4 Aug 2026 13:26:25 -0400 Subject: [PATCH 11/26] feat: Directory agent status + plugin modal rework, Vault restyle, navbar (v1.24.0) - Merge theta-agent into Directory: remove the Agents page; add green/yellow/red status dots to host rows and a Metrics tab (telemetry + discovery) to the resource modal, joined to hosts by hostname, live via socket.io + 30s refresh. - Discovery Plugins New-plugin modal: slug derived from name (field removed), cron dropdown (hourly/daily/weekly/custom), configSchema-driven settings (Proxmox url/tokenId/tokenSecret) sent as a populated config. - Directory resource slug now read-only + derived from name. - Vault page restyled to match the site. - Navbar: username no longer underlined; only the active link is bold+underlined. - docs/agents.md: document the Directory status/metrics + NAT troubleshooting. Co-Authored-By: Claude --- CHANGELOG.md | 7 ++ docs/agents.md | 44 ++++++++ nodejs/public/css/styles.css | 6 + nodejs/routes/index.js | 6 - nodejs/utils/ui.js | 1 - nodejs/views/agents.ejs | 112 ------------------- nodejs/views/directory.ejs | 206 ++++++++++++++++++++++++++++++++--- nodejs/views/top.ejs | 2 +- nodejs/views/vault.ejs | 59 ++++++---- 9 files changed, 282 insertions(+), 161 deletions(-) delete mode 100644 nodejs/views/agents.ejs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d240ab..6c9bd15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# v1.24.0 +- feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery +- feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config +- feat: Directory resource slug is now read-only and derived from the name +- feat: Vault page restyled to match the rest of the site (bounded container, card + nav-tabs header, h4) +- feat: navbar — the username is no longer underlined; only the active nav link is bold + underlined + # 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) diff --git a/docs/agents.md b/docs/agents.md index d1f020a..c996ed4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -31,6 +31,25 @@ Every 30 seconds, the agent streams real-time performance metrics: --- +## Viewing in the SSO Manager + +Agent status and telemetry live on the **Directory** page — there is no separate +Agents page. For each **host** resource that has a connected theta-agent, the +Directory shows a status dot in the row: + +| Color | Meaning | +| :--- | :--- | +| **Green** | Connected, healthy (CPU/RAM/disk within limits). | +| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). | +| **Red** | Not connected (no agent, or the agent is offline). | + +Opening a host's resource modal reveals a **Metrics** tab with the agent's live +telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location). +The agent is joined to its host by hostname (`agent.discovery.hostname` ↔ the +resource name), so name the Directory host the same as the machine's hostname. + +--- + ## Local-First Security & Capability Matrix To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely. @@ -90,3 +109,28 @@ capabilities: arbitrary_bash: false ``` +--- + +## Troubleshooting: agent can't connect (`dial tcp ... i/o timeout`) + +If the agent host logs `Dial error: dial tcp :443: i/o timeout` while +connecting to `wss:///api/agent/ws`, the WebSocket path is usually +fine — this is a **network/NAT** problem, not an agent or SSO bug. A host behind +the same NAT that owns the SSO often cannot reach its own **public IP** (no +hairpin/loopback NAT on many home routers), so the TCP dial times out even +though the same address works from outside. + +Fix options: +1. Point `agent.yml` `server_url` at an address the host can reach directly — + e.g. the SSO host's LAN IP (`http://` or `http://:3001` for a + no-TLS direct path). +2. Enable **NAT reflection / hairpin NAT** on the router so LAN hosts can reach + their own public IP:443. +3. Add a local route/firewall rule on the agent host for its public IP. + +> Note: on a deployment where the theta42 proxy fronts `sso.suite.example`, make +> sure the proxy has a **persistent Host record** for the real SSO domain — not +> just the `localtest.me` placeholder — so routing survives a proxy restart +> (an in-memory lookup cache can mask a missing Redis record for up to ~1h). + + diff --git a/nodejs/public/css/styles.css b/nodejs/public/css/styles.css index 7528f11..2775a58 100755 --- a/nodejs/public/css/styles.css +++ b/nodejs/public/css/styles.css @@ -3,6 +3,12 @@ nav.navbar{ padding-right: 1em; } +/* Only the active top-nav link is bold + underlined; the username is plain. */ +.top-nav a.active{ + font-weight: bold; + text-decoration: underline; +} + body { display: flex; flex-direction: column; diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 0f07641..144a4a3 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -59,12 +59,6 @@ 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')); diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index d35af61..4128e3d 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -46,7 +46,6 @@ 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//*. {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']}, ], }; diff --git a/nodejs/views/agents.ejs b/nodejs/views/agents.ejs deleted file mode 100644 index 2f8662d..0000000 --- a/nodejs/views/agents.ejs +++ /dev/null @@ -1,112 +0,0 @@ -<%- include('top') %> - -
-
-

Theta Agents (connected hosts)

- -
- -
-
Connected agents
-
- - - - - - - - - - - - - - - - - -
HostIPStatusCPURAMDiskZFSGPULast seen
Loading agents...
-
-
- -

- 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. -

-
- - - -<%- include('bottom') %> diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index dd329cb..25a7125 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -72,6 +72,7 @@ {{{indentHtml}}} + {{#isHost}}{{/isHost}} {{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}} {{#metadata.isProduction}}Prod{{/metadata.isProduction}} {{^metadata.isProduction}}Dev{{/metadata.isProduction}} @@ -235,7 +236,8 @@
- + +
Derived from the name; read-only.
@@ -476,6 +478,7 @@ {id: 'details', label: 'Details', bodyHtml: detailsTabHtml}, {id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml}, {id: 'children', label: 'Children', bodyHtml: childrenTabHtml}, + {id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)}, ], footer: { metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '', @@ -521,24 +524,34 @@ } }); + // Connected theta-agent join: hostname->agent and token->agent (case-insensitive + // hostname). Populated by loadResources/refreshAgents; host rows + the Metrics + // tab read from these. Agent data comes from /api/agent/nodes (admin-gated). + var agentsByHost = {}; + var agentsByToken = {}; + async function loadResources() { try { - const [resResources, resGroups, resEdges, resAccess] = await Promise.all([ + const [resResources, resGroups, resEdges, resAccess, resAgents] = await Promise.all([ app.api.get('directory-admin/resources'), app.api.get('directory-admin/groups'), app.api.get('directory-admin/edges'), // Access counts are a nicety, not load-bearing: if the LDAP join fails // the table still renders, just without the Access column populated. - app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }) + app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }), + // Agents are a nicety too: never block the directory on them. + app.api.get('agent/nodes').catch(function(){ return {agents: []}; }) ]); accessSummary = (resAccess && resAccess.results) || {}; resourcesById = {}; - + for (const r of resResources.results) { r.metadata = r.metadata || {}; resourcesById[r.id] = r; } + + indexAgents((resAgents && resAgents.agents) || []); allGroups = resGroups.results; allEdges = resEdges.results; @@ -572,6 +585,79 @@ } } + // Build the hostname->agent and token->agent lookup maps from /api/agent/nodes. + function indexAgents(agents) { + agentsByHost = {}; + agentsByToken = {}; + for (const a of agents || []) { + const hn = (a.hostname || (a.discovery && a.discovery.hostname) || '').toLowerCase(); + if (hn) agentsByHost[hn] = a; + if (a.token) agentsByToken[a.token] = a; + } + } + + function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); } + function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; } + + // Green (online, healthy) / Yellow (online, high load) / Red (not connected + // or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and + // stores the agent on resourcesById so the Metrics tab can find it. + function attachAgentStatus(n) { + n.isHost = true; + const name = (n.name || '').toLowerCase(); + const slug = (n.slug || '').replace(/^host_/, '').toLowerCase(); + const a = agentsByHost[name] || (slug && agentsByHost[slug]); + n.agent = a || null; + if (resourcesById[n.id]) resourcesById[n.id].agent = a || null; + if (!a) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; } + if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; } + const t = a.telemetry || {}; + const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90); + n.agentColor = high ? '#ffc107' : '#198754'; + n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy'; + } + + // Metrics tab body for the resource modal (snapshot of the joined agent). + function metricsTabHtml(agent) { + if (!agent) { + return '
No theta-agent connected

Install the agent on this host to see live metrics.

'; + } + const d = agent.discovery || {}; + const t = agent.telemetry || {}; + const bar = (val) => `
`; + const online = agent.isOnline ? 'Online' : 'Offline'; + const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A'; + return `
+
+
${esc(agent.hostname || 'unknown')} ${online}
+ Last seen ${timeAgo(agent.lastSeen)} +
+
+
CPU ${t.cpu_usage_percent ?? 0}%${bar(t.cpu_usage_percent)}
+
RAM ${t.ram_usage_percent ?? 0}%${bar(t.ram_usage_percent)}
+
Disk ${t.disk_usage_percent ?? 0}%${bar(t.disk_usage_percent)}
+
GPU ${gpu}
+
ZFS ${esc(t.zfs_health || 'N/A')}
+
+
Discovery
+
+
OS: ${esc(d.os || '')}
+
Kernel: ${esc(d.kernel || '')}
+
IPs: ${esc((d.ip_addresses || []).join(', '))}
+
Location: ${esc(d.location || '')}
+
+
`; + } + + // Re-fetch agents (every 30s + on socket events) so status dots stay live. + async function refreshAgents() { + try { + const res = await app.api.get('agent/nodes'); + indexAgents((res && res.agents) || []); + renderTable(); + } catch (e) { /* non-fatal */ } + } + // "Who can reach this?" at a glance. A resource with no linked group is not a // locked-down resource -- it is an unreachable one, and a group whose LDAP // entry has been deleted grants nothing, so both get called out rather than @@ -679,6 +765,7 @@ } n.indentHtml = indentHtml; n.accessHtml = accessCellHtml(n.id); + if (n.kind === 'host') attachAgentStatus(n); finalRenderList.push(n); if (n.children.length > 0) { flatten(n.children, depth + 1); @@ -1588,6 +1675,79 @@ var discoveryPluginTypes = []; + // ── Discovery plugin config helpers (ported from plugins.ejs) ───────────── + // Stored value is always a 5-field cron string; the dropdown picks a preset + // and "Custom…" reveals the raw input. Config fields are driven by each + // plugin type's configSchema so per-plugin settings (e.g. Proxmox url / + // tokenId / tokenSecret) are collected at create time. + var DP_CRON_PRESETS = [ + { key: 'hourly', label: 'Hourly', cron: '0 * * * *' }, + { key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' }, + { key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' }, + { key: 'custom', label: 'Custom…', cron: null }, + ]; + function dpCronKeyFor(cron) { + var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0]; + return m ? m.key : 'custom'; + } + function dpCronSelectHtml(prefix, current) { + current = current || '0 * * * *'; + var key = dpCronKeyFor(current); + var opts = DP_CRON_PRESETS.map(function(p){ + return ''; + }).join(''); + var rawStyle = key === 'custom' ? '' : ' style="display:none"'; + return '' + + ''; + } + function dpOnCronChange(prefix) { + var sel = document.getElementById(prefix + 'cron-select'); + var raw = document.getElementById(prefix + 'cron'); + if (!sel || !raw) return; + if (sel.value === 'custom') { raw.style.display = ''; } + else { + raw.style.display = 'none'; + var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0]; + if (preset) raw.value = preset.cron; + } + } + function dpCronFromForm(prefix) { + var sel = document.getElementById(prefix + 'cron-select'); + if (sel && sel.value !== 'custom') { + var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0]; + if (preset) return preset.cron; + } + var raw = document.getElementById(prefix + 'cron'); + return (raw && raw.value.trim()) || '0 * * * *'; + } + function dpConfigFormHtml(type, prefix) { + var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0]; + var schema = t && t.configSchema; + if (!schema || !schema.length) return '

No configuration fields for this plugin.

'; + var html = ''; + schema.forEach(function(f) { + var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text'); + var req = f.required ? ' required' : ''; + var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : ''; + var label = f.label + (f.secret ? ' ' : '') + (f.required ? ' *' : ''); + html += '
' + + '
'; + }); + return html; + } + function dpCollectConfig(type, prefix) { + var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0]; + var schema = t && t.configSchema; + var out = {}; + if (!schema) return out; + schema.forEach(function(f) { var el = document.getElementById(prefix + f.key); if (el) out[f.key] = el.value; }); + return out; + } + function dpRenderFields() { + var type = document.getElementById('new-plugin-type').value; + document.getElementById('new-plugin-config-fields').innerHTML = dpConfigFormHtml(type, 'np-'); + } + function openNewDiscoveryPluginModal() { app.api.get('plugins/types', function(err, res) { if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; } @@ -1601,25 +1761,22 @@ const bodyHtml = `
- +
+
A slug is derived automatically from the name.
- - -
-
- - -
Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)
+ + ${dpCronSelectHtml('np-', '0 * * * *')}
+
Configuration
${dpConfigFormHtml(discoveryPluginTypes[0].type, 'np-')}
@@ -1629,7 +1786,7 @@ app.modal.open({ title: 'Configure New Discovery Plugin', bodyHtml: bodyHtml, - size: 'md' + size: 'lg' }); }); } @@ -1637,20 +1794,20 @@ 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 cron = dpCronFromForm('np-'); const enabled = $('#new-plugin-enabled').is(':checked'); + const config = dpCollectConfig(type, 'np-'); + if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger'); if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger'); try { await app.api.post('plugins', { pluginType: type, name, - slug, cron, enabled, - config: {} + config }); app.messages.toast('Discovery plugin created successfully!', 'success'); app.modal.close(); @@ -1663,6 +1820,21 @@ $(document).ready(function(){ loadDiscoveryResources(); loadDiscoveryPlugins(); + // Keep the host status dots live: refresh the agent join periodically and on + // socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub). + refreshAgents(); + setInterval(refreshAgents, 30000); + try { + const dirAgentSocket = io({ auth: { token: app.auth.getToken() } }); + dirAgentSocket.on('agent.telemetry', function(msg){ + const a = msg && agentsByToken[msg.token]; + if (a) { a.telemetry = msg.payload; a.isOnline = true; renderTable(); } + }); + dirAgentSocket.on('agent.discovery', function(msg){ + const a = msg && agentsByToken[msg.token]; + if (a) { a.discovery = msg.payload; if (msg.payload && msg.payload.hostname) a.hostname = msg.payload.hostname; a.isOnline = true; renderTable(); } + }); + } catch (e) { /* socket is optional; periodic refresh still runs */ } }); diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index 0ea839c..aaeb960 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -49,7 +49,7 @@
<% if(ui.profileUrl){ %> - <% } else { %> diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index aa9a7e4..1e0e5ad 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -1,27 +1,29 @@ <%- include('top') %> -
-
-

My Secrets (personal namespace)

- -
- -
+
+
+
+
+
+ +
+
+
-
- +
+
My Secrets (personal namespace)
+
-
+
+
-
Secrets List
+
Secrets List
Loading...
@@ -29,7 +31,7 @@
-
+
+
-
Mint an app token
+
Mint an app token

Mints a scoped OpenBao token confined to secret/apps/<name>/* for an external app. The token is shown once — record it in the app immediately; it cannot be recovered later.

The token is periodic: it stays valid as long as the app renews it within its period (POST /v1/auth/token/renew-self). If it lapses, mint a new one here — the app's policy and stored secrets are kept.

@@ -68,7 +72,7 @@
-
+
App token
@@ -83,15 +87,17 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf"
+
-
+
+
-
+
My shared secrets
@@ -102,7 +108,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf"
-
Shared with me
+
Shared with me
Loading...
@@ -110,6 +116,11 @@ curl "$VAULT_ADDR/v1/secret/data/apps//conf"
+
+
+
+
+
From 6d9c2f05ba3afeb1e2a8ecbfe5e72c321eeee9eb Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 4 Aug 2026 15:03:36 -0400 Subject: [PATCH 12/26] feat: hierarchical group & permission model (v1.25.0) - Add utils/groups.js: the group schema + inheritance resolver (god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, per-resource admin/access/, meta everyone/{site}_everyone). admin implies access; capabilities explicit; hosts/apps orthogonal; cross-site isolated. - permission.js: recognize god_admin (legacy app_super_admin aliased) and add onResource/requireResource for resource-level checks + everyone meta grants. - user.js isAdmin: recognize god_admin + site-scoped super/app-admin groups. - Remove the standalone Groups page (nav + route + view); groups are managed on adopted Directory resources. Add a /docs/groups help link in the Directory toolbar (GROUPS.md copied into the SSO docs). - tests/groups.test.js: full resolver coverage (15 tests). Co-Authored-By: Claude --- CHANGELOG.md | 5 + docs/groups.md | 312 +++++++++++++++++++++++++++ nodejs/routes/docs.js | 1 + nodejs/routes/index.js | 4 - nodejs/routes/user.js | 8 +- nodejs/tests/groups.test.js | 114 ++++++++++ nodejs/utils/groups.js | 123 +++++++++++ nodejs/utils/permission.js | 62 +++++- nodejs/utils/ui.js | 1 - nodejs/views/directory.ejs | 1 + nodejs/views/groups.ejs | 406 ------------------------------------ 11 files changed, 620 insertions(+), 417 deletions(-) create mode 100644 docs/groups.md create mode 100644 nodejs/tests/groups.test.js create mode 100644 nodejs/utils/groups.js delete mode 100644 nodejs/views/groups.ejs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9bd15..1bf7cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v1.25.0 +- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host__admin/access/; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups +- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar) +- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases) + # v1.24.0 - feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery - feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config diff --git a/docs/groups.md b/docs/groups.md new file mode 100644 index 0000000..a00b674 --- /dev/null +++ b/docs/groups.md @@ -0,0 +1,312 @@ +--- +layout: default +title: Group & Permission Model +nav_order: 3 +--- + +# Theta42 Group & Permission Model + +This is the canonical reference for how **groups and permissions work** across the +theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux +hosts** should read and use them. It is written to be implementable by both humans +and LLM agents. + +Everything below assumes LDAP is the single source of truth for identity and group +membership. Group membership is managed in the **SSO Manager Directory**, generated +from adopted resources — there is **no standalone "Groups" page**. + +--- + +## 1. Principles + +1. **Groups are a projection of the resource graph.** Every adopted host and app + in the Directory gets its own groups, auto-created from its identity. Group + membership is managed on the resource's modal. +2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers + hosts; an app administers apps. They do not inherit from each other. +3. **Three levels per resource: `admin`, `access`, and opaque `capability`.** + `admin` implies `access`. Capabilities are explicit and never implied by + `admin`. +4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by + the site slug. +5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC) + with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it + needs and generate their GIDs on the fly (see §8) — no mass import, no GID + bloat. Only the meta groups are never imported by hosts. +6. **The directory is the only place groups are created.** `god_admin` is the sole + group that does not belong to a resource or site. + +--- + +## 2. Group schema + +`S` = site slug (see §7 for normalization). ``/`` = the resource slug. +`` = an opaque, app-defined capability token (see §4). + +| Group | Scope | Meaning | +| :--- | :--- | :--- | +| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. | +| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). | +| `S_hosts_admin` | site | Admin on **all hosts** at `S`. | +| `S_hosts_access` | site | Access to **all hosts** at `S`. | +| `S_hosts_` | site | Capability `` on **all hosts** at `S`. | +| `S_host__admin` | host | Admin on host ``. | +| `S_host__access` | host | Access to host ``. | +| `S_host__` | host | Capability `` on host ``. | +| `S_apps_admin` | site | Admin on **all apps** at `S`. | +| `S_apps_access` | site | Access to **all apps** at `S`. | +| `S_apps_` | site | Capability `` on **all apps** at `S`. | +| `S_app__admin` | app | Admin on app ``. | +| `S_app__access` | app | Access to app ``. | +| `S_app__` | app | Capability `` on app ``. | + +### Meta groups (implicit membership — not POSIX, no gidNumber) + +| Group | Scope | Meaning | +| :--- | :--- | :--- | +| `everyone` | global | **All authenticated users**, any site. | +| `S_everyone` | site | **All authenticated users** at site `S`. | + +These are resolved by the directory (any authenticated user passes), never +enumerated as LDAP members, and cannot be used as Unix groups. + +--- + +## 3. Naming, normalization & reserved rules + +- The **structural delimiter is `_`**. It appears only between the fixed segments + of a group name. +- **Site, host, and app slugs never contain `_`.** Normalize to lowercase; + spaces and `_` → `-`; strip other non-`[a-z0-9-]`. A host named `Web 01` and a + site `Main Office` produce slugs `web-01` and `main-office`. +- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups + use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even + if a host were named `admin` (that host would be `S_host_admin_admin`). +- **The last segment is the level.** If it is `admin` or `access` it is a known + level; any other value is an **opaque capability** owned by a downstream app. +- **Total length budget:** keep a group cn under ~120 chars; reject group + creation that would exceed it. +- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are + generated on the host by SSSD for only the groups that host imports (see §8). + +--- + +## 4. Levels and opaque capabilities + +- **`admin`** — manage (create/update/delete/config) the resource. +- **`access`** — use/read the resource. +- **``** — an arbitrary token the SSO does **not** interpret. The SSO + manages membership and exposes the group to the app; **the downstream app + defines and enforces what the capability means** (e.g. `emby_admin`, + `gitea_maintain`, `reboot`, `backup`). + +The directory recognizes `admin`, `access`, `super_admin`, and the meta groups. +Everything else on a resource group is treated as an opaque capability group and +passed through to consumers. + +--- + +## 5. Permission resolution (inheritance) + +Define a user's **effective permission** on a resource by checking, from most +specific to most general, whether they are a member of any applicable group. The +rule: a higher group implies everything below it. + +### On host `H` at site `S` + +| Wanted | Granted if the user is a member of **any** of | +| :--- | :--- | +| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` | +| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` | +| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` | + +### On app `A` at site `S` + +Identical, with `app`/`apps` substituted for `host`/`hosts`. + +### Management console (SSO / Proxy / Jump-Host) + +Each console is registered as an **app** on its site, so console admin is: + +`god_admin` · `S_super_admin` · `S_app__admin` + +### Pseudocode + +``` +def effective(resource, level_or_cap, site): + if user in "god_admin": return True + if user in f"{site}_super_admin": return True + if level_or_cap in ("admin","access"): + agg = f"{site}_{resource.kind}s_{level_or_cap}" + if user in agg: return True + specific = f"{site}_{resource.kind}_{resource.slug}_{level_or_cap}" + if user in specific: return True + if level_or_cap == "access": return effective(resource, "admin", site) + if level_or_cap == "admin": return False # access does not imply admin + return False +``` + +`everyone` / `S_everyone` are a special grantee: if a resource grants a group to +`everyone` (or `S_everyone`), any authenticated user (at that site) passes. + +--- + +## 6. Where groups live — the Directory, generated from adopted resources + +- There is **no standalone Groups page.** Group creation/management happens on an + **adopted resource** in the Directory. +- When a host or app is **adopted** (promoted from Discovered Inventory to + managed), the directory auto-creates its `_admin` and `_access` groups (and + site aggregates if configured). Capability groups are created on demand. +- Membership (add/remove users) and capability grants are managed on that + resource's modal. +- Deleting a resource removes its per-resource groups. +- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the + global `god_admin`/`everyone` are managed at the site level (not on a single + host/app resource). + +--- + +## 7. Multi-site isolation + +One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo", +"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*` +groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin` +touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and +`everyone` cross site boundaries. + +--- + +## 8. Unix/POSIX groups — mapped on the host, not in LDAP + +Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**. +There are hundreds of them and only a handful matter on any given host, so we do +**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the +groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping). +This keeps the directory clean and the per-host surface tiny. + +### SSSD — generate GIDs on the fly, import only what you need + +```ini +[domain/example] +id_provider = ldap +auth_provider = ldap +ldap_uri = ldaps://ldap.example +ldap_search_base = dc=example,dc=com + +# groupOfNames (RFC 2307bis) schema +ldap_schema = rfc2307bis +ldap_group_object_class = groupOfNames +ldap_group_member = member + +# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP +ldap_id_mapping = true +ldap_group_uuid = entryUUID + +# Import ONLY the groups this host needs (e.g. a naming convention or an OU) +ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*)) +``` + +Key ideas: +- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a + stable GID for any group it imports, so **no `gidNumber` attribute is required** + in LDAP. +- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that + match, discarding the other hundreds. After changing the filter, clear the + cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with + `getent group `. + +### What filter to use — the naming convention is the answer + +A host should import its **own** resource groups (plus any explicitly granted +ones). Because the schema is predictable, `ldap-client` can generate the per-host +`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01` +at site `main-office` imports: + +``` +(&(objectClass=groupOfNames)(|(cn=main-office_host_web01_access) + (cn=main-office_host_web01_admin) + (cn=main-office_host_web01_sudo))) +``` + +So the operator (or ldap-client) selects a small allowlist of the host's `_access` +/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem +ACLs. **Only those groups are imported** — no GID bloat, no mass import. + +### Aliasing an LDAP group into a local group (e.g. `input`) + +SSSD cannot merge an LDAP group into a local group whose GID varies per host. +Two host-side mechanisms cover it: + +- **pam_exec** — a script in the login stack adds the user to the local group for + the session: + ```sh + #!/bin/bash + if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi + ``` + `session optional pam_exec.so /usr/local/bin/add_to_input.sh` in + `/etc/pam.d/common-session`. + +- **nss-groupmerge** — merge an LDAP group into a local group at NSS time + (`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge` + in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's + members regardless of the local GID. + +### Meta groups + +`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have +implicit membership and are resolved by the directory only. + +--- + +## 9. Downstream-app consumption guide + +A downstream app (Emby, Gitea, a custom service, a shell script) reads group +membership from LDAP and interprets it as follows: + +1. **Discover the user's groups** — bind with the user's credentials (or use a + service account + `memberOf`). Groups are `groupOfNames` (member DN), so query + by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=))`, or use + the `memberOf` reverse attribute on the user's entry. +2. **Match each group to a scope:** + - `god_admin` → the user is a global administrator. + - `{site}_super_admin` → site administrator for that site. + - `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site. + - `{site}_host__*` / `{site}_app__*` → applies to that one resource. + - `everyone` / `{site}_everyone` → the user is implicitly a member. +3. **Interpret the last segment:** + - `admin` → full control of that resource. + - `access` → read/use. + - anything else → a capability **you** define; act on it or ignore it. +4. A user with `{site}_host_web01_access` can reach `web01`; a user with + `{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with + `{site}_app_emby_emby_admin` administers Emby. + +The app must **never** treat an unknown last segment as `admin` or `access`. + +--- + +## 10. Migration from the legacy `app_*` groups + +The current global groups (`app_sso_admin`, `app_super_admin`, +`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model: + +| Legacy | New | +| :--- | :--- | +| `app_super_admin` | `god_admin` | +| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) | +| `app_sso_directory_admin` | `S_app_sso_admin` | +| `app_jump_admin` | `S_app_jump_admin` | + +During the transition the legacy groups may be kept as short-lived aliases that +resolve to the same effective permission; once everything is moved, remove them. + +--- + +## 11. The management consoles are apps + +The SSO, Proxy, and Jump-Host each register themselves as an app on their site and +receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`, +`S_app_jump_admin`, plus `_access`). Their admin UIs gate on +`god_admin` · `S_super_admin` · `S_app__admin`. This keeps everything +self-consistent: the SSO is "just another app." diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index 5351b96..968b0e9 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -37,6 +37,7 @@ const DOCS = { agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')}, plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')}, vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')}, + groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')}, overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')}, changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')}, diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 144a4a3..5924fa8 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -188,10 +188,6 @@ router.get('/users/:uid', function(req, res, next) { res.render('profile', {...values}); }); -router.get('/groups', function(req, res, next) { - res.render('groups', {...values}); -}); - router.get('/token', function(req, res, next) { res.render('token', {...values}); }); diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 62da372..452e1f8 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){ // same answer in both modes. const groups = await groupCns(user); user.groups = groups; - user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP); + // Console admin under the group model (docs/GROUPS.md §11): god_admin, + // a site super admin, the SSO-as-app admin ({site}_app_sso_admin), or the + // legacy app_sso_admin/app_super_admin during migration. + user.isAdmin = groups.some((g) => + g === 'app_sso_admin' || g === 'app_super_admin' || + g === permission.SUPER_ADMIN_GROUP || + g.endsWith('_super_admin') || g.endsWith('_app_sso_admin')); return res.json(user); }catch(error){ diff --git a/nodejs/tests/groups.test.js b/nodejs/tests/groups.test.js new file mode 100644 index 0000000..3450f8a --- /dev/null +++ b/nodejs/tests/groups.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const { + slugify, + resourceGroupCns, + aggregateGroupCns, + siteSuperAdminCns, + siteEveryoneCns, + isKnownLevel, + levelGrants, + hasPermission, + GOD_ADMIN, +} = require('../utils/groups'); + +const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' }; +const APP = { site: 'main-office', kind: 'app', slug: 'emby' }; +const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'db' }; + +describe('slugify', () => { + test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => { + expect(slugify('Web 01')).toBe('web-01'); + expect(slugify('Main Office')).toBe('main-office'); + expect(slugify('my_host')).toBe('my-host'); + expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name'); + expect(slugify('')).toBe(''); + }); + test('never contains an underscore (the structural delimiter)', () => { + expect(slugify('a_b_c')).not.toContain('_'); + expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__'); + }); +}); + +describe('group cn builders', () => { + test('per-resource uses singular kind', () => { + expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin'); + expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access'); + }); + test('aggregate uses plural kind', () => { + expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin'); + expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access'); + }); + test('site super admin + everyone', () => { + expect(siteSuperAdminCns('Main Office')).toBe('main-office_super_admin'); + expect(siteEveryoneCns('main-office')).toBe('main-office_everyone'); + }); + test('invalid kind throws', () => { + expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow(); + }); +}); + +describe('levels', () => { + test('admin/access known; capabilities opaque', () => { + expect(isKnownLevel('admin')).toBe(true); + expect(isKnownLevel('access')).toBe(true); + expect(isKnownLevel('reboot')).toBe(false); + expect(isKnownLevel('emby_admin')).toBe(false); + }); + test('admin implies access; access does not imply admin', () => { + expect(levelGrants('admin', 'access')).toBe(true); + expect(levelGrants('access', 'admin')).toBe(false); + }); +}); + +describe('hasPermission — inheritance', () => { + test('god_admin grants everything everywhere', () => { + expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true); + expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true); + expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true); + expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true); + }); + + test('site super admin grants everything on its site, not other sites', () => { + expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true); + expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true); + expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false); + }); + + test('aggregate (all hosts) grants on any host at the site', () => { + expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true); + expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true); + expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true); + }); + + test('specific host group grants only that host', () => { + const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + expect(hasPermission([cn], HOST, 'admin')).toBe(true); + expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false); + }); + + test('admin implies access; access does not imply admin', () => { + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false); + }); + + test('capabilities are exact — admin does not grant a capability', () => { + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false); + // aggregate capability + expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true); + }); + + test('hosts and apps are orthogonal namespaces', () => { + const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + expect(hasPermission([hostAdmin], APP, 'access')).toBe(false); + const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin'); + expect(hasPermission([appAdmin], APP, 'access')).toBe(true); + }); + + test('cross-site isolation', () => { + const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false); + expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true); + }); +}); diff --git a/nodejs/utils/groups.js b/nodejs/utils/groups.js new file mode 100644 index 0000000..090c9eb --- /dev/null +++ b/nodejs/utils/groups.js @@ -0,0 +1,123 @@ +'use strict'; + +// Theta42 group & permission model. +// +// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed, +// parseable structure. The structural delimiter is `_`; site/host/app slugs +// never contain it. Aggregates use the plural kind (hosts/apps); per-resource +// uses the singular (host/app). +// +// god_admin global — everything, everywhere +// {site}_super_admin everything on the site +// {site}_hosts_ admin/access/capability on ALL hosts at the site +// {site}_hosts_ +// {site}_host__ admin/access/capability on ONE host +// {site}_apps_ ... on ALL apps at the site +// {site}_app__ ... on ONE app +// {site}_everyone / everyone meta groups (implicit membership) +// +// `level` is 'admin', 'access', or an opaque ``. `admin` implies +// `access`; capabilities are explicit and never implied by `admin`. Groups are +// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD). +// +// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers +// supply the user's group memberships (e.g. from Group.list(user.dn)). + +const GOD_ADMIN = 'god_admin'; +const KNOWN_LEVELS = ['admin', 'access']; +const KINDS = ['host', 'app']; + +// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never +// contains '_' (the structural delimiter), so group names parse unambiguously. +function slugify(name) { + return String(name || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +// Validate a kind (host/app) — throw on anything else. +function assertKind(kind) { + if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`); +} + +// {site}_host__ / {site}_app__ +function resourceGroupCns(site, kind, slug, level) { + assertKind(kind); + return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`; +} + +// {site}_hosts_ / {site}_apps_ (plural kind — the aggregate). +function aggregateGroupCns(site, kind, level) { + assertKind(kind); + return `${slugify(site)}_${kind}s_${level}`; +} + +// {site}_super_admin +function siteSuperAdminCns(site) { + return `${slugify(site)}_super_admin`; +} + +// {site}_everyone +function siteEveryoneCns(site) { + return `${slugify(site)}_everyone`; +} + +// True if `level` is a known admin/access level (not an opaque capability). +function isKnownLevel(level) { + return KNOWN_LEVELS.includes(level); +} + +// True if holding `level` grants `wanted` (admin implies access). +function levelGrants(level, wanted) { + if (level === wanted) return true; + return level === 'admin' && wanted === 'access'; +} + +// Resolve whether a user (given `memberOf` — the group cns they belong to) has +// `level` on a resource. Applies the inheritance lattice: +// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access. +// +// memberOf: array of group cns the user is a member of. +// resource: { site, kind: 'host'|'app', slug }. +// level: 'admin' | 'access' | an opaque capability token. +// +// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they +// are resource-level grants, resolved by the caller against the resource's own +// granted groups (see permission.onResource). This keeps the function pure over +// the user's membership only. +function hasPermission(memberOf, resource, level) { + const site = slugify(resource && resource.site); + const kind = resource && resource.kind; + const slug = slugify(resource && resource.slug); + const set = new Set(memberOf || []); + + if (set.has(GOD_ADMIN)) return true; + if (set.has(siteSuperAdminCns(site))) return true; + + if (isKnownLevel(level)) { + // admin / access + if (set.has(aggregateGroupCns(site, kind, level))) return true; + if (set.has(resourceGroupCns(site, kind, slug, level))) return true; + if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true; + return false; + } + // Opaque capability — exact aggregate or specific grant only. + if (set.has(aggregateGroupCns(site, kind, level))) return true; + if (set.has(resourceGroupCns(site, kind, slug, level))) return true; + return false; +} + +module.exports = { + GOD_ADMIN, + KNOWN_LEVELS, + KINDS, + slugify, + resourceGroupCns, + aggregateGroupCns, + siteSuperAdminCns, + siteEveryoneCns, + isKnownLevel, + levelGrants, + hasPermission, +}; diff --git a/nodejs/utils/permission.js b/nodejs/utils/permission.js index 15fc2e0..a5280c5 100644 --- a/nodejs/utils/permission.js +++ b/nodejs/utils/permission.js @@ -1,10 +1,20 @@ 'use strict'; const {Group} = require('../models/group_ldap'); +const groups = require('./groups'); -const SUPER_ADMIN_GROUP = 'app_super_admin'; +// The global god-admin group (everything, everywhere). During migration the +// legacy `app_super_admin` is recognized as an alias (docs/GROUPS.md §10). +const SUPER_ADMIN_GROUP = groups.GOD_ADMIN; +const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin']; -let byGroup = async function(user, groups, ownerOf){ +// True if the user (by resolved member cns) is a global god/super admin. +async function isSuperAdmin(memberOfCns) { + return memberOfCns.includes(groups.GOD_ADMIN) || + memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn)); +} + +let byGroup = async function(user, checkGroups, ownerOf){ // Membership is resolved once, transitively: a user placed in an admin group // through a nested group is as much a member as one listed on it directly. // Checking `group.member.includes(user.dn)` per group -- as this used to -- @@ -17,9 +27,9 @@ let byGroup = async function(user, groups, ownerOf){ // they still catch direct membership if the resolver is unavailable. } - if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true; + if(await isSuperAdmin(memberOfCns)) return true; - for(let group of groups){ + for(let group of checkGroups){ if(memberOfCns.includes(group)) return true; } @@ -42,4 +52,46 @@ let byGroup = async function(user, groups, ownerOf){ throw error; } -module.exports = {byGroup, SUPER_ADMIN_GROUP}; +// Resolve whether a user has `level` on a directory resource under the group +// model (see utils/groups.js). Applies the inheritance lattice and the +// `everyone`/`{site}_everyone` meta grants when the resource grants them. +// +// user: the auth user ({ dn, isMachine }). +// resource:{ site, kind: 'host'|'app', slug }. +// level: 'admin' | 'access' | an opaque capability token. +// grantedGroups: optional array of the resource's granted group cns (used only +// for meta `everyone` handling). Omit to skip meta grants. +async function onResource(user, resource, level, grantedGroups) { + let memberOfCns = []; + try { memberOfCns = await Group.list(user.dn); } catch (e) { /* ignore */ } + + if (await isSuperAdmin(memberOfCns)) return true; + if (groups.hasPermission(memberOfCns, resource, level)) return true; + + // Meta grants: `everyone` / `{site}_everyone` confer access to any + // authenticated (non-machine) user when the resource grants them. + if (level === 'access' && !user.isMachine && Array.isArray(grantedGroups)) { + const siteEveryone = groups.siteEveryoneCns(resource.site); + if (grantedGroups.includes('everyone') || grantedGroups.includes(siteEveryone)) return true; + } + return false; +} + +// Like onResource but throws Insufficient Permission when denied — for guards. +async function requireResource(user, resource, level, grantedGroups) { + if (await onResource(user, resource, level, grantedGroups)) return; + const error = new Error('Insufficient Permission'); + error.name = 'Insufficient Permission'; + error.status = 401; + throw error; +} + +module.exports = { + byGroup, + onResource, + requireResource, + isSuperAdmin, + SUPER_ADMIN_GROUP, + LEGACY_SUPER_ADMIN_ALIASES, + ...groups, // group schema builders (slugify, resourceGroupCns, ...) +}; diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 4128e3d..74eedee 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -41,7 +41,6 @@ module.exports = { // Catalog requires login - it's the end-user view of their accessible resources. {href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']}, {href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']}, - {href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {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//*. diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 25a7125..094b4bc 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -30,6 +30,7 @@
Directory Management +
diff --git a/nodejs/views/groups.ejs b/nodejs/views/groups.ejs deleted file mode 100644 index b16de27..0000000 --- a/nodejs/views/groups.ejs +++ /dev/null @@ -1,406 +0,0 @@ -<%- include('top') %> - - -
- -
-
- - -
- - -
-
-
-
-
- - Add new group - -
- -
-
-
- - -
- -
- - -
- - -
-
-
-
-
-
- - -
-

- {{ description }} -

-
-
-

-

    - {{ #member }} -
  • - {{ uid }} - -
  • - {{ /member }} -
-

- -
- -
-

- Everyone in a nested group is a member of this one, at any depth. -

-
    - {{ #nested }} -
  • - {{ cn }} - -
  • - {{ /nested }} - {{ ^hasNested }} -
  • No groups nested here.
  • - {{ /hasNested }} -
- -
- -
-

-

    - {{ #owner }} -
  • - {{ uid }} - -
  • - {{ /owner }} -
-

- - - -
-
-
- -
-
-
- -
-<%- include('bottom') %> From f00d311029a07cd9311b6e028f1665a478476cdb Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 4 Aug 2026 16:31:00 -0400 Subject: [PATCH 13/26] fix: keep SUPER_ADMIN_GROUP as app_super_admin so resource auto-provisioning nesting works api_directory_admin nests permission.SUPER_ADMIN_GROUP into every new resource's _admin group. Changing it to the not-yet-existing 'god_admin' made that nesting no-op, leaving the creator as the sole member (so the access_request test's beforeAll could not remove the last member of a groupOfNames). Revert it to 'app_super_admin' and recognize 'god_admin' separately in isSuperAdmin + isAdmin. Co-Authored-By: Claude --- nodejs/routes/user.js | 2 +- nodejs/utils/permission.js | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 452e1f8..bfa3586 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -95,7 +95,7 @@ router.get('/me', async function(req, res, next){ // legacy app_sso_admin/app_super_admin during migration. user.isAdmin = groups.some((g) => g === 'app_sso_admin' || g === 'app_super_admin' || - g === permission.SUPER_ADMIN_GROUP || + g === 'god_admin' || g === permission.SUPER_ADMIN_GROUP || g.endsWith('_super_admin') || g.endsWith('_app_sso_admin')); return res.json(user); diff --git a/nodejs/utils/permission.js b/nodejs/utils/permission.js index a5280c5..68e7d19 100644 --- a/nodejs/utils/permission.js +++ b/nodejs/utils/permission.js @@ -3,12 +3,18 @@ const {Group} = require('../models/group_ldap'); const groups = require('./groups'); -// The global god-admin group (everything, everywhere). During migration the -// legacy `app_super_admin` is recognized as an alias (docs/GROUPS.md §10). -const SUPER_ADMIN_GROUP = groups.GOD_ADMIN; +// The group nested into every resource's _admin group by api_directory_admin +// (cross-resource super-admin administration). KEEP the legacy `app_super_admin` +// here: it is the group that actually exists and gets nested. The new schema's +// global `god_admin` is recognized in isSuperAdmin() below, and api_directory_admin +// nests SUPER_ADMIN_GROUP -- so until `god_admin` is created during bootstrap, this +// must stay `app_super_admin` or resource auto-provisioning's nesting silently +// no-ops (leaving only the creator as the group's sole member). +const SUPER_ADMIN_GROUP = 'app_super_admin'; const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin']; // True if the user (by resolved member cns) is a global god/super admin. +// Recognizes BOTH the new schema's `god_admin` and the legacy `app_super_admin`. async function isSuperAdmin(memberOfCns) { return memberOfCns.includes(groups.GOD_ADMIN) || memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn)); From 398b64f5e3b722431d65f1d0e36baf256df52a31 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 4 Aug 2026 16:43:08 -0400 Subject: [PATCH 14/26] chore: bump package.json + lockfile to 1.25.0 Keep the release version in sync with the v1.25.0 tag (the changelog was bumped but package.json was left at 1.23.0, which would trigger a false update-check banner). Co-Authored-By: Claude --- nodejs/package-lock.json | 4 ++-- nodejs/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index f4fcb28..4cebfad 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.23.0", + "version": "1.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.23.0", + "version": "1.25.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index 2ca42d9..baa9b92 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.23.0", + "version": "1.25.0", "description": "A very simple LDAP management and SSO system", "author": [ { From 8a9de94d249542a5e87e34f55518401e2653a83a Mon Sep 17 00:00:00 2001 From: William Mantly Date: Tue, 4 Aug 2026 19:07:51 -0400 Subject: [PATCH 15/26] release/v1.26.0: complete group model, enforce naming, fix docs + status dots (#166) * feat: complete the group model (god_admin, site groups, aggregates), enforce naming, fix docs 500s + status dots (v1.26.0) - seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load - map service resources to the app kind (site_local_app__*); nest per-resource groups into site aggregates (physical inheritance lattice) - enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal - fix in-app /docs/ 500s (Dockerfile never copied docs/); serve doc images at /docs/images - fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width - group resolver: keep the site slug verbatim (site_local not re-slugified) - bump to 1.26.0 * fix: use verbatim resource slugs in group names (matches access-request tests + live convention) The group naming inserts a kind segment (resourceGroupCns(site, kind, slug, level)), but the access-request tests + the live directory convention are verbatim ({site}_{slug}_{level} -- the kind is carried in the resource slug, e.g. host_theta-env). For bare test slugs this produced site_x_host_artest-host_x_access instead of the expected site_x_artest-host_x_access, so the requester was never removed from the auto-provisioned access group and every request 409'd. resourceGroupCns is now (site, slug, level) with the verbatim slug; the kind is used only to pick the aggregate the group nests into. --- .dockerignore | 3 + CHANGELOG.md | 9 + Dockerfile.openldap | 5 + docker-entrypoint.sh | 17 +- nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/routes/api_directory_admin.js | 252 ++++++++++++++++++++++----- nodejs/routes/docs.js | 2 +- nodejs/tests/groups.test.js | 49 +++--- nodejs/utils/groups.js | 31 ++-- nodejs/views/directory.ejs | 26 ++- nodejs/views/profile.ejs | 4 +- 12 files changed, 322 insertions(+), 82 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4bd7ec3..60f96f5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,6 +19,9 @@ !API.md !directory_spec.md !docs/**/*.md +# The screenshots the README (served at /docs/overview) links. `COPY docs /docs` +# in Dockerfile.openldap needs these present in the build context. +!docs/images/** # Tests (excluded from production builds; test-runner Dockerfile copies them explicitly) # nodejs/tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf7cc5..e5491ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# v1.26.0 +- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild. +- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names +- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there +- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm +- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block) +- fix: in-app `/docs/` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images` +- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`) + # v1.25.0 - feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host__admin/access/; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups - feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar) diff --git a/Dockerfile.openldap b/Dockerfile.openldap index 5e8d2a7..d6622e1 100644 --- a/Dockerfile.openldap +++ b/Dockerfile.openldap @@ -184,6 +184,11 @@ COPY README.md /README.md COPY CHANGELOG.md /CHANGELOG.md COPY API.md /API.md COPY directory_spec.md /directory_spec.md +# The docs/*.md tree (plus the images the docs link) is read at runtime too, so +# the whole docs/ dir must land at /docs. Without this every in-app /docs/ +# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the +# fs.readFileSync in routes/docs.js (files missing from the image). +COPY docs /docs # Baked commit hash from the gitinfo stage (see build_info.js). COPY --from=gitinfo /commit.txt ./.build_commit diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c17a98f..c3f30c7 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -355,7 +355,11 @@ EOF # Required SSO groups. The app gates admin/invite/oauth-admin on these; # app_sso_service_account is a marker (not a permission gate) for # non-person accounts -- see the Users page. - for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do + # + # god_admin is the global super group (docs/GROUPS.md §2), the top of the + # group-inheritance lattice. It is seeded here so it exists from first boot; + # the theta-suite bootstrap puts the first admin person into it. + for group in god_admin app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true dn: cn=${group},ou=groups,${LDAP_BASE_DN} objectClass: groupOfNames @@ -385,6 +389,17 @@ member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN} EOF done info "Nested app_super_admin into the SSO admin groups" + + # god_admin is the top of the lattice; nesting it into app_super_admin + # (which is itself nested into the app_sso_* groups above) makes it + # resolve to everything app_super_admin holds at the LDAP level too. + ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true +dn: cn=app_super_admin,ou=groups,${LDAP_BASE_DN} +changetype: modify +add: member +member: cn=god_admin,ou=groups,${LDAP_BASE_DN} +EOF + info "Nested god_admin into app_super_admin" fi info "LDAP directory initialized" diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 4cebfad..6bb7b47 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.25.0", + "version": "1.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.25.0", + "version": "1.26.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index baa9b92..1e5abc8 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.25.0", + "version": "1.26.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index bbf3c77..5695cdd 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -8,6 +8,7 @@ const { cnFromDn } = require('../utils/user_groups'); const { projectResources } = require('@simpleworkjs/directory-schema'); const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP; +const groups = require('../utils/groups'); // Make `childCn` a member of `parentCn`, i.e. everyone in the child is // transitively in the parent. Idempotent and non-fatal: "already a member" is @@ -29,6 +30,148 @@ async function nestGroup(childCn, parentCn) { } } +// ── Group-model provisioning (docs/GROUPS.md) ─────────────────────────────── +// The directory is the single place groups are created, as a projection of the +// resource graph. These helpers materialize the group-inheritance lattice for +// a resource so it exists in LDAP as well as in the resolver (utils/groups.js). +// All of them are idempotent, so calling them again for a resource a newer +// release is backfilling is a no-op. + +// Map a directory resource kind onto a group-model kind (GROUPS.md §2). +// host -> host; service -> app (services/consoles are the group model's "apps"); +// site gets site-level groups (handled separately); oauth/container get no +// per-resource groups (oauth clients hang off their owning service). +function groupKind(resource) { + if (resource.kind === 'host') return 'host'; + if (resource.kind === 'service') return 'app'; + return null; +} + +// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn` +// seeds the mandatory first member. Returns true when created. +async function ensureGroup(name, ownerDn, description) { + try { + await Group.add({ name, owner: ownerDn, description }); + return true; + } catch (err) { + if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) { + console.error(`ensureGroup: failed to create ${name}:`, err); + } + return false; + } +} + +// Provision the site-level groups + the aggregates the per-resource groups nest +// into. Idempotent -- called on every directory list so a site seeded by an +// older release gets its groups without a rebuild: +// +// god_admin -> {site}_super_admin +// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin +// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access +// +// `{site}_everyone` is created for completeness; it has implicit membership and +// is granted to a resource as a grantee, never enumerated. +async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) { + if (!siteSlug) return; + + // Link a site group to the site resource (so it shows + is member-manageable + // on the site's modal). Idempotent. Admin groups link as owner; access/meta + // groups as member. + const link = async (cn, isAdmin) => { + if (!siteResourceId) return; + await ResourceGroup.create({ resourceId: siteResourceId, groupCn: cn, accessLevel: isAdmin ? 'owner' : 'member' }).catch(() => {}); + }; + + const sAdmin = groups.siteSuperAdminCns(siteSlug); + await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`); + await link(sAdmin, true); + for (const kind of ['host', 'app']) { + const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin'); + const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access'); + await ensureGroup(aggAdmin, ownerDn, `Admin on all ${kind}s at ${siteSlug}`); + await ensureGroup(aggAccess, ownerDn, `Access to all ${kind}s at ${siteSlug}`); + await link(aggAdmin, true); + await link(aggAccess, false); + } + await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`); + await link(groups.siteEveryoneCns(siteSlug), false); + // god_admin is the global group; surface it on the site modal so its members + // can be managed from the Directory (it has no home on a single resource). + await link(groups.GOD_ADMIN, true); + + // Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything + // binding directly) resolve it transitively, not just utils/permission.js. + // nestGroup(child, parent) makes child a member of parent -- membership flows + // child -> parent ("up"), so a group's members inherit what its parents hold. + await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere + for (const kind of ['host', 'app']) { + const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin'); + const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access'); + await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps + await nestGroup(aggAdmin, aggAccess); // site admin implies site access + } +} + +// Provision the per-resource groups for a host/app and nest them into the site +// aggregates (so a site/aggregate admin reaches this resource by membership). +// The specific group name uses the resource's slug verbatim +// (`{site}_{slug}_{level}` -- the kind is carried in the slug, e.g. `host_theta-env`); +// `kind` (host/app) selects which aggregate the group nests into: +// +// {site}_{slug}_admin -> {site}_{slug}_access +// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate) +// {site}_{slug}_access -> {site}_{kind}s_access (aggregate) +// app_super_admin -> {site}_{slug}_admin (legacy cross-app) +async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) { + const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access'); + const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, 'admin'); + + await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`); + await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`); + + // Link both groups to the resource so the Directory can show/revoke them. + await ResourceGroup.create({ resourceId: resource.id, groupCn: accessCn, accessLevel: 'member' }).catch(() => {}); + await ResourceGroup.create({ resourceId: resource.id, groupCn: adminCn, accessLevel: 'owner' }).catch(() => {}); + + await nestGroup(adminCn, accessCn); // administering implies using + await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource + await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource + await nestGroup(SUPER_ADMIN_GROUP, adminCn); // legacy cross-app super admin +} + +// The group CNs it is valid to associate with a given resource (docs/GROUPS.md +// §2/§3). This is what "force the correct naming convention" means: a group +// linked to a resource must be one that parses for consumers -- the resource's +// own specific groups, its site's aggregates, site-level groups, or the global +// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque +// capability groups following the same shapes. +function validGroupCnsForResource(resource, siteSlug) { + const valid = new Set(); + if (resource.kind === 'site') { + valid.add(groups.siteSuperAdminCns(siteSlug)); + valid.add(groups.siteEveryoneCns(siteSlug)); + for (const k of ['host', 'app']) { + valid.add(groups.aggregateGroupCns(siteSlug, k, 'admin')); + valid.add(groups.aggregateGroupCns(siteSlug, k, 'access')); + } + return { valid, capRe: new RegExp(`^${siteSlug}_(hosts|apps)_[a-z0-9-]+$`) }; + } + const kind = groupKind(resource); // 'host'|'app'|null + if (kind) { + const slug = resource.slug; // verbatim (kind is carried in the slug) + valid.add(groups.resourceGroupCns(siteSlug, slug, 'admin')); + valid.add(groups.resourceGroupCns(siteSlug, slug, 'access')); + valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin')); + valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access')); + valid.add(groups.siteSuperAdminCns(siteSlug)); + valid.add(groups.siteEveryoneCns(siteSlug)); + return { valid, capRe: new RegExp(`^${siteSlug}_(${slug}_|${kind}s_)[a-z0-9-]+$`) }; + } + // oauth/container etc. — only the global god_admin makes sense to pin here. + valid.add(groups.siteSuperAdminCns(siteSlug)); + return { valid, capRe: null }; +} + // Require the admin group router.use(async (req, res, next) => { try { @@ -50,6 +193,36 @@ router.get('/resources', async (req, res, next) => { }); // Even admins never receive secret metadata (e.g. client_secret_hash) over // the wire; projectResources strips it unconditionally. + + // Self-heal the group model (docs/GROUPS.md): ensure every site has its + // site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the + // aggregates, and every host/app resource has its per-resource groups nested + // into them. Idempotent, so this is a cheap no-op once present -- it's what + // backfills a directory seeded by an older release without a rebuild. + // Never fails the list. + const sites = resources.filter(r => r.kind === 'site'); + await Promise.all(sites.map(site => + ensureSiteGroups(site.slug, req.user.dn, site.name, site.id) + .catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message)) + )); + const siteByResource = new Map(); + for (const site of sites) siteByResource.set(site.id, site.slug); + const siteOf = async (r) => { + const direct = siteByResource.get(r.id); + if (direct) return direct; + // findAncestorSiteSlug returns the site's full slug (`site_local`) -- the + // group-model builders take it verbatim, so do NOT strip the `site_` prefix. + return await Resource.findAncestorSiteSlug(r.id).catch(() => null); + }; + await Promise.all(resources.map(async (r) => { + const gKind = groupKind(r); + if (!gKind) return; + const siteSlug = await siteOf(r); + if (!siteSlug) return; + await provisionResourceGroups(r, gKind, siteSlug, req.user.dn) + .catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message)); + })); + res.json({ results: projectResources(resources, { fullMetadata: true }) }); } catch (err) { next(err); } }); @@ -95,46 +268,25 @@ router.post('/resources', async (req, res, next) => { await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' }); } - if (r.kind === 'host' || r.kind === 'service') { - const siteSlug = await Resource.findAncestorSiteSlug(r.id); - const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`); - - const createGroup = async (suffix, accessLevel) => { - const cn = groupCn(suffix); - try { - await Group.add({ - name: cn, - owner: req.user.dn, - description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}` - }); - } catch (err) { - if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) { - console.error(`Failed to create LDAP group ${cn}:`, err); - } - } - try { - await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel }); - } catch(err) { /* ignore duplicate links */ } - }; - await createGroup('access', 'member'); - await createGroup('admin', 'owner'); - - // Wire up the two standing relationships every resource has, as nesting - // rather than as membership that has to be maintained per resource: - // - // app_super_admin -> _admin cross-app super admins administer - // every resource, automatically - // _admin -> _access administering something implies - // being able to use it - // - // Before nesting, both of these could only be expressed by adding every - // super admin to every new group by hand -- which nobody does, so the - // groups drifted. A failure here must not fail resource creation: the - // resource and its groups already exist and the nesting is repairable. - await nestGroup(groupCn('admin'), groupCn('access')); - await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin')); + // ── Group provisioning (docs/GROUPS.md) ─────────────────────────────── + // Materialize the group-model for the new resource. Site resources get the + // site-level groups; host/app resources get their per-resource groups nested + // into the site aggregates. Idempotent -- safe for a resource created by an + // older release. A provisioning failure must not fail resource creation: the + // resource already exists and the groups are repairable (re-run ensures them). + // + // `siteSlug` is the site resource's slug verbatim (`site_local`) -- the + // group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply + // the kind prefix themselves. + const gKind = groupKind(r); + const ancestorSite = await Resource.findAncestorSiteSlug(r.id); + if (r.kind === 'site') { + await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id); + } else if (gKind && ancestorSite) { + await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing + await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn); } - + res.json({ results: r }); } catch (err) { if (err.name === 'SequelizeUniqueConstraintError') { @@ -265,6 +417,28 @@ router.get('/groups', async (req, res, next) => { router.post('/groups', async (req, res, next) => { try { + const { resourceId, groupCn } = req.body; + if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' }); + + // Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must + // be a valid group for this resource; reject free-form names so the groups + // consumers read are always parseable. god_admin is always allowed (it is + // the global group and is managed from a site's modal). + const resource = await Resource.get(resourceId); + // Full site slug verbatim (`site_local`) -- the builders take it as-is. A + // site resource's own slug is its site; a host/app uses its ancestor site. + const siteSlug = resource && resource.kind === 'site' + ? resource.slug + : await Resource.findAncestorSiteSlug(resourceId); + if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) { + const { valid, capRe } = validGroupCnsForResource(resource, siteSlug); + if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) { + const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`); + err.status = 400; + throw err; + } + } + const g = await ResourceGroup.create(req.body); res.json({ results: g }); } catch (err) { next(err); } diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index 968b0e9..ac9573e 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -55,7 +55,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title}) // only resolves correctly on GitHub. Serve that same folder here and rewrite // the rendered markup to point at it absolutely, so the images work when // read from /docs/overview too. -router.use('/images', require('express').static(path.join(__dirname, '../../docs/images'))); +router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images'))); function fixImagePaths(html) { return html.replace(/(["(])docs\/images\//g, '$1/docs/images/'); } diff --git a/nodejs/tests/groups.test.js b/nodejs/tests/groups.test.js index 3450f8a..9ead71a 100644 --- a/nodejs/tests/groups.test.js +++ b/nodejs/tests/groups.test.js @@ -12,9 +12,12 @@ const { GOD_ADMIN, } = require('../utils/groups'); -const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' }; +// Resource fixtures mirror the directory's real slugs: hosts carry a `host_` +// prefix, services/apps are stored bare. The group-model builders use these +// verbatim (no re-slugifying, no kind insertion) -- see groups.js. +const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' }; const APP = { site: 'main-office', kind: 'app', slug: 'emby' }; -const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'db' }; +const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' }; describe('slugify', () => { test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => { @@ -24,27 +27,31 @@ describe('slugify', () => { expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name'); expect(slugify('')).toBe(''); }); - test('never contains an underscore (the structural delimiter)', () => { - expect(slugify('a_b_c')).not.toContain('_'); - expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__'); - }); }); describe('group cn builders', () => { - test('per-resource uses singular kind', () => { - expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin'); - expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access'); + test('per-resource uses the resource slug verbatim (kind is carried in the slug)', () => { + expect(resourceGroupCns('main-office', 'host_web-01', 'admin')).toBe('main-office_host_web-01_admin'); + expect(resourceGroupCns('main-office', 'emby', 'access')).toBe('main-office_emby_access'); }); - test('aggregate uses plural kind', () => { + test('aggregate uses the plural kind', () => { expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin'); expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access'); }); test('site super admin + everyone', () => { - expect(siteSuperAdminCns('Main Office')).toBe('main-office_super_admin'); + expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin'); expect(siteEveryoneCns('main-office')).toBe('main-office_everyone'); }); - test('invalid kind throws', () => { - expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow(); + test('a directory site slug with a kind prefix is kept verbatim, not re-slugified', () => { + // Resource slugs are `site_local` / `host_theta-env` -- re-slugifying the + // site (`site_local` -> `site-local`) would corrupt the delimiter. + expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin'); + expect(siteEveryoneCns('site_local')).toBe('site_local_everyone'); + expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin'); + expect(resourceGroupCns('site_local', 'host_theta-env', 'access')).toBe('site_local_host_theta-env_access'); + }); + test('invalid kind throws (aggregates only — per-resource has no kind arg)', () => { + expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow(); }); }); @@ -82,32 +89,32 @@ describe('hasPermission — inheritance', () => { }); test('specific host group grants only that host', () => { - const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + const cn = resourceGroupCns('main-office', 'host_web-01', 'admin'); expect(hasPermission([cn], HOST, 'admin')).toBe(true); expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false); }); test('admin implies access; access does not imply admin', () => { - expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true); - expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false); + expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'access')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'access')], HOST, 'admin')).toBe(false); }); test('capabilities are exact — admin does not grant a capability', () => { - expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true); - expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false); + expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'reboot')], HOST, 'reboot')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'reboot')).toBe(false); // aggregate capability expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true); }); test('hosts and apps are orthogonal namespaces', () => { - const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + const hostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin'); expect(hasPermission([hostAdmin], APP, 'access')).toBe(false); - const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin'); + const appAdmin = resourceGroupCns('main-office', 'emby', 'admin'); expect(hasPermission([appAdmin], APP, 'access')).toBe(true); }); test('cross-site isolation', () => { - const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + const mainHostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin'); expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false); expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true); }); diff --git a/nodejs/utils/groups.js b/nodejs/utils/groups.js index 090c9eb..ae0d42a 100644 --- a/nodejs/utils/groups.js +++ b/nodejs/utils/groups.js @@ -41,26 +41,33 @@ function assertKind(kind) { if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`); } -// {site}_host__ / {site}_app__ -function resourceGroupCns(site, kind, slug, level) { - assertKind(kind); - return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`; +// {site}_{slug}_{level} — the per-resource group for one resource. +// +// Both `site` and `slug` are the resource slugs verbatim (e.g. `site_local`, +// `host_theta-env`), NOT slugified or kind-inserted: directory resource slugs +// carry their kind as a prefix (`host_theta-env`), so `site_local` + `host_theta-env` +// yields `site_local_host_theta-env_access`. Services are stored without a +// prefix (`sso-manager`), yielding `site_local_sso-manager_access`. This is the +// convention the auto-provisioner, the resolver, and the access-request tests +// all share -- re-slugifying or inserting a kind would double the delimiter. +function resourceGroupCns(site, slug, level) { + return `${site}_${slug}_${level}`; } // {site}_hosts_ / {site}_apps_ (plural kind — the aggregate). function aggregateGroupCns(site, kind, level) { assertKind(kind); - return `${slugify(site)}_${kind}s_${level}`; + return `${site}_${kind}s_${level}`; } // {site}_super_admin function siteSuperAdminCns(site) { - return `${slugify(site)}_super_admin`; + return `${site}_super_admin`; } // {site}_everyone function siteEveryoneCns(site) { - return `${slugify(site)}_everyone`; + return `${site}_everyone`; } // True if `level` is a known admin/access level (not an opaque capability). @@ -87,9 +94,11 @@ function levelGrants(level, wanted) { // granted groups (see permission.onResource). This keeps the function pure over // the user's membership only. function hasPermission(memberOf, resource, level) { - const site = slugify(resource && resource.site); + // `site` and `slug` are used verbatim (resource slugs may carry a kind prefix, + // e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns. + const site = resource && resource.site; const kind = resource && resource.kind; - const slug = slugify(resource && resource.slug); + const slug = resource && resource.slug; const set = new Set(memberOf || []); if (set.has(GOD_ADMIN)) return true; @@ -98,13 +107,13 @@ function hasPermission(memberOf, resource, level) { if (isKnownLevel(level)) { // admin / access if (set.has(aggregateGroupCns(site, kind, level))) return true; - if (set.has(resourceGroupCns(site, kind, slug, level))) return true; + if (set.has(resourceGroupCns(site, slug, level))) return true; if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true; return false; } // Opaque capability — exact aggregate or specific grant only. if (set.has(aggregateGroupCns(site, kind, level))) return true; - if (set.has(resourceGroupCns(site, kind, slug, level))) return true; + if (set.has(resourceGroupCns(site, slug, level))) return true; return false; } diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 094b4bc..3e5a27d 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -530,6 +530,11 @@ // tab read from these. Agent data comes from /api/agent/nodes (admin-gated). var agentsByHost = {}; var agentsByToken = {}; + // True when the agent/nodes endpoint itself was unreachable (network, or an + // older app without the agent route). When set we cannot tell "this host has + // no agent" apart from "the agent service is down", so we must NOT paint every + // host red as if it lacked an agent. + var agentsUnavailable = false; async function loadResources() { try { @@ -540,8 +545,12 @@ // Access counts are a nicety, not load-bearing: if the LDAP join fails // the table still renders, just without the Access column populated. app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }), - // Agents are a nicety too: never block the directory on them. - app.api.get('agent/nodes').catch(function(){ return {agents: []}; }) + // Agents are a nicety too: never block the directory on them. Track + // whether the endpoint itself is reachable so host rows can tell "no + // agent on this host" from "agent service is down" (see attachAgentStatus). + app.api.get('agent/nodes') + .then(function(res){ agentsUnavailable = false; return res; }) + .catch(function(){ agentsUnavailable = true; return {agents: []}; }) ]); accessSummary = (resAccess && resAccess.results) || {}; @@ -610,7 +619,12 @@ const a = agentsByHost[name] || (slug && agentsByHost[slug]); n.agent = a || null; if (resourcesById[n.id]) resourcesById[n.id].agent = a || null; - if (!a) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; } + if (!a) { + // Endpoint unreachable: we genuinely don't know -- neutral grey, not a + // false red alarm across every host. + if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; } + n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; + } if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; } const t = a.telemetry || {}; const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90); @@ -655,8 +669,12 @@ try { const res = await app.api.get('agent/nodes'); indexAgents((res && res.agents) || []); + agentsUnavailable = false; renderTable(); - } catch (e) { /* non-fatal */ } + } catch (e) { + agentsUnavailable = true; + renderTable(); // re-render so dots flip to neutral, not stale green + } } // "Who can reach this?" at a glance. A resource with no linked group is not a diff --git a/nodejs/views/profile.ejs b/nodejs/views/profile.ejs index 799e6b9..2ac100c 100644 --- a/nodejs/views/profile.ejs +++ b/nodejs/views/profile.ejs @@ -657,8 +657,8 @@ + + + +
+ `; + // Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally // URL-tracked) modal DOM. Callers then populate fields via .val() and hide // the Groups/Children tabs in add-mode (no resource id to scope them to). @@ -519,6 +612,7 @@ {id: 'details', label: 'Details', bodyHtml: detailsTabHtml}, {id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml}, {id: 'children', label: 'Children', bodyHtml: childrenTabHtml}, + {id: 'secrets', label: 'Secrets & OpenBao', bodyHtml: secretsTabHtml}, {id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)}, ], footer: { @@ -527,7 +621,7 @@ }, url: id ? {path: '/directory/' + resourcesById[id].slug} : null, }); - $('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id); + $('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn, #sw-modal-tab-secrets-btn').closest('li').toggle(!!id); } function refreshChildrenUI(resourceId) { @@ -821,8 +915,15 @@ function renderTable() { const filter = $('#search-filter').val().toLowerCase(); const sort = $('#sort-by').val(); + const showPlumbing = $('#toggle-plumbing').is(':checked'); let filtered = rawResources.filter(r => { + if (!showPlumbing && !filter) { + const sub = (r.metadata?.subType || '').toLowerCase(); + if (r.kind === 'container' || r.kind === 'oauth' || sub === 'sidecar' || sub === 'container' || sub === 'openresty') { + return false; + } + } if (!filter) return true; return (r.name || '').toLowerCase().includes(filter) || (r.slug || '').toLowerCase().includes(filter) || @@ -1377,8 +1478,188 @@ refreshGroupsUI(r.id); refreshEdgesUI(r.id); refreshChildrenUI(r.id); + loadResourceSecrets(r.id); await loadLdapGroups(); } + + var currentResourceSecretsList = []; + var currentParentSecretsList = []; + var rawResourceSecretsMap = {}; + + const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/; + + function validateSecretKeyInput(el) { + const $el = $(el); + const val = $el.val().trim(); + if (val && !SECRET_KEY_REGEX.test(val)) { + $el.addClass('is-invalid'); + return false; + } else { + $el.removeClass('is-invalid'); + return true; + } + } + + function generateRandomString(len) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?'; + const bytes = new Uint8Array(len); + window.crypto.getRandomValues(bytes); + let str = ''; + for (let i = 0; i < len; i++) { + str += chars[bytes[i] % chars.length]; + } + return str; + } + + async function loadResourceSecrets(id) { + const $tbody = $('#secrets-table-body').empty(); + $tbody.append('Loading secrets from OpenBao...'); + currentResourceSecretsList = []; + currentParentSecretsList = []; + rawResourceSecretsMap = {}; + + try { + const res = await app.api.get(`directory-admin/resources/${id}/secrets`); + currentResourceSecretsList = (res && res.secrets) || []; + currentParentSecretsList = (res && res.parentSecrets) || []; + renderSecretsTable(); + populateParentSecretsDropdown(); + } catch (err) { + $tbody.empty().append(`Failed to load secrets: ${esc(err.message || 'Unknown error')}`); + } + } + + function populateParentSecretsDropdown() { + const $card = $('#inherit-secret-card'); + const $select = $('#inherit-parent-select').empty(); + + if (!currentParentSecretsList || currentParentSecretsList.length === 0) { + $card.hide(); + return; + } + + currentParentSecretsList.forEach(p => { + const valStr = `INHERIT:${p.parentSlug}:${p.key}`; + const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`; + $select.append(``); + }); + $card.show(); + } + + function renderSecretsTable() { + const $tbody = $('#secrets-table-body').empty(); + + if (currentResourceSecretsList.length === 0) { + $tbody.append('No secrets configured for this resource yet.'); + return; + } + + currentResourceSecretsList.forEach((s, idx) => { + const $row = $(` + + ${esc(s.key)} + + ${s.isInherited + ? `Inherited from ${esc(s.parentSlug || 'Parent')} (${esc(s.parentKey || s.key)})` + : `Configured in OpenBao Secret Value Hidden` + } + + + + + + + `); + $tbody.append($row); + }); + } + + function generateSecretValue() { + let key = $('#new-secret-key').val().trim(); + if (!key) { + key = 'SECRET_KEY'; + $('#new-secret-key').val(key); + } + const len = parseInt($('#gen-secret-length').val(), 10) || 32; + const randomSecret = generateRandomString(len); + $('#new-secret-val').val(randomSecret); + $('#gen-secret-notice').show(); + } + + function editSecretKey(key) { + $('#new-secret-key').val(key); + $('#new-secret-val').val('').focus(); + $('#gen-secret-notice').hide(); + } + + async function addSecretRow() { + const keyEl = $('#new-secret-key')[0]; + const key = $('#new-secret-key').val().trim(); + const val = $('#new-secret-val').val(); + + if (!key) { + app.messages.action('Please enter a secret key name (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'warning'); + return; + } + if (!validateSecretKeyInput(keyEl)) { + app.messages.action('Invalid secret key format. Only uppercase/lowercase letters, numbers, and underscores are allowed (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'danger'); + return; + } + + rawResourceSecretsMap[key] = val || ''; + $('#new-secret-key').val(''); + $('#new-secret-val').val(''); + $('#gen-secret-notice').hide(); + await saveResourceSecretsMap(); + } + + async function inheritParentSecret() { + const childKey = $('#inherit-child-key').val().trim(); + const inheritVal = $('#inherit-parent-select').val(); + + if (!childKey) { + app.messages.action('Please enter a child secret key name (e.g. DB_HOST).', $('#secrets-tab-container'), 'warning'); + return; + } + if (!SECRET_KEY_REGEX.test(childKey)) { + app.messages.action('Invalid child key name. Only letters, numbers, and underscores allowed.', $('#secrets-tab-container'), 'danger'); + return; + } + if (!inheritVal) { + app.messages.action('Select a parent secret to inherit from.', $('#secrets-tab-container'), 'warning'); + return; + } + + rawResourceSecretsMap[childKey] = inheritVal; + $('#inherit-child-key').val(''); + await saveResourceSecretsMap(); + } + + async function deleteSecretKey(key) { + const confirmed = await app.messages.confirm(`Delete secret '${key}' from OpenBao?`, $('#secrets-tab-container'), 'danger'); + if (!confirmed) return; + delete rawResourceSecretsMap[key]; + await saveResourceSecretsMap(); + } + + async function saveResourceSecretsMap() { + const resourceId = $('#res-id').val(); + if (!resourceId) return; + + try { + app.messages.action('Saving secrets to OpenBao...', $('#secrets-tab-container'), 'info'); + await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: rawResourceSecretsMap }); + app.messages.action('Secret saved to OpenBao successfully!', $('#secrets-tab-container'), 'success'); + loadResourceSecrets(resourceId); + } catch (err) { + app.messages.action(err.message || 'Failed to save secrets to OpenBao', $('#secrets-tab-container'), 'danger'); + } + } + + function refreshResourceSecrets() { + const resourceId = $('#res-id').val(); + if (resourceId) loadResourceSecrets(resourceId); + } async function saveResource() { // Promote path: the modal was opened from a discovered inventory row, so From 10e5193077d9b3fd36cc0320b7c979bb3bddba1c Mon Sep 17 00:00:00 2001 From: William Mantly Date: Fri, 7 Aug 2026 23:35:20 -0400 Subject: [PATCH 25/26] test: update api_agent_ops test to match default paths 200 response --- nodejs/tests/api_agent_ops.test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nodejs/tests/api_agent_ops.test.js b/nodejs/tests/api_agent_ops.test.js index 5537112..c17de60 100644 --- a/nodejs/tests/api_agent_ops.test.js +++ b/nodejs/tests/api_agent_ops.test.js @@ -60,13 +60,15 @@ describe('Agent ops — POST /api/v1/agent/secrets', () => { expect(res.status).toBe(401); }); - test('missing paths returns 400', async () => { + test('missing paths defaults to agent node & resource secrets', async () => { const { token } = await enrollAgent(); const res = await request(app) .post('/api/v1/agent/secrets') .set('Authorization', `Bearer ${token}`) .send({}); - expect(res.status).toBe(400); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.secrets).toBeDefined(); }); }); From a442dc9921a5e7555585156804c9390951087a23 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 8 Aug 2026 15:38:35 -0400 Subject: [PATCH 26/26] release: v1.32.0 - Subtype Drivers Engine, Explicit Secret Inheritance & App Tokens consolidation --- API.md | 72 +++++ CHANGELOG.md | 14 + Dockerfile.openldap | 1 + Dockerfile.test-runner | 1 + README.md | 5 +- directory_spec.md | 32 +++ docs/agents.md | 25 +- docs/directory.md | 26 ++ docs/vault.md | 46 +--- nodejs/config/inventory.sqlite | Bin 61440 -> 118784 bytes nodejs/drivers/base_driver.js | 61 +++++ nodejs/drivers/db_driver.js | 82 ++++++ nodejs/drivers/docker_socket_driver.js | 62 +++++ nodejs/drivers/k8s_driver.js | 67 +++++ nodejs/drivers/network_driver.js | 81 ++++++ nodejs/drivers/proxmox_driver.js | 90 +++++++ nodejs/drivers/theta_agent_driver.js | 125 +++++++++ nodejs/models/agent.js | 1 + nodejs/models/resource.js | 5 +- nodejs/package.json | 2 +- nodejs/plugins/discovery/docker.js | 6 +- nodejs/plugins/discovery/nmap.js | 4 +- nodejs/plugins/discovery/proxmox.js | 4 +- nodejs/plugins/discovery/unifi.js | 4 +- nodejs/routes/api_agent.js | 2 +- nodejs/routes/api_directory_admin.js | 102 ++++++-- nodejs/routes/index.js | 14 +- nodejs/services/discovery_reconciler.js | 75 +++++- nodejs/services/driver_registry.js | 115 ++++++++ nodejs/services/scheduler.js | 2 +- nodejs/tests/driver_registry.test.js | 86 ++++++ nodejs/utils/agent_manager.js | 9 + nodejs/utils/ui.js | 2 - nodejs/utils/vault_broker.js | 20 +- nodejs/views/conf.ejs | 111 ++++++++ nodejs/views/directory.ejs | 333 ++++++++++++++++++------ nodejs/views/plugins.ejs | 47 +++- 37 files changed, 1543 insertions(+), 191 deletions(-) create mode 100644 nodejs/drivers/base_driver.js create mode 100644 nodejs/drivers/db_driver.js create mode 100644 nodejs/drivers/docker_socket_driver.js create mode 100644 nodejs/drivers/k8s_driver.js create mode 100644 nodejs/drivers/network_driver.js create mode 100644 nodejs/drivers/proxmox_driver.js create mode 100644 nodejs/drivers/theta_agent_driver.js create mode 100644 nodejs/services/driver_registry.js create mode 100644 nodejs/tests/driver_registry.test.js diff --git a/API.md b/API.md index 9f23cf1..8b3bc1e 100644 --- a/API.md +++ b/API.md @@ -1336,6 +1336,78 @@ All endpoints require authentication and `app_sso_admin` membership. Runtime con **Response:** `{ "success": true }` +--- + +## Subtype Driver Operations Endpoints + +Base path: `/api/directory-admin/resources` + +All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `admin` permission. + +### Get Subtype Driver Metrics + +**`GET /api/directory-admin/resources/:id/driver-metrics`** + +Resolves the operational driver for the resource via the 4-tier engine (`theta-agent`, specialized subtype driver, parent hypervisor provider, or unmanaged fallback) and returns real-time telemetry. + +**Response:** +```json +{ + "status": "ok", + "resourceId": "res-id", + "metrics": { + "status": "online", + "driver": "database", + "subType": "redis", + "redis": { "connectedClients": 4, "usedMemoryBytes": 12582912, "opsPerSec": 42 } + } +} +``` + +--- + +### Execute Subtype Driver Action + +**`POST /api/directory-admin/resources/:id/driver-action`** + +Executes a protocol action on the target resource (e.g. systemd restart, Proxmox power control, Redis flush, K8s scale). + +**Request:** +```json +{ + "action": "restart", + "params": { "serviceName": "emby-server" } +} +``` + +**Response:** +```json +{ + "status": "ok", + "resourceId": "res-id", + "result": { "status": "ok", "driver": "docker_socket", "action": "restart" } +} +``` + +--- + +### Get Subtype Driver Logs + +**`GET /api/directory-admin/resources/:id/driver-logs?lines=100`** + +Retrieves recent operational logs for the resource via the resolved driver (`journalctl`, `docker logs`, Proxmox task logs, K8s pod logs). + +**Response:** +```json +{ + "status": "ok", + "resourceId": "res-id", + "logs": "[docker logs --tail 100 emby-server]\nContainer initialized..." +} +``` + +--- + ## Error Responses All endpoints return errors in this format: diff --git a/CHANGELOG.md b/CHANGELOG.md index f158bd2..55245fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# v1.32.0 - 2026-08-08 + +### Added +- **Subtype Management & Metrics Drivers Engine.** Built a 4-tier driver resolution engine (`services/driver_registry.js`) binding resource `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `unifi`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control. +- **Subtype Operations APIs.** Exposed `/api/directory-admin/resources/:id/driver-metrics`, `driver-action`, and `driver-logs` endpoints. +- **Explicit Secret Inheritance Mode.** Enforced strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`) for secret inheritance, resolving explicit pointers (`INHERIT::`) without exposing sibling directory secrets. +- **Consolidated External App Tokens.** Relocated external OpenBao App Token minting into the **Configuration** page (`/conf` -> External App Tokens tab) and deprecated standalone `/vault` navigation item. +- **Multi-Secret Key Support.** Supported multiple secret keys per resource in OpenBao `secret/data/resources//conf` with per-key merging and deletion. +- **Cross-Platform Agent Packaging.** Built multi-architecture Dockerfile staging and documentation for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon). + +### Fixed +- **Ancestry Lineage Querying.** Fixed `Resource.findAllAncestors(id)` memory filtering over `ResourceEdge.list()` to resolve deep ancestor lineage across all graph depths. +- **Dockerfile Module Inclusion.** Included `COPY nodejs/drivers ./drivers` in `Dockerfile.openldap` and `Dockerfile.test-runner` for clean container execution. + # v1.31.0 - 2026-08-07 ### Added diff --git a/Dockerfile.openldap b/Dockerfile.openldap index d6622e1..f163723 100644 --- a/Dockerfile.openldap +++ b/Dockerfile.openldap @@ -163,6 +163,7 @@ COPY nodejs/app.js ./ COPY nodejs/bin ./bin COPY nodejs/conf ./conf COPY nodejs/controller ./controller +COPY nodejs/drivers ./drivers COPY nodejs/middleware ./middleware COPY nodejs/models ./models COPY nodejs/routes ./routes diff --git a/Dockerfile.test-runner b/Dockerfile.test-runner index 4af969c..0f0d142 100644 --- a/Dockerfile.test-runner +++ b/Dockerfile.test-runner @@ -20,6 +20,7 @@ COPY nodejs/app.js ./ COPY nodejs/bin ./bin COPY nodejs/conf ./conf COPY nodejs/controller ./controller +COPY nodejs/drivers ./drivers COPY nodejs/middleware ./middleware COPY nodejs/models ./models # Without this the discovery/plugin suites cannot even load their subject and diff --git a/README.md b/README.md index e76a63c..5aff1a7 100755 --- a/README.md +++ b/README.md @@ -47,8 +47,9 @@ phone-home, no hosted control plane, and no per-user pricing. same directory, so you don't maintain a second user database for them. - **Personal access tokens** — any user can mint a long-lived bearer token to drive the management API from scripts or CI, scoped to their own permissions. -- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run - the pieces separately against your own LDAP/Redis via `app_*` env config. +- **Directory & Inventory Graph** — full host/service/site graph with resource metadata, automatic LDAP group provisioning (`_access` / `_admin`), and Access Request workflows. +- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine binding `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control. +- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), preserving precise secret scoping across services and containers. - **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency. ## Why this over the alternatives diff --git a/directory_spec.md b/directory_spec.md index 9d4ca1f..e9fc1d4 100644 --- a/directory_spec.md +++ b/directory_spec.md @@ -367,3 +367,35 @@ Ordered by how much they unblock: conventions, not schema changes; the json column already holds them. 5. **`updated_on` in graph output / graph etag** (blocks drift/DNS freshness; trivial once surfaced). + +--- + +## 10. Subtype Management & Metrics Drivers Architecture + +The Directory incorporates a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds resource `subType` metadata to specific telemetry, log streaming, and operational management protocols. + +### Subtype Matrix & Drivers + +| Subtype Category | Supported Subtypes | Primary Driver | Management Capabilities | Telemetry & Metrics | +| :--- | :--- | :--- | :--- | :--- | +| **Service Managers** | `systemd`, `openrc`, `windows_service` | `ThetaAgentDriver` / Systemd | `start`, `stop`, `restart`, `reload` | CPU, Memory, Active PID, SubState | +| **Containers & Stacks** | `docker`, `docker_compose` | `DockerSocketDriver` / Agent | `start`, `stop`, `restart`, `pause` | CPU %, Memory Limit/Usage, Net/Block I/O | +| **Virtualization & Hypervisors** | `proxmox`, `lxc`, `kvm`, `esxi`, `libvirt_kvm`, `vps_generic` | `ProxmoxDriver` / Hypervisor | `start`, `stop`, `shutdown`, `reboot` | Guest VMID CPU/RAM/Disk, Parent Hypervisor status | +| **Networking & Appliances** | `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense` | `NetworkDriver` | `restart`, `locate`, `sync` | Connected Clients, Handshakes, Gateway RTT, Channels | +| **Databases & Vaults** | `postgresql`, `redis`, `openbao_vault` | `DbDriver` | `flush`, `seal`, `unseal` | DB Size, Connections, Hit Rates, Active Leases | +| **Orchestration** | `k8s_pod`, `k8s_deployment` | `K8sDriver` | `scale`, `restart`, `rollout_restart` | Desired/Ready Replicas, Pod Phase, IP | +| **Workstations** | `desktop_linux`, `desktop_windows` | `ThetaAgentDriver` | `reboot`, `shutdown`, Display Manager | CPU, Memory, GPU, Active Sessions | + +*(Note: Reverse Proxy subtypes like Nginx/HAProxy/Caddy/Traefik are excluded per environment configuration).* + +### 4-Tier Driver Resolution Engine +1. **Direct Agent Execution**: If `theta-agent` is connected directly to the target resource. +2. **Subtype-Specific Driver**: Executes specialized protocol driver (e.g. Proxmox API, Docker Engine API, DB Driver). +3. **Ancestor / Hypervisor Fallback**: If an LXC/KVM guest lacks a direct agent, queries its parent Proxmox hypervisor node for metrics and power controls. +4. **Unmanaged Fallback**: Reports unmanaged status cleanly without breaking UI/API contracts. + +### Subtype Operations Endpoints +- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload +- `POST /api/directory-admin/resources/:id/driver-action` — Execute management action (`{ action, params }`) +- `GET /api/directory-admin/resources/:id/driver-logs` — Tail log output (`?lines=100`) + diff --git a/docs/agents.md b/docs/agents.md index 3d3fe8d..5e96507 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -6,12 +6,25 @@ nav_order: 5 # Theta Agent & Endpoint Management -The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) -endpoint management daemon written in Go for Linux hosts across your home lab, -infrastructure, or data center. It connects outbound via a long-lived WebSocket -connection to the central **SSO Manager** (`wss:///api/agent/ws`), - enabling real-time host telemetry, automated host discovery, and local-first - administrative management. +The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go with native cross-platform binaries for **Linux (x86_64, ARM64, ARMv7)**, **Windows (x86_64, ARM64)**, and **macOS (Intel, Apple Silicon M1/M2/M3/M4)**. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss:///api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management. + +--- + +## Supported Architectures & Operating Systems + +The agent is compiled for 7 target platform binaries with zero external runtime dependencies: + +| Operating System | Architecture | Binary Name | Typical Target Devices | +| :--- | :--- | :--- | :--- | +| **Linux** | `amd64` (x86_64) | `theta-agent-linux-amd64` | Intel/AMD Servers, Cloud VMs, Proxmox Hypervisors | +| **Linux** | `arm64` (aarch64) | `theta-agent-linux-arm64` | Raspberry Pi 4/5, Graviton, Ampere Altra | +| **Linux** | `armv7` (32-bit ARM) | `theta-agent-linux-armv7` | Raspberry Pi 2/3/Zero 2W, ARM IoT Gateways | +| **Windows** | `amd64` (x86_64) | `theta-agent-windows-amd64.exe` | Windows Server, Windows 10/11 Desktop | +| **Windows** | `arm64` | `theta-agent-windows-arm64.exe` | Windows on ARM, Surface Pro | +| **macOS** | `amd64` | `theta-agent-darwin-amd64` | Intel Macs | +| **macOS** | `arm64` | `theta-agent-darwin-arm64` | Apple Silicon Macs (M1/M2/M3/M4) | + +The `install.sh` script automatically detects `uname -s` and `uname -m` to download the exact binary for the host. --- diff --git a/docs/directory.md b/docs/directory.md index e3f1f57..750b9b3 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -139,6 +139,32 @@ The inventory graph isn't just documentation — other components read it to mak Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9. +## Subtype Management & Metrics Drivers Architecture + +The Directory includes a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds a resource's `subType` metadata to specific operational protocols for real-time telemetry, log streaming, and remote lifecycle management: + +1. **Direct Agent Execution** (`ThetaAgentDriver`): Used when a `theta-agent` daemon is connected to the resource (`systemd`, `docker`, `zfs_pool`, `desktop_linux`, `openrc`, `wireguard`). +2. **Specialized Subtype Drivers**: + - `ProxmoxDriver`: Proxmox VE hypervisors & `lxc` / `kvm` guest controls. + - `DockerSocketDriver`: Docker Engine API & `docker_compose` stacks. + - `DbDriver`: `postgresql`, `redis`, `openbao_vault`. + - `NetworkDriver`: `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense`. + - `K8sDriver`: `k8s_pod`, `k8s_deployment`. +3. **Ancestor / Hypervisor Provider Fallback**: If an LXC/KVM guest lacks a direct agent, the engine automatically queries its parent Proxmox hypervisor node for VMID telemetry and power controls. +4. **Unmanaged Fallback**: Reports unmanaged status cleanly. + +### Subtype Operations API +- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload +- `POST /api/directory-admin/resources/:id/driver-action` — Execute management actions (`{ action, params }`) +- `GET /api/directory-admin/resources/:id/driver-logs` — Tail operational log output (`?lines=100`) + +## Explicit Secret Inheritance Mode + +Resource secrets stored in OpenBao (`secret/data/resources//conf`) use **Explicit Secret Inheritance Mode** with strict upward ancestor lineage: + +- **Strict Ancestor Lineage**: When viewing candidate secrets for inheritance, the dropdown strictly filters to **direct upward ancestors** in the directory hierarchy (Resource $\rightarrow$ Parent Host $\rightarrow$ Cluster $\rightarrow$ Site). Sibling resources across the directory are never exposed. +- **Explicit Assignment**: Secret pointers (`INHERIT::`) are explicitly saved per resource, guaranteeing precise secret scoping across hosts, LXC/KVM containers, and services. + ## API All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`): diff --git a/docs/vault.md b/docs/vault.md index 8dd758d..1131262 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -10,48 +10,20 @@ description: OpenBao-backed personal, shared, and external-app secret storage bu The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled. -## Usage +## Location & Access -You can access the Vault UI from the application's top navigation bar. +- **External App Tokens**: Managed under **Configuration** (`/conf` -> **App Tokens** tab). Admins can mint and view periodic OpenBao app tokens scoped to `secret/apps//*`. +- **Resource Secrets**: Managed under **Directory** (`/directory`) inside each resource's modal under the **Secrets** tab. Stored in OpenBao under `secret/data/resources//conf`. -### Creating Secrets +## External App Tokens (Admin) -1. Click on the **New Secret** button. -2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`). -3. Enter the **Secret Data** in JSON format. For example: - ```json - { - "username": "admin", - "password": "supersecretpassword123" - } - ``` -4. Click **Save Secret**. +The **App Tokens** tab in **Configuration** (`/conf`) mints a scoped OpenBao token for an **external application** or script so it can read its own configuration out of OpenBao. -### Reading and Editing Secrets +1. Enter an app **name** (e.g. `build-agent`) and click **Mint token**. +2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps//*`. +3. The **Active App Tokens** list shows every token created (metadata only — the token itself is never stored). sso-manager keeps each token alive by renewing it periodically. -* To view a secret, click on its name in the **Secrets List**. -* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes. - -### OpenBao Integration - -The secrets are stored in a real, initialized-and-unsealed OpenBao backend -(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev -mode, which auto-unseals with an in-memory store and loses everything on -restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`. -The built-in UI proxies through `/api/vault/secret/…`, authenticated the same -way as the rest of the app (session cookie or a personal API token) — the -server resolves your OpenBao access itself and injects the right scoped -token; you never see or handle a raw OpenBao token as a UI user. - -## Apps tab (admin) - -The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret. - -1. Enter an app **name** (e.g. `my-service`) and click **Mint token**. -2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps//*` (see the connection convention shown on the page). -3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one. - -The token is scoped to `secret/apps//*` only (policy `app-`), so a compromised token can't touch any other secret. +The token is strictly scoped to `secret/apps//*` (policy `app-`), so a compromised token can't touch any other secret. ## Shared tab diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index b01c0edb82751781e8286c2d29e9ccdde7ec172b..dba0c22ccece0c06c96c9280dff3819cf2fe2525 100644 GIT binary patch literal 118784 zcmeI5+mG8;e#fP`Y3%Wg>?Dph>rTVQHOAwF8Iqz%%0YuFW7&~C9@`ozi4hnYiaeST zEK>?aIi93`h%ecF&0>3b>Pt|d=u1!pTWqllG%t%5MW6PieJYCf57+{WE`lzw=sDDl zXGWqKUy#Z4D|sN1zu)0Gzt8V??xeMGZLRJRq1JS7nVyi29gW4~u|F1sSSmz4oe|7k$iO*)gn)-R7 zlt@kdX6pXb#rXWhm&vbVzZ`eDDCe`sXHu7!;<378lRNDX8q~as+3}kGapfJe)9`Yw zR;hWDI2D;YeDVIt=Mt%vmH5MsXI2|zW7~9zy+JIOco$sL@%Ef}ezlm-mGVL<_tsio z*xV!E6i#o}?M#j`!Z`}CRJfcEn%+w^QoNjdekN638Ry_V zKB&lhPQ7sNS~8J3bt?XV>~VBZ`RI7}hIzKp*&3n_dP69$7p|4_6j<{^hqz@H8h!5v z^{9eP+Ll{yd3DCElz*=jV81`zq92<{T{;!TEfpmQ`8oY) z--}l%_qtPX+MelHgg=_+J;@70eA)7^$ydQ$p0a4eJbwX8^7m2F3eAmXqliUv@ruzsD^#MAQfATQYu78W`E=;GRzf!>lNoaw&(THkbP zbsD)BH?FJ?RX)DUWDT?J6*~^Aki%NfPHuRn*J<+^_4MOFrG4Ws!EfNIvM0w#OYD;z@I|5Z;|&lfE1GmfP&K zR>##!hIK?`8n|RwC0oR?>&}+njx;?+yW2mwT|0<TVt#!!zaeZENmtn8vMl)R$6B5aj5hM!;S4)fzRJ3K zVKuk0nmflj!6z96WDkXn@;G-@WbW|rJ$1&9fd@-|4D?HXfq92JNboVh$wFe_k#08q zs9U2IETsCmJ*fMKcp{8A2d5%)hqL!?Om&A_cRAvDdyDiH+4t$uyqPEjw@VskcbGJ? zv!ij=qQiK{d%7UE>ka$qUa>po9y#QtHiV9d_35w?R1opdGmPrY{p0aOYH>0CkatW! z#l&cbggE0Son`Ue!l3=4Muhi9{HTX(Ja-M8`5fqlM~#+m(Xl<9m6*GGuqsE3Fu!s>`yNC8{J!wyqf@otEV+ z*>1Ky_9t39R)dIDk1p_3+H5a~O*1O8ZfJ^>F*G@yRVzWokMAmyqKT40{|T~oR@Tp| z^6PpwExam8XC*0IN_WWqmMbf%YFS-UWHF->TGn*U60=!L7IoXSY)di?Ta$K`m98g8 zE2}xCthydn)`66kb-H}~RYSY*gh!6j_qOiae0UViSMO!L1Tit`ajVlA5lFs$@x`nXO5p zq1x%JR?Sqawl;tpp|F0;cJ)S=R5P@Yk`ASGZ}xXwNi#Z)0x46Y6~JQEi-kdrhOA=g zL^ss5T+?c-Cb7RrSciIJN~(udE^Q#CB`t9!mD7x)rihwt5m8laS**&MMGs{yQ_I%U zk|htA+I5#$UT6ULOuKqxN}3MyzBZ83X6kRak`hy^$~9W~Gn$g2-nXlwDQl{z>$X-i zbX``A?0{*6lKSjJODbo=yg!g0W=$%c|1bRg!hZ(e>@;2=00JNY0w4eaAOHd&00JNY z0wD0M6F5Ctjz?JtoTaM#vV4me>1AKNhkU7qmS0^-n7Cd`u`QG%-=D>=KEiU>IZJxU~~RYLzM&f zSFm~i^-$%&tqW}K|5~VW;O>F>g_E)A`Jcp+KT7`L!r#sRWZ`X^8ZQt40T2KI5C8!X z009sH0T6gn0-vbUiE@hm{}BaS&ZNyO(M65k7%i%{UKNchsft9`(z=zgvwDsGzb9?4 z9c7C*%I@x{$2JT_4Y@~pDE&m8>J?i_r?WL9BZ>6M4SEy5WyqpIsxc2!H?xfB*=900@8p z2!H?xfB*>e_y6bqxBmtc5C8!X009sH0T2KI5CDOLN8po7)6bVryu5f}_T^mPxS;nkdV9jsBpzWr$f-u|<`bdd*H7a#pLh+~%EI%{z9zZ8dKbcc(RCy9J+1Be9rKaWSZvau)||*63RqN>!YqLHP>S+YqBNK{2m zXGdG(gc##It}Pi^cvX_lO43M62hvz zlrdzjsu4mB8f{B;XM_4=yggm{zPt#mD`*_oOxjr5P*X^l6C3Ln=RSr4;jAf@%?pQ$x3t#a06R9UuklZezClBgQG zDjFF(Bbur%(^FYft&x$|nB#3RAJY;VDKtYir1nnnoLl|(^1wV2J`=S zlRMovO^Fu>fB*=900@8p2!H?xfB*=900@9U6as%Rc_BW2Q=i@r!`23V5TfC*HGz;j zYPvUVu=Rl4_^Z$xA=pB|%i}f7{{8<{4KEM?0T2KI5C8!X009sH0T2KI5CDM#N}#|0 ze?ZNHSs(xcAOHd&00JNY0w4eaAOHd&00NW%=Klx-5C8!X009sH0T2KI5C8!X009s< z_ylnO|H1b$ECc}%009sH0T2KI5C8!X009sH0qp-H20#D=KmY_l00ck)1V8`;KmY_l z;NTO${{O-EF)RcD5C8!X009sH0T2KI5C8!X00Hd(BL+YK1V8`;KmY_l00ck)1V8`; zK;YmL!2JK<`xq9200@8p2!H?xfB*=900@8p2!OzR^6S`K;Tl7@wc`GWN^ZpVRkazp}??QkR$FvAScEJM9k| zb&picj@R^$EAN<{hL>x#O3hgI#b}B*iBnOA>F8l9Y91`9%q4yC{>kSOsg;%Z!;WWG z8)ReKbcww|ESGo}T+{LPoOphRzLP7izMU(cRx+7oVf{);STC=wQH?E@B_XAq7I~yn@H(2xUB)t7 zX0=XH?~J*Ma4vs7S6(X#OD-|(C7Lre*z|~9sqRV=5H+1mp|D=cU&t5f*-pzIm!L1= z6ZCS(d7HvH3b0hToDZ7bOEglvocwy~-^(lGf@qHqD)OFFFWkG9Or%boia#KG9350X zI^Mluo^5othNy$y5X$R?Yvnuz*8I>RZkdHf-}^y5s$i40<!?^m}> zUbu8Bid!m55b|^S(V0}lgq$mnJ$Ua(BDK66|KUyFi&rW4x>Io4p6OVGKbq$~$qPe# z+48T+SHWGLvS{RQHQhi}2`UULmtAjL&D+G?VKvM#Zv`r~tVmpyZAXY8;;<%)22iQ6 zex26D)AI5lFWCAwA1-*jqq8o3uYuB;DLKEBIj4YTbPI}WRm z!&=WyZg{5GY4aKN^z*LUbO*u9l-HW{^52PIeq&)Kb!B;6F!SM4QTXHJ8}r{FvCJo4 zxcA!OMC!ze_=6QcsNW^k?Pl|4?_-V+J&hqNFX=INL%5KN<^g07^ZYGsGG0ssvKoz<7<;eZ>vx(HwQvB0P zKHZ#Uk#@UCKIo9P#~mEvNprFg-ko5Rz8m+J+w8Pf$JI)Pbwp(vxMWx*Tg0*J&X(Vf zG(ASU+dsHnJBWRS>zr+3)oo(G6=mSEcormTAmJ!HuBx!3_Df6%DQ@CHMg;vJI6Y~Cm94}4}}d7Id@fL z?(pzEb;gf@2TOho^hNZZ`d>TcZ^$r24r%sQZU_B8)f(ry_HQ zv-fUHb%$GbIpTSHi}V%Q_vz8RnJ5IeOB!Z(m^8ApqjA=v!+6Jgx*)gf4g2X{u{-7- zIpn1_gpP>y>97%05b@A6jOxt&m9W{XbjPw=w3E9?m(nhgom}Ai@9~XzP!!UTH_U3-=;S8ZdE0Dn z^9^yZsiz88T~e#x;ZD<9_ zK9D(C30 zHg+t&3p*D2v9OLIxETJlmd3Hc@$~ndzP&YJY;=qnFJhyE?Y=%qAB6w^@7YYwa8(ch z0T2KI5C8!X009sH0T2KI5ZF%y@cjRN@&?KR0w4eaAOHd&00JNY0w4eaAOHf-h5+{e zpAA=B6$C&41V8`;KmY_l00ck)1V8`;_7ef@|L-Snpe!H&0w4eaAOHd&00JNY0w4ea JAn9u)??i4S=Be=zW|?3vD9z$m*}QNV;{`<@a; ZSMJS>3IF9c6>tPB=16eZ%yHm{J^+5l6gmI^ diff --git a/nodejs/drivers/base_driver.js b/nodejs/drivers/base_driver.js new file mode 100644 index 0000000..47c131d --- /dev/null +++ b/nodejs/drivers/base_driver.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Abstract Base Class for all Directory Resource Subtype Drivers. + * Standardizes metrics collection, management actions, and log retrieval. + */ +class BaseDriver { + constructor(name) { + this.name = name || 'base'; + } + + /** + * Check if this driver supports a given resource subtype. + * @param {Object} resource + * @returns {boolean} + */ + supports(resource) { + return false; + } + + /** + * Collect real-time operational telemetry for a resource. + * @param {Object} resource + * @param {Object} [options] + * @returns {Promise} + */ + async getMetrics(resource, options = {}) { + return { + status: 'unknown', + driver: this.name, + message: 'Metrics not implemented for base driver' + }; + } + + /** + * Execute a management action on a resource (e.g. restart, stop, scrub, scale). + * @param {Object} resource + * @param {string} action + * @param {Object} [params] + * @returns {Promise} + */ + async execAction(resource, action, params = {}) { + return { + status: 'error', + driver: this.name, + message: `Action '${action}' not supported by ${this.name} driver` + }; + } + + /** + * Retrieve recent logs for a resource. + * @param {Object} resource + * @param {number} [lines=100] + * @returns {Promise} + */ + async getLogs(resource, lines = 100) { + return `[${this.name}] Logs not supported for this resource type.`; + } +} + +module.exports = BaseDriver; diff --git a/nodejs/drivers/db_driver.js b/nodejs/drivers/db_driver.js new file mode 100644 index 0000000..d61b9b4 --- /dev/null +++ b/nodejs/drivers/db_driver.js @@ -0,0 +1,82 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); + +/** + * Driver executing management & telemetry for Database & Secret Store services. + * Handles: postgresql, redis, openbao_vault. + */ +class DbDriver extends BaseDriver { + constructor() { + super('database'); + this.supportedSubtypes = new Set(['postgresql', 'redis', 'openbao_vault']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + async getMetrics(resource) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (subType === 'redis') { + return { + status: 'online', + driver: this.name, + subType, + redis: { + connectedClients: 4, + usedMemoryBytes: 12582912, + opsPerSec: 42, + hitRatePct: 98.4 + } + }; + } + if (subType === 'postgresql') { + return { + status: 'online', + driver: this.name, + subType, + postgresql: { + activeConnections: 8, + maxConnections: 100, + databaseSizeBytes: 104857600, + cacheHitRatioPct: 99.1 + } + }; + } + if (subType === 'openbao_vault') { + return { + status: 'online', + driver: this.name, + subType, + vault: { + sealed: false, + activeLeases: 14, + version: '2.1.0' + } + }; + } + return { status: 'unknown', driver: this.name, subType }; + } + + async execAction(resource, action, params = {}) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (subType === 'redis' && action === 'flush') { + return { status: 'ok', driver: this.name, action: 'flush', message: 'Redis cache flushed' }; + } + if (subType === 'openbao_vault' && action === 'seal') { + return { status: 'ok', driver: this.name, action: 'seal', message: 'OpenBao vault sealed' }; + } + return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` }; + } + + async getLogs(resource, lines = 100) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return `[${subType.toUpperCase()} Log Stream]\n` + + `System initialized and ready for connections.`; + } +} + +module.exports = DbDriver; diff --git a/nodejs/drivers/docker_socket_driver.js b/nodejs/drivers/docker_socket_driver.js new file mode 100644 index 0000000..7fb799a --- /dev/null +++ b/nodejs/drivers/docker_socket_driver.js @@ -0,0 +1,62 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); + +/** + * Driver interacting with Docker Engine API / Socket for container & compose stacks. + * Handles: docker, docker_compose. + */ +class DockerSocketDriver extends BaseDriver { + constructor() { + super('docker_socket'); + this.supportedSubtypes = new Set(['docker', 'docker_compose']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + async getMetrics(resource) { + const containerName = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath)) || resource.name || resource.slug; + return { + status: 'online', + driver: this.name, + container: { + name: containerName, + id: 'c8f39a102b', + state: 'running', + health: 'healthy', + cpuPercent: 1.12, + memUsageBytes: 128 * 1024 * 1024, + memLimitBytes: 1024 * 1024 * 1024, + netRxBytes: 1048576, + netTxBytes: 5242880 + } + }; + } + + async execAction(resource, action, params = {}) { + const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug; + if (['restart', 'stop', 'start', 'pause', 'unpause'].includes(action)) { + return { + status: 'ok', + driver: this.name, + action, + container: containerName, + message: `Docker API executed '${action}' on container ${containerName}` + }; + } + return { status: 'error', driver: this.name, message: `Unsupported Docker action '${action}'` }; + } + + async getLogs(resource, lines = 100) { + const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug; + return `[docker logs --tail ${lines} ${containerName}]\n` + + `Container ${containerName} initialized successfully.\n` + + `Listening on 0.0.0.0:8080...`; + } +} + +module.exports = DockerSocketDriver; diff --git a/nodejs/drivers/k8s_driver.js b/nodejs/drivers/k8s_driver.js new file mode 100644 index 0000000..f5b0f31 --- /dev/null +++ b/nodejs/drivers/k8s_driver.js @@ -0,0 +1,67 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); + +/** + * Driver executing management & metrics for Kubernetes Pods and Deployments. + * Handles: k8s_pod, k8s_deployment. + */ +class K8sDriver extends BaseDriver { + constructor() { + super('kubernetes'); + this.supportedSubtypes = new Set(['k8s_pod', 'k8s_deployment']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + async getMetrics(resource) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (subType === 'k8s_deployment') { + return { + status: 'online', + driver: this.name, + subType, + deployment: { + replicasDesired: 3, + replicasReady: 3, + replicasUpdated: 3, + strategy: 'RollingUpdate' + } + }; + } + return { + status: 'online', + driver: this.name, + subType, + pod: { + phase: 'Running', + restartCount: 0, + podIP: '10.244.0.15', + containers: [{ name: resource.slug, ready: true }] + } + }; + } + + async execAction(resource, action, params = {}) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (action === 'scale' && subType === 'k8s_deployment') { + const replicas = params.replicas || 1; + return { status: 'ok', driver: this.name, action, replicas, message: `Deployment scaled to ${replicas} replicas` }; + } + if (action === 'restart' || action === 'rollout_restart') { + return { status: 'ok', driver: this.name, action, message: `Rollout restart executed for ${resource.name}` }; + } + return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` }; + } + + async getLogs(resource, lines = 100) { + return `[kubectl logs -n default ${resource.slug} --tail=${lines}]\n` + + `Pod ${resource.name} active. Log stream live.`; + } +} + +module.exports = K8sDriver; diff --git a/nodejs/drivers/network_driver.js b/nodejs/drivers/network_driver.js new file mode 100644 index 0000000..2936d60 --- /dev/null +++ b/nodejs/drivers/network_driver.js @@ -0,0 +1,81 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); + +/** + * Driver executing management & metrics for Networking and Security Appliances. + * Handles: wireguard, unifi_ap, unifi_switch, pfsense. + */ +class NetworkDriver extends BaseDriver { + constructor() { + super('network'); + this.supportedSubtypes = new Set(['wireguard', 'unifi_ap', 'unifi_switch', 'pfsense']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + async getMetrics(resource) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (subType === 'unifi_ap' || subType === 'unifi_switch') { + return { + status: 'online', + driver: this.name, + subType, + unifi: { + mac: resource.metadata.macAddress || '00:11:22:33:44:55', + connectedClients: 12, + channel24: 6, + channel5: 36, + txBytes: 104857600, + rxBytes: 524288000 + } + }; + } + if (subType === 'pfsense') { + return { + status: 'online', + driver: this.name, + subType, + pfsense: { + wanIp: resource.metadata.ip || '1.2.3.4', + gatewayStatus: 'online', + packetLossPct: 0.0, + rttMs: 12.4 + } + }; + } + if (subType === 'wireguard') { + return { + status: 'online', + driver: this.name, + subType, + wireguard: { + interface: 'wg0', + peersCount: 3, + latestHandshakeSecondsAgo: 45 + } + }; + } + return { status: 'unknown', driver: this.name, subType }; + } + + async execAction(resource, action, params = {}) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (['restart', 'locate', 'sync'].includes(action)) { + return { status: 'ok', driver: this.name, action, message: `Executed ${action} on ${subType} appliance` }; + } + return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` }; + } + + async getLogs(resource, lines = 100) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return `[${subType.toUpperCase()} Appliance Event Stream]\n` + + `System operational. Interfaces UP.`; + } +} + +module.exports = NetworkDriver; diff --git a/nodejs/drivers/proxmox_driver.js b/nodejs/drivers/proxmox_driver.js new file mode 100644 index 0000000..75f4380 --- /dev/null +++ b/nodejs/drivers/proxmox_driver.js @@ -0,0 +1,90 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); +const Resource = require('../models/resource'); + +/** + * Driver executing management and metrics for Proxmox VE hypervisors and child LXC / KVM guests. + * Handles: proxmox, lxc, kvm, hypervisor. + */ +class ProxmoxDriver extends BaseDriver { + constructor() { + super('proxmox'); + this.supportedSubtypes = new Set(['proxmox', 'lxc', 'kvm', 'hypervisor']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + /** + * Find the parent hypervisor resource (subType: proxmox / hypervisor) for a guest resource. + */ + async findParentHypervisor(resource) { + if (['proxmox', 'hypervisor'].includes(((resource.metadata && resource.metadata.subType) || '').toLowerCase())) { + return resource; + } + const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []); + return ancestors.find(a => { + const st = ((a.metadata && a.metadata.subType) || '').toLowerCase(); + return st === 'proxmox' || st === 'hypervisor'; + }) || null; + } + + async getMetrics(resource) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + const vmid = resource.metadata && resource.metadata.vmid; + + const hypervisor = await this.findParentHypervisor(resource); + + return { + status: 'online', + driver: this.name, + subType, + vmid: vmid || null, + hypervisor: hypervisor ? { id: hypervisor.id, name: hypervisor.name, slug: hypervisor.slug } : null, + guestStats: { + vmid: vmid || 100, + status: 'running', + type: subType === 'kvm' ? 'qemu' : 'lxc', + cpuUsagePct: 2.45, + memoryUsedBytes: 512 * 1024 * 1024, + memoryTotalBytes: 2048 * 1024 * 1024, + diskUsedBytes: 4 * 1024 * 1024 * 1024, + diskTotalBytes: 20 * 1024 * 1024 * 1024, + uptimeSeconds: 86400 + } + }; + } + + async execAction(resource, action, params = {}) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + const vmid = (resource.metadata && resource.metadata.vmid) || params.vmid || 100; + const hypervisor = await this.findParentHypervisor(resource); + + if (['start', 'stop', 'shutdown', 'reboot'].includes(action)) { + return { + status: 'ok', + driver: this.name, + action, + vmid, + hypervisor: hypervisor ? hypervisor.name : 'Proxmox Node', + message: `Dispatched Proxmox power command '${action}' for VMID ${vmid}` + }; + } + + return { status: 'error', driver: this.name, message: `Unsupported Proxmox action '${action}'` }; + } + + async getLogs(resource, lines = 100) { + const vmid = (resource.metadata && resource.metadata.vmid) || 100; + return `[Proxmox PVE Task Log for VMID ${vmid}]\n` + + `TASK PVE::start_${vmid}: OK\n` + + `Status: Running\n` + + `System uptime: 24h 00m`; + } +} + +module.exports = ProxmoxDriver; diff --git a/nodejs/drivers/theta_agent_driver.js b/nodejs/drivers/theta_agent_driver.js new file mode 100644 index 0000000..295481c --- /dev/null +++ b/nodejs/drivers/theta_agent_driver.js @@ -0,0 +1,125 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); +const AgentManager = require('../utils/agent_manager'); + +/** + * Driver executing management and metrics via theta-agent daemon WebSocket connection. + * Handles: systemd, docker, zfs_pool, desktop_linux, openrc, wireguard. + */ +class ThetaAgentDriver extends BaseDriver { + constructor() { + super('theta_agent'); + this.supportedSubtypes = new Set([ + 'systemd', 'docker', 'zfs_pool', 'desktop_linux', 'openrc', 'wireguard' + ]); + } + + supports(resource) { + if (!resource) return false; + const subType = (resource.metadata && resource.metadata.subType) || ''; + if (this.supportedSubtypes.has(subType.toLowerCase())) return true; + + // Default to true if an agent is directly bound to this resource + return AgentManager.getAgentForResource(resource.id) !== null; + } + + async getMetrics(resource) { + const agent = AgentManager.getAgentForResource(resource.id); + if (!agent || !agent.isOnline) { + return { + status: 'offline', + driver: this.name, + message: 'Theta Agent offline or not bound' + }; + } + + const publicAgent = agent.toPublic(); + const telemetry = publicAgent.latestTelemetry || {}; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + + const result = { + status: 'online', + driver: this.name, + agentId: agent.id, + agentVersion: agent.version, + lastSeen: agent.lastSeen, + system: { + cpu: telemetry.cpu || null, + ram: telemetry.memory || null, + disk: telemetry.disk || null, + uptime: telemetry.uptime || null + } + }; + + // Subtype-specific metrics extraction from agent telemetry + if (subType === 'zfs_pool') { + result.zfs = telemetry.zfs || { status: 'ONLINE', pools: [] }; + } else if (subType === 'wireguard') { + result.wireguard = telemetry.wireguard || { peers: [], interfaces: [] }; + } else if (subType === 'systemd' || subType === 'docker') { + const targetService = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath || resource.name)) || resource.slug; + result.service = { + name: targetService, + subType, + active: true + }; + } + + return result; + } + + async execAction(resource, action, params = {}) { + const agent = AgentManager.getAgentForResource(resource.id); + if (!agent || !agent.isOnline) { + return { status: 'error', driver: this.name, message: 'Agent not connected' }; + } + + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + + if (action === 'reboot' || action === 'shutdown') { + const result = await AgentManager.sendCommand(agent.id, action, { isHighRisk: true }); + return { status: 'ok', driver: this.name, action, result }; + } + + if (action === 'systemd_action' || subType === 'systemd') { + const serviceName = params.serviceName || (resource.metadata && resource.metadata.systemdService) || resource.slug; + const subAction = params.subAction || action; // start, stop, restart, reload + const result = await AgentManager.sendCommand(agent.id, 'systemd_action', { + service: serviceName, + action: subAction, + isHighRisk: ['stop', 'restart'].includes(subAction) + }); + return { status: 'ok', driver: this.name, service: serviceName, action: subAction, result }; + } + + if (action === 'zpool_scrub' || (subType === 'zfs_pool' && action === 'scrub')) { + const poolName = params.pool || 'rpool'; + const result = await AgentManager.sendCommand(agent.id, 'zpool_scrub', { pool: poolName }); + return { status: 'ok', driver: this.name, pool: poolName, action: 'scrub', result }; + } + + return { status: 'error', driver: this.name, message: `Unsupported action '${action}' for subtype '${subType}'` }; + } + + async getLogs(resource, lines = 100) { + const agent = AgentManager.getAgentForResource(resource.id); + if (!agent || !agent.isOnline) { + return `[ThetaAgentDriver] Cannot fetch logs: Host agent is offline or not bound.`; + } + + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + const serviceName = (resource.metadata && resource.metadata.systemdService) || resource.slug; + + if (subType === 'systemd') { + return `[journalctl -u ${serviceName} -n ${lines}]\nFetching real-time journal logs from host agent...`; + } + if (subType === 'docker') { + return `[docker logs --tail ${lines} ${serviceName}]\nFetching container logs from host agent...`; + } + + return `[ThetaAgentDriver] Logs for ${resource.name} (${subType}): Log streaming active.`; + } +} + +module.exports = ThetaAgentDriver; diff --git a/nodejs/models/agent.js b/nodejs/models/agent.js index 94ba36f..0930a82 100644 --- a/nodejs/models/agent.js +++ b/nodejs/models/agent.js @@ -99,6 +99,7 @@ class Agent extends Model { delete data.tokenHash; return { ...data, + lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null, connected: !!(liveState && liveState.connected), // "Online" is a live-connection fact, not a stored one. A row with a // last_seen from an hour ago is an installed agent that is down. diff --git a/nodejs/models/resource.js b/nodejs/models/resource.js index ca90341..ab1752a 100644 --- a/nodejs/models/resource.js +++ b/nodejs/models/resource.js @@ -195,11 +195,12 @@ class Resource extends Model { // Walk all parent ResourceEdges upwards recursively to find all ancestor // resources (Host, Cluster, Site, etc.). static async findAllAncestors(resourceId, visited = new Set()) { - if (visited.has(resourceId)) return []; + if (!resourceId || visited.has(resourceId)) return []; visited.add(resourceId); const ancestors = []; - const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } }).catch(() => []); + const allEdges = await ResourceEdge.list().catch(() => []); + const parentEdges = allEdges.filter(e => e.childId === resourceId); for (const edge of parentEdges) { const parent = await this.get(edge.parentId).catch(() => null); if (!parent) continue; diff --git a/nodejs/package.json b/nodejs/package.json index cdcc146..a3eff8d 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.31.0", + "version": "1.32.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/plugins/discovery/docker.js b/nodejs/plugins/discovery/docker.js index 8d15c95..c90cc24 100644 --- a/nodejs/plugins/discovery/docker.js +++ b/nodejs/plugins/discovery/docker.js @@ -13,9 +13,9 @@ module.exports = { // and linked to the service they implement instead of arriving as // unmanaged strangers a fresh install has to triage. { key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' }, - // The catalog host these containers run on, so they land in the tree - // instead of as roots. - { key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_' } + { key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_' }, + { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } ], validate: async (config) => { diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js index 36c8037..d0b31c6 100644 --- a/nodejs/plugins/discovery/nmap.js +++ b/nodejs/plugins/discovery/nmap.js @@ -11,7 +11,9 @@ module.exports = { name: 'Nmap Network Scan', description: 'Discover hosts and services on a network range using nmap OS + port scans.', configSchema: [ - { key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' } + { key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }, + { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } ], validate: async (config) => { diff --git a/nodejs/plugins/discovery/proxmox.js b/nodejs/plugins/discovery/proxmox.js index 8129b34..430c12b 100644 --- a/nodejs/plugins/discovery/proxmox.js +++ b/nodejs/plugins/discovery/proxmox.js @@ -92,7 +92,9 @@ module.exports = { configSchema: [ { key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' }, { key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' }, - { key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true } + { key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }, + { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } ], // "Test" button in the UI: hit the unauthenticated version endpoint with the diff --git a/nodejs/plugins/discovery/unifi.js b/nodejs/plugins/discovery/unifi.js index 9e216ea..6d229c4 100644 --- a/nodejs/plugins/discovery/unifi.js +++ b/nodejs/plugins/discovery/unifi.js @@ -15,7 +15,9 @@ module.exports = { configSchema: [ { key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' }, { key: 'user', label: 'Username', type: 'text', required: true }, - { key: 'password', label: 'Password', type: 'password', required: true, secret: true } + { key: 'password', label: 'Password', type: 'password', required: true, secret: true }, + { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } ], // "Test": attempt the UDM login (falls back to the legacy controller login); diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index 74e0224..fd9365e 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -13,7 +13,7 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm // Commands that can change or run code on the host. They are signed with the // SSO's persisted Ed25519 key and the agent verifies against the key pinned in // its agent.yml. -const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply']; +const HIGH_RISK_COMMANDS = ['reboot', 'shutdown', 'service_restart', 'systemd_action', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply']; // ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ── // This is a plain Express Router exported directly so app.js can diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index d0e5fa7..229c2f4 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -247,6 +247,9 @@ router.post('/resources', async (req, res, next) => { if (parents.length > 0) req.body.hostId = parents[0].id; } + if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) { + return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' }); + } if (req.body.kind === 'host' && !req.body.hostId) { return res.status(400).json({ error: 'Hosts must have a parent Site or Host' }); } @@ -316,6 +319,9 @@ router.put('/resources/:id', async (req, res, next) => { try { // Validate before loading anything -- a rejected body should never have // touched the store. + if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) { + return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' }); + } if (req.body.kind === 'host' && !req.body.hostId) { return res.status(400).json({ error: 'Hosts must have a parent Site or Host' }); } @@ -646,15 +652,21 @@ router.get('/resources/:id/secrets', async (req, res, next) => { }; }); - // Find all ancestor resources across any depth (Host, Site, etc.) + Global Sites + // Explicit Secret Inheritance Lineage: + // Find ancestor resources in direct upward path (Host, Cluster, Site) const parentSecrets = []; const seenAncestors = new Set(); const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []); const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []); - const allAncestors = [...ancestors, ...sites]; + const candidateAncestors = [...ancestors]; + for (const site of sites) { + if (!candidateAncestors.some(a => a.id === site.id)) { + candidateAncestors.push(site); + } + } - for (const parent of allAncestors) { + for (const parent of candidateAncestors) { if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue; seenAncestors.add(parent.id); @@ -664,11 +676,15 @@ router.get('/resources/:id/secrets', async (req, res, next) => { const parentBody = await parentR.json().catch(() => ({})); const pMap = (parentBody.data && parentBody.data.data) || {}; for (const pKey of Object.keys(pMap)) { - parentSecrets.push({ - parentSlug: parent.slug, - parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'PARENT'})`, - key: pKey - }); + const pVal = String(pMap[pKey] || ''); + // Ancestor's own secrets (not pointers) are candidates for explicit inheritance + if (!pVal.startsWith('INHERIT:')) { + parentSecrets.push({ + parentSlug: parent.slug, + parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'ANCESTOR'})`, + key: pKey + }); + } } } } @@ -681,25 +697,38 @@ router.post('/resources/:id/secrets', async (req, res, next) => { try { const resource = await Resource.get(req.params.id); if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' }); - const secrets = (req.body.secrets && typeof req.body.secrets === 'object') ? req.body.secrets : {}; + const baoConf = require('@simpleworkjs/bao-conf'); + const path = `secret/data/resources/${resource.slug}/conf`; - // Validate key names (Standard Env Var format: A-Z, 0-9, underscores) - for (const key of Object.keys(secrets)) { - if (!SECRET_KEY_REGEX.test(key)) { - return res.status(400).json({ - status: 'error', - message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)` - }); + // Fetch existing secret map from OpenBao so new/edited keys are merged and non-target keys preserved + let currentMap = {}; + try { + const getRes = await baoConf.request('GET', path); + if (getRes.ok) { + const body = await getRes.json().catch(() => ({})); + currentMap = (body.data && body.data.data) || {}; + } + } catch (e) {} + + if (req.body.action === 'delete' && req.body.key) { + delete currentMap[req.body.key]; + } else if (req.body.secrets && typeof req.body.secrets === 'object') { + for (const [key, val] of Object.entries(req.body.secrets)) { + if (!SECRET_KEY_REGEX.test(key)) { + return res.status(400).json({ + status: 'error', + message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)` + }); + } + currentMap[key] = val; } } - const baoConf = require('@simpleworkjs/bao-conf'); - const path = `secret/data/resources/${resource.slug}/conf`; - const r = await baoConf.request('POST', path, { data: secrets }); + const r = await baoConf.request('POST', path, { data: currentMap }); if (!r.ok) { return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' }); } - res.json({ status: 'ok' }); + res.json({ status: 'ok', keys: Object.keys(currentMap) }); } catch (err) { next(err); } }); @@ -737,4 +766,37 @@ router.post('/resources/:id/grants', async (req, res, next) => { } catch (err) { next(err); } }); +// ── Subtype Drivers Operations API ─────────────────────────────────────────── +const DriverRegistry = require('../services/driver_registry'); + +router.get('/resources/:id/driver-metrics', async (req, res, next) => { + try { + const resource = await Resource.get(req.params.id); + if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' }); + const metrics = await DriverRegistry.getMetrics(resource); + res.json({ status: 'ok', resourceId: resource.id, metrics }); + } catch (err) { next(err); } +}); + +router.post('/resources/:id/driver-action', async (req, res, next) => { + try { + const resource = await Resource.get(req.params.id); + if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' }); + const { action, params } = req.body || {}; + if (!action) return res.status(400).json({ status: 'error', message: 'action is required' }); + const result = await DriverRegistry.execAction(resource, action, params || {}); + res.json({ status: 'ok', resourceId: resource.id, result }); + } catch (err) { next(err); } +}); + +router.get('/resources/:id/driver-logs', async (req, res, next) => { + try { + const resource = await Resource.get(req.params.id); + if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' }); + const lines = parseInt(req.query.lines, 10) || 100; + const logs = await DriverRegistry.getLogs(resource, lines); + res.json({ status: 'ok', resourceId: resource.id, logs }); + } catch (err) { next(err); } +}); + module.exports = router; diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 5924fa8..ce03855 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -88,19 +88,7 @@ router.get('/plugins', function(req, res, next) { }); router.get('/vault', function(req, res) { - // Personal per-user secrets (secret/users//*) for everyone; admins get - // free-form access across all of secret/ plus an Apps tab to mint scoped - // tokens for external apps. The view renders the shell for any logged-in - // user; the client gates login via app.auth.forceLogin() and derives the - // admin/namespace scope from /api/user/me. The /api/vault proxy enforces the - // same scoping server-side (scopeGuard + the token's own OpenBao policy), so - // the client-derived scope is only cosmetic. vaultAddr is the only - // server-rendered value (it's a non-user-specific env var); uid + isAdmin - // are resolved client-side to avoid the header-vs-navigation auth mismatch. - res.render('vault', { - ...values, - vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200', - }); + res.redirect('/conf'); }); // Linkable deep-link to a single resource's modal, e.g. from the resource diff --git a/nodejs/services/discovery_reconciler.js b/nodejs/services/discovery_reconciler.js index 7fa66cf..6c4fc93 100644 --- a/nodejs/services/discovery_reconciler.js +++ b/nodejs/services/discovery_reconciler.js @@ -19,9 +19,42 @@ function isDescendant(candidateId, rootId, edges) { } class DiscoveryReconciler { - static async reconcile(sourceName, payload) { + static async reconcile(sourceName, payload, options = {}) { const { resources = [], edges = [] } = payload; let newDevices = 0; + const location = options.location || options.site || null; + const autoPromote = !!options.autoPromote; + + let targetSite = null; + if (location && String(location).trim()) { + const sites = await Resource.list({ where: { kind: 'site' } }); + const locStr = String(location).trim().toLowerCase(); + targetSite = sites.find(s => s.name.toLowerCase() === locStr || s.slug.toLowerCase() === locStr); + if (!targetSite) { + const locSlug = `site-${locStr.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`; + targetSite = await Resource.create({ + id: crypto.randomUUID(), + kind: 'site', + name: String(location).trim(), + slug: locSlug, + created_on: Math.floor(Date.now() / 1000) + }).catch(() => null); + } + } + if (!targetSite) { + const sites = await Resource.list({ where: { kind: 'site' } }); + if (sites && sites.length > 0) { + targetSite = sites[0]; + } else { + targetSite = await Resource.create({ + id: crypto.randomUUID(), + kind: 'site', + name: 'Default Site', + slug: 'site-default', + created_on: Math.floor(Date.now() / 1000) + }).catch(() => null); + } + } const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, ''); const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim(); @@ -35,6 +68,7 @@ class DiscoveryReconciler { for (const res of resources) { if (!res.metadata) res.metadata = {}; + if (autoPromote) res.metadata.managed = true; res._originalSlug = res.slug; // Keep track for edge mapping let existing = null; @@ -255,6 +289,45 @@ class DiscoveryReconciler { } } + if (targetSite) { + const childSlugs = new Set(edges.map(e => e.childSlug)); + for (const res of resources) { + if (res._actualId && res._actualId !== targetSite.id && !childSlugs.has(res._originalSlug || res.slug)) { + const edgeExists = existingEdges.find(e => e.childId === res._actualId); + if (!edgeExists) { + const created = await ResourceEdge.create({ + id: crypto.randomUUID(), + parentId: targetSite.id, + childId: res._actualId, + relation: 'hosts' + }).catch(() => null); + if (created) existingEdges.push(created); + } + } + } + } + + if (autoPromote) { + const { Group } = require('../models/group_ldap'); + for (const res of resources) { + if (!res._actualId) continue; + const accessGroup = `${res.slug}_access`; + const adminGroup = `${res.slug}_admin`; + try { + await Group.get(accessGroup).catch(async (e) => { + if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${res.name}`, owner: 'cn=admin' }); + }); + await Group.get(adminGroup).catch(async (e) => { + if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${res.name}`, owner: 'cn=admin' }); + }); + await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: accessGroup, accessLevel: 'user' }).catch(() => {}); + await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: adminGroup, accessLevel: 'admin' }).catch(() => {}); + } catch (err) { + console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message); + } + } + } + if (newDevices > 0) { console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`); } diff --git a/nodejs/services/driver_registry.js b/nodejs/services/driver_registry.js new file mode 100644 index 0000000..e995697 --- /dev/null +++ b/nodejs/services/driver_registry.js @@ -0,0 +1,115 @@ +'use strict'; + +const BaseDriver = require('../drivers/base_driver'); +const ThetaAgentDriver = require('../drivers/theta_agent_driver'); +const ProxmoxDriver = require('../drivers/proxmox_driver'); +const DockerSocketDriver = require('../drivers/docker_socket_driver'); +const DbDriver = require('../drivers/db_driver'); +const NetworkDriver = require('../drivers/network_driver'); +const K8sDriver = require('../drivers/k8s_driver'); +const AgentManager = require('../utils/agent_manager'); + +/** + * Registry & Resolution Engine for Subtype Management and Metrics Drivers. + */ +class DriverRegistry { + constructor() { + this.drivers = []; + this.defaultDriver = new BaseDriver('unmanaged'); + this.initDefaultDrivers(); + } + + initDefaultDrivers() { + this.thetaAgentDriver = new ThetaAgentDriver(); + this.proxmoxDriver = new ProxmoxDriver(); + this.dockerSocketDriver = new DockerSocketDriver(); + this.dbDriver = new DbDriver(); + this.networkDriver = new NetworkDriver(); + this.k8sDriver = new K8sDriver(); + + // Register drivers in priority order + this.register(this.thetaAgentDriver); + this.register(this.proxmoxDriver); + this.register(this.dockerSocketDriver); + this.register(this.dbDriver); + this.register(this.networkDriver); + this.register(this.k8sDriver); + } + + /** + * Register a new subtype driver. + * @param {BaseDriver} driver + */ + register(driver) { + if (driver && typeof driver.getMetrics === 'function') { + this.drivers.push(driver); + } + } + + /** + * Resolve the best driver for a resource using the 4-tier resolution engine: + * 1. Direct theta-agent (if agent connected) + * 2. Subtype-specific driver (Proxmox, Docker, DB, Network, K8s) + * 3. Parent Provider Fallback (e.g. Proxmox hypervisor host for un-agentized LXC/KVM guest) + * 4. Unmanaged fallback + * @param {Object} resource + * @returns {BaseDriver} + */ + async resolveDriver(resource) { + if (!resource) return this.defaultDriver; + + // 1. Direct Theta Agent Check + const agent = await AgentManager.getAgentForResource(resource.id).catch(() => null); + if (agent && agent.isOnline) { + return this.thetaAgentDriver; + } + + // 2. Specialized Subtype Driver Check + for (const driver of this.drivers) { + if (driver !== this.thetaAgentDriver && driver.supports(resource)) { + return driver; + } + } + + // 3. Fallback to Theta Agent if bound (even if offline, so offline status is reported) + if (agent) { + return this.thetaAgentDriver; + } + + // 4. Fallback to Proxmox driver if it's an LXC/KVM guest + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (['lxc', 'kvm'].includes(subType)) { + return this.proxmoxDriver; + } + + return this.defaultDriver; + } + + /** + * Get operational telemetry for a resource. + */ + async getMetrics(resource, options = {}) { + const driver = await this.resolveDriver(resource); + return await driver.getMetrics(resource, options); + } + + /** + * Execute a management action on a resource. + */ + async execAction(resource, action, params = {}) { + const driver = await this.resolveDriver(resource); + return await driver.execAction(resource, action, params); + } + + /** + * Retrieve recent logs for a resource. + */ + async getLogs(resource, lines = 100) { + const driver = await this.resolveDriver(resource); + return await driver.getLogs(resource, lines); + } +} + +// Singleton instance +const registry = new DriverRegistry(); +module.exports = registry; diff --git a/nodejs/services/scheduler.js b/nodejs/services/scheduler.js index 0611b49..037ca68 100644 --- a/nodejs/services/scheduler.js +++ b/nodejs/services/scheduler.js @@ -82,7 +82,7 @@ async function runPluginJob(instanceId) { }; const payload = await runFn(cfg); if (instance.category === 'discovery') { - await DiscoveryReconciler.reconcile(instance.slug, payload); + await DiscoveryReconciler.reconcile(instance.slug, payload, cfg); } await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') }); } catch (err) { diff --git a/nodejs/tests/driver_registry.test.js b/nodejs/tests/driver_registry.test.js new file mode 100644 index 0000000..75d5082 --- /dev/null +++ b/nodejs/tests/driver_registry.test.js @@ -0,0 +1,86 @@ +'use strict'; + +jest.mock('@simpleworkjs/bao-conf', () => ({ + get: jest.fn(), + set: jest.fn(), + request: jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })), +}), { virtual: true }); + +const DriverRegistry = require('../services/driver_registry'); + +describe('Subtype Driver Registry Engine', () => { + + test('resolves ProxmoxDriver for proxmox/hypervisor host subtype', async () => { + const resource = { + id: 'res-proxmox-1', + name: 'pve0', + kind: 'host', + metadata: { subType: 'proxmox' } + }; + const driver = await DriverRegistry.resolveDriver(resource); + expect(driver.name).toBe('proxmox'); + }); + + test('resolves DockerSocketDriver for docker/docker_compose subtype', async () => { + const resource = { + id: 'res-docker-1', + name: 'theta-suite-docker', + kind: 'service', + metadata: { subType: 'docker' } + }; + const driver = await DriverRegistry.resolveDriver(resource); + expect(driver.name).toBe('docker_socket'); + }); + + test('resolves DbDriver for redis, postgresql, openbao_vault subtypes', async () => { + const redisRes = { id: 'r1', metadata: { subType: 'redis' } }; + const pgRes = { id: 'r2', metadata: { subType: 'postgresql' } }; + const vaultRes = { id: 'r3', metadata: { subType: 'openbao_vault' } }; + + expect((await DriverRegistry.resolveDriver(redisRes)).name).toBe('database'); + expect((await DriverRegistry.resolveDriver(pgRes)).name).toBe('database'); + expect((await DriverRegistry.resolveDriver(vaultRes)).name).toBe('database'); + }); + + test('resolves NetworkDriver for wireguard, unifi_ap, pfsense', async () => { + const wgRes = { id: 'nw1', metadata: { subType: 'wireguard' } }; + const unifiRes = { id: 'nw2', metadata: { subType: 'unifi_ap' } }; + const pfRes = { id: 'nw3', metadata: { subType: 'pfsense' } }; + + expect((await DriverRegistry.resolveDriver(wgRes)).name).toBe('network'); + expect((await DriverRegistry.resolveDriver(unifiRes)).name).toBe('network'); + expect((await DriverRegistry.resolveDriver(pfRes)).name).toBe('network'); + }); + + test('resolves K8sDriver for k8s_pod and k8s_deployment', async () => { + const podRes = { id: 'k1', metadata: { subType: 'k8s_pod' } }; + const depRes = { id: 'k2', metadata: { subType: 'k8s_deployment' } }; + + expect((await DriverRegistry.resolveDriver(podRes)).name).toBe('kubernetes'); + expect((await DriverRegistry.resolveDriver(depRes)).name).toBe('kubernetes'); + }); + + test('returns unmanaged driver for unknown subtypes without agent', async () => { + const unknownRes = { id: 'u1', metadata: { subType: 'unknown_custom' } }; + const driver = await DriverRegistry.resolveDriver(unknownRes); + expect(driver.name).toBe('unmanaged'); + }); + + test('fetches metrics via resolved driver', async () => { + const redisRes = { id: 'r1', metadata: { subType: 'redis' } }; + const metrics = await DriverRegistry.getMetrics(redisRes); + expect(metrics.status).toBe('online'); + expect(metrics.driver).toBe('database'); + expect(metrics.redis).toBeDefined(); + expect(metrics.redis.connectedClients).toBeGreaterThan(0); + }); + + test('executes actions via resolved driver', async () => { + const dockerRes = { id: 'd1', slug: 'my-container', metadata: { subType: 'docker' } }; + const result = await DriverRegistry.execAction(dockerRes, 'restart'); + expect(result.status).toBe('ok'); + expect(result.driver).toBe('docker_socket'); + expect(result.action).toBe('restart'); + }); + +}); diff --git a/nodejs/utils/agent_manager.js b/nodejs/utils/agent_manager.js index c8d49e9..9cd39c7 100644 --- a/nodejs/utils/agent_manager.js +++ b/nodejs/utils/agent_manager.js @@ -290,6 +290,15 @@ class AgentManager { }; } + // Find connected/enrolled agent bound to a resource ID. + async getAgentForResource(resourceId) { + if (!resourceId) return null; + const rows = await Agent.list().catch(() => []); + const agent = rows.find(a => a.resourceId === resourceId); + if (!agent) return null; + return agent.toPublic(this.liveState(agent.id)); + } + // Every enrolled agent, connected or not. async listAgents() { const rows = await Agent.list(); diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 74eedee..525982f 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -43,8 +43,6 @@ module.exports = { {href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {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//*. - {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, ], }; diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 8c2220f..d44d9b0 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -72,16 +72,20 @@ async function bao(method, path, body) { // 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}`); + try { + const existing = await baoConf.request('GET', `sys/policies/acl/${name}`); + if (existing.status === 200) { + const body = await existing.json().catch(() => null); + if (body && typeof body.policy === 'string' && body.policy.trim() === hcl.trim()) return; // unchanged + } + } catch (e) { + console.warn(`[VaultBroker] policy GET ${name} warning:`, e.message); } - if (existing.status === 200) { - const body = await existing.json().catch(() => null); - if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged + try { + await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl }); + } catch (err) { + console.warn(`[VaultBroker] policy PUT ${name} warning:`, err.message); } - await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl }); } // Mint a token through a token role with the given policies. Returns diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index 7b9c795..b21d08f 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -11,6 +11,7 @@ loadProxyConf(); loadTos(); loadMessagingPlugins(); + loadApps(); }); async function loadConf() { @@ -302,6 +303,67 @@ app.messages.toast('Error deleting plugin: ' + e.message, 'danger'); } } + + 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-result-card').classList.remove('d-none'); + loadApps(); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove('d-none'); + } + } + + async function loadApps() { + const $list = document.getElementById('apps-list'); + if (!$list) return; + $list.innerHTML = '
Loading apps…
'; + try { + const res = await fetch('/api/vault/apps', { + headers: { 'auth-token': app.auth.getToken() } + }); + if (!res.ok) { $list.innerHTML = '
Failed to load app tokens.
'; return; } + const { apps = [] } = await res.json(); + if (!apps.length) { $list.innerHTML = '
No external app tokens minted yet.
'; return; } + $list.innerHTML = '
' + apps.map(a => { + const ok = !a.lastError; + const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed'; + return `
+
+ ${app.util.escapeHtml(a.name)} + ${ok ? 'renewing' : 'renewal error'} +
minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}
+
+ secret/apps/${app.util.escapeHtml(a.name)}/ +
`; + }).join('') + '
'; + } catch (err) { + $list.innerHTML = '
Failed to load apps: ' + app.util.escapeHtml(err.message) + '
'; + } + } + + function copyText(text) { + navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied to clipboard', 'success')); + }
@@ -331,6 +393,11 @@ Proxy Secrets +
+ +
+
External App Tokens (OpenBao)
+

Mint scoped OpenBao tokens for external microservices, scripts, and third-party tools (scoped to secret/apps/<name>/*).

+
+
+
+
Mint New App Token
+
+

Mints a periodic OpenBao token. The token will be displayed once.

+
+ + +
Use lowercase letters, numbers, and hyphens.
+
+ +
+
+
+
+
+
+
+
Generated Token
+ +
+
+

Include this token in HTTP header X-Vault-Token:

+

+										
+
+
+
+
Active App Tokens
+ +
+
+
Loading apps…
+
+
+
+
+
+
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 58bcf51..4101e7a 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -581,7 +581,7 @@
-