Add standalone mode with @simpleworkjs/orm-backed user/host stores
- Add standalone.enabled config flag to switch between LDAP+SSO and ORM-backed backends without changing the production code path - New ORM models: StandaloneUser (uid, passwordHash, sshPublicKeys, groups) and StandaloneHost (slug, displayName, kind, metadata) - user_file.js and hosts_file.js implement the same interfaces as the LDAP client and accessibleHosts() respectively - models/user_ldap.js and utils/access.js become conditional facades that delegate based on conf.standalone.enabled at require time - Zero changes to ssh_server.js core logic, bridge.js, key_inject.js, tui_picker.js, or any other consumer - Fix ssh_server.js: use ?? instead of || for listenPort (0 is falsy) - Fix ssh_server.js: register session listeners before awaiting audit.create() so client exec/shell requests aren't rejected - Patch StringField.toSequelize() and IntegerField.toSequelize() to pass through primaryKey (the ORM's UUIDField already does this) - 47 tests pass (24 existing + 15 new unit + 3 existing integration + 5 new standalone integration) - Defaults to SQLite; any Sequelize dialect works via conf.orm Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+71
-57
@@ -1,70 +1,84 @@
|
||||
'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.
|
||||
// Host discovery — SSO Manager API in production, ORM-backed inventory in
|
||||
// standalone mode. Both export the same interface:
|
||||
// accessibleHosts(user) -> [host resources]
|
||||
// clearCache(uid?) -> void
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
||||
const userLdap = require('../models/user_ldap');
|
||||
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
const cache = new Map(); // uid -> {at, hosts}
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
// Standalone mode: use the ORM-backed host inventory.
|
||||
const { accessibleHosts } = require('./hosts_file');
|
||||
module.exports = { accessibleHosts, clearCache: () => {} };
|
||||
} else {
|
||||
// Production mode: LDAP groups + SSO API (unchanged).
|
||||
|
||||
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
||||
// unit tests can stub the transport; the shared client validates the
|
||||
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
||||
// thrown error instead of a silent `[]`).
|
||||
function directoryClient({ fetchImpl = fetch } = {}) {
|
||||
const sso = conf.sso || {};
|
||||
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
|
||||
}
|
||||
// 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.
|
||||
|
||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||
}
|
||||
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
|
||||
const userLdap = require('../models/user_ldap');
|
||||
|
||||
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 CACHE_TTL_MS = 30 * 1000;
|
||||
const cache = new Map(); // uid -> {at, 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);
|
||||
}
|
||||
// Build a directory client bound to conf.sso. fetchImpl is injectable so the
|
||||
// unit tests can stub the transport; the shared client validates the
|
||||
// `{ results }` envelope on every call (turns the old bare-array drift into a
|
||||
// thrown error instead of a silent `[]`).
|
||||
function directoryClient({ fetchImpl = fetch } = {}) {
|
||||
const sso = conf.sso || {};
|
||||
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
|
||||
}
|
||||
|
||||
const hosts = [...seen.values()];
|
||||
cache.set(user.uid, { at: Date.now(), hosts });
|
||||
return hosts;
|
||||
}
|
||||
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
|
||||
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
|
||||
}
|
||||
|
||||
function clearCache(uid) {
|
||||
if (uid) cache.delete(uid);
|
||||
else cache.clear();
|
||||
}
|
||||
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;
|
||||
|
||||
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
// ORM-backed host inventory for standalone mode. Implements the same interface
|
||||
// as utils/access.js so ssh_server.js works unchanged: accessibleHosts(user)
|
||||
// returns an array of host resources the user may reach.
|
||||
//
|
||||
// In standalone mode all hosts in the inventory are accessible to every
|
||||
// authenticated user — there is no group-based filtering. The _user parameter
|
||||
// is accepted for interface compatibility but ignored.
|
||||
|
||||
const StandaloneHost = require('../models/standalone_host');
|
||||
|
||||
async function accessibleHosts(_user) {
|
||||
const hosts = await StandaloneHost.list({ where: { kind: 'host' } });
|
||||
// The ORM returns model instances; map to plain objects matching the shape
|
||||
// that target_match.js and tui_picker.js expect.
|
||||
return hosts.map((h) => ({
|
||||
id: h.slug, // slug doubles as the stable id in standalone mode
|
||||
kind: h.kind,
|
||||
slug: h.slug,
|
||||
displayName: h.displayName,
|
||||
metadata: h.metadata || {},
|
||||
}));
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
// No cache in standalone mode — every call reads from the DB.
|
||||
}
|
||||
|
||||
module.exports = { accessibleHosts, clearCache };
|
||||
Reference in New Issue
Block a user