From 4fb4e77007ba3b1565d2e36ff1ff9f2eaddcd655 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sun, 26 Jul 2026 23:11:23 -0400 Subject: [PATCH] Dashboard: list hosts you can reach; bump @simpleworkjs/ldap to 1.0.1 - New GET /api/user/hosts (auth-only): all hosts for admins, group-filtered list for everyone else. - accessibleHosts() accepts a pre-resolved user.groups, so the web UI's OIDC session skips a redundant LDAP getGroups(dn) call. - Dashboard shows a "Hosts you can reach" / "All hosts" table. - @simpleworkjs/ldap 1.0.1 fixes addSshKey's ObjectClassViolationError on accounts predating the ldapPublicKey objectClass -- was aborting key injection (and the SSH connection) on affected accounts. - Bump to 1.5.0. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 +++++++++ nodejs/package-lock.json | 12 ++++++------ nodejs/package.json | 6 +++--- nodejs/public/js/app.js | 3 ++- nodejs/routes/user.js | 13 +++++++++++++ nodejs/test/unit/access.test.js | 28 +++++++++++++++++++++++++++- nodejs/utils/access.js | 19 +++++++++++++++---- nodejs/views/dashboard.ejs | 27 ++++++++++++++++++++++++++- 8 files changed, 101 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b00630..872cc6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. +## [1.5.0] - 2026-07-27 + +### Added +- **Web UI dashboard now lists the hosts you can reach** ("Hosts you can reach", or "All hosts" for admins) — previously the dashboard only showed usage metrics, with no way to see your actual access from the browser. Backed by a new `GET /api/user/hosts` endpoint (auth-only, not admin-gated): admins get the full inventory via `utils/access.js`'s new `allHosts()`, everyone else gets the same group-based resolution the SSH front door uses. +- `utils/access.js`'s `accessibleHosts()` now accepts a pre-resolved `groups` array on the user object, skipping the LDAP `getGroups(dn)` round-trip — the web UI's OIDC session already has its groups claim and has no LDAP `dn` to query with. + +### Fixed +- **Bumped `@simpleworkjs/ldap` to 1.0.1**, which fixes `addSshKey` throwing `ObjectClassViolationError` (LDAP `0x41`) on accounts predating the `ldapPublicKey` auxiliary objectClass. This is the code path this jump host's key-injection (`utils/key_inject.js`) uses on every first connection for a user — on affected accounts it aborted the SSH connection entirely (`key-inject-failed`). + ## [1.4.0] - 2026-07-26 ### Added diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index db49238..4e82cec 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,19 +1,19 @@ { "name": "t42-jump-host", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-jump-host", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.0.0", - "@simpleworkjs/ldap": "^1.0.0", + "@simpleworkjs/ldap": "^1.0.1", "@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", @@ -177,9 +177,9 @@ } }, "node_modules/@simpleworkjs/ldap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz", - "integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.1.tgz", + "integrity": "sha512-1jz3WQ9ghwNHz2mI+H33qw8nIQpLkoNHEy8lgdgsE/nCLK8JGU1SIKEdbsdMeXEQ0seKqyjuyXwmhdotsx6Lww==", "license": "MIT", "dependencies": { "ldapts": "^8.1.8" diff --git a/nodejs/package.json b/nodejs/package.json index eeb0f01..aa15769 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-jump-host", - "version": "1.4.0", + "version": "1.5.0", "description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics", "author": [ { @@ -20,10 +20,10 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", - "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/app-stack": "^1.0.0", - "@simpleworkjs/ldap": "^1.0.0", + "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.0.0", + "@simpleworkjs/ldap": "^1.0.1", "@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index 4efc5aa..c1b7fdf 100644 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -11,7 +11,8 @@ app.jump = (function(app){ var qs = $.param(query || {}); app.api.get('audit' + (qs ? '?' + qs : ''), cb); } - return {metrics: metrics, sessions: sessions, audit: audit}; + function hosts(cb){ app.api.get('user/hosts', cb); } + return {metrics: metrics, sessions: sessions, audit: audit, hosts: hosts}; })(app); // Shared render helpers. diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 9fed6a4..a4f6e4e 100644 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -5,6 +5,7 @@ const router = require('express').Router(); const { isAdmin } = require('../middleware/auth'); +const access = require('../utils/access'); router.get('/me', (req, res) => { res.json({ @@ -14,4 +15,16 @@ router.get('/me', (req, res) => { }); }); +// The hosts this session can SSH to — every host for an admin, otherwise the +// same group-based resolution the SSH front door uses (accessibleHosts), +// fed the OIDC session's already-known groups instead of an LDAP lookup. +router.get('/hosts', async (req, res, next) => { + try { + const hosts = isAdmin(req) + ? await access.allHosts() + : await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] }); + res.json({ results: hosts }); + } catch (err) { next(err); } +}); + module.exports = router; diff --git a/nodejs/test/unit/access.test.js b/nodejs/test/unit/access.test.js index 2ee31ff..06d3c9e 100644 --- a/nodejs/test/unit/access.test.js +++ b/nodejs/test/unit/access.test.js @@ -2,7 +2,7 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const { accessibleHosts, clearCache } = require('../../utils/access'); +const { accessibleHosts, allHosts, clearCache } = require('../../utils/access'); function stubLdap(groups) { return { getGroups: async () => groups }; @@ -54,6 +54,32 @@ test('caches per uid', async () => { assert.strictEqual(calls, 1); }); +test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => { + clearCache(); + let ldapCalled = false; + const user = { uid: 'erin', groups: ['host_web01_access'] }; + const fetchImpl = stubFetch({ + host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }], + }); + const ldap = { getGroups: async () => { ldapCalled = true; return []; } }; + const hosts = await accessibleHosts(user, { fetchImpl, ldap }); + assert.deepStrictEqual(hosts.map((h) => h.id), ['5']); + assert.strictEqual(ldapCalled, false); +}); + +test('allHosts fetches the whole host inventory with no group filter', async () => { + const fetchImpl = async (url) => { + assert.ok(!url.includes('group='), 'must not filter by group'); + assert.ok(url.includes('kind=host')); + return { ok: true, json: async () => ({ results: [ + { id: '1', kind: 'host', slug: 'host_a' }, + { id: '2', kind: 'host', slug: 'host_b' }, + ] }) }; + }; + const hosts = await allHosts({ fetchImpl }); + assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']); +}); + test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => { clearCache(); const user = { uid: 'dave', dn: 'd' }; diff --git a/nodejs/utils/access.js b/nodejs/utils/access.js index b2f4630..7dd7e5c 100644 --- a/nodejs/utils/access.js +++ b/nodejs/utils/access.js @@ -8,9 +8,10 @@ const conf = require('@simpleworkjs/conf'); if (conf.standalone && conf.standalone.enabled) { - // Standalone mode: use the ORM-backed host inventory. + // Standalone mode: use the ORM-backed host inventory. Every host is + // accessible to every user, so allHosts and accessibleHosts coincide. const { accessibleHosts } = require('./hosts_file'); - module.exports = { accessibleHosts, clearCache: () => {} }; + module.exports = { accessibleHosts, allHosts: () => accessibleHosts(), clearCache: () => {} }; } else { // Production mode: LDAP groups + SSO API (unchanged). @@ -48,11 +49,21 @@ if (conf.standalone && conf.standalone.enabled) { return directoryClient({ fetchImpl }).getResourcesByGroup(group); } + // Every host in the inventory, unfiltered — for admins (the web UI's own + // account is already gated by requireAdmin before this is ever called). + async function allHosts({ fetchImpl = fetch } = {}) { + const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' }); + return resources.filter(r => r.kind === 'host'); + } + async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) { const hit = cache.get(user.uid); if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts; - const groups = await ldap.getGroups(user.dn); + // The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we + // look them up; the web UI already has the session's OIDC groups claim + // and passes it directly, skipping a redundant LDAP round-trip. + const groups = user.groups || await ldap.getGroups(user.dn); const seen = new Map(); for (const cn of groups) { @@ -80,5 +91,5 @@ if (conf.standalone && conf.standalone.enabled) { else cache.clear(); } - module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup }; + module.exports = { accessibleHosts, allHosts, clearCache, fetchResourcesByGroup }; } diff --git a/nodejs/views/dashboard.ejs b/nodejs/views/dashboard.ejs index 12231b8..efab4bb 100644 --- a/nodejs/views/dashboard.ejs +++ b/nodejs/views/dashboard.ejs @@ -28,6 +28,15 @@ +
+
+
+
Hosts you can reach
+
+
+
+
+
Top hosts
@@ -49,7 +58,17 @@ $b.append('' + app.jump.esc(x.name) + '' + x.count + ''); }); } - $(document).ready(function(){ + function hostRows(sel, hosts){ + var $b = $(sel).empty(); + if(!hosts || !hosts.length){ $b.append('No hosts reachable.'); return; } + hosts.forEach(function(h){ + var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || ''; + $b.append('' + app.jump.esc(h.displayName || h.name || h.slug) + '' + + '' + app.jump.esc(h.slug) + '' + + '' + app.jump.esc(addr) + ''); + }); + } + $(document).ready(async function(){ app.jump.metrics(function(error, data){ if(error || !data) return; $('#stat-active').text(data.active); @@ -59,6 +78,12 @@ rows('#top-hosts', data.topHosts); rows('#top-users', data.topUsers); }); + await app.auth.loadUser(); + if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts'); + app.jump.hosts(function(error, data){ + if(error) return hostRows('#my-hosts', []); + hostRows('#my-hosts', data && data.results); + }); }); <%- include('bottom') %>