Files
jump-host/nodejs/utils/access.js
T
wmantly 36e9d5b0b3 feat: initial jump-host — SSH jump host for the theta42 stack
An SSH jump host that authenticates users against the shared LDAP
directory, authorizes them from the SSO Manager's inventory graph, and
bridges them to downstream hosts — auditing everything.

- Username-grammar routing (uid_-_target@jump) + interactive TUI picker
- Inbound LDAP auth (publickey / password with off|local|all policy)
- Directory-driven access (LDAP groups x /api/discovery/resources?group=)
- Per-user key injection into sshPublicKey, connects downstream as the user
- Shell / exec / SFTP-subsystem bridging (WinSCP works)
- Web UI + HTTP API (:3002) for audit + metrics; LDAP-admin gated
- Packaged like proxy: ops/install.sh + systemd, all-in-one Docker, compose
- Tests: 23 unit + 3 integration (node --test), all green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:48:49 -04:00

68 lines
2.2 KiB
JavaScript

'use strict';
// Which directory hosts may a user reach, and how do we dial them?
//
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
// /api/discovery/me only answers for the API token's own user, and /graph
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
// directly) with per-group resource lookups:
//
// 1. LDAP: groups the user's DN is a member of
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
// 3. union, keep kind === 'host'
//
// Results are cached per-uid for a short TTL — the TUI picker and the
// username-grammar path share the cache. Dependency-injected fetch/ldap for
// unit testing.
const conf = require('@simpleworkjs/conf');
const userLdap = require('../models/user_ldap');
const CACHE_TTL_MS = 30 * 1000;
const cache = new Map(); // uid -> {at, hosts}
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
const sso = conf.sso || {};
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`;
const res = await fetchImpl(url, {
headers: { Authorization: `Bearer ${sso.apiToken}` },
});
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`);
const data = await res.json();
return (data && data.results) || [];
}
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);
const seen = new Map();
for (const cn of groups) {
let resources;
try {
resources = await fetchResourcesByGroup(cn, { fetchImpl });
} catch (error) {
// One bad group must not hide the rest; the SSO being down
// surfaces as an empty list + log line, not a crash.
console.error(`[access] ${error.message}`);
continue;
}
for (const r of resources) {
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
}
}
const hosts = [...seen.values()];
cache.set(user.uid, { at: Date.now(), hosts });
return hosts;
}
function clearCache(uid) {
if (uid) cache.delete(uid);
else cache.clear();
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };