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:
+15
-1
@@ -49,4 +49,18 @@ module.exports.authRouter = oidcClient.router;
|
||||
require('./audit_event');
|
||||
|
||||
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
|
||||
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
||||
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
|
||||
|
||||
// Standalone mode: initialize @simpleworkjs/orm for local user/host stores.
|
||||
// The ORM must be loaded before any code calls user_ldap or access — both of
|
||||
// which check conf.standalone.enabled at require time and may delegate to the
|
||||
// ORM-backed wrappers. Model registration is synchronous; table sync is async
|
||||
// but the first query will implicitly wait (Sequelize.sync is in-flight).
|
||||
// Export the promise so integration tests can await it before seeding data.
|
||||
let ormReady = Promise.resolve();
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const ormConf = conf.orm || { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false };
|
||||
ormReady = init({ conf: { orm: ormConf }, models: [require('./standalone_user'), require('./standalone_host')] });
|
||||
}
|
||||
module.exports.ormReady = ormReady;
|
||||
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
// ORM model for standalone-mode hosts. Stored in the configured SQL database
|
||||
// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js
|
||||
// wrapper translates between this model and the accessibleHosts() interface
|
||||
// that ssh_server.js expects.
|
||||
|
||||
const { Model, fields } = require('@simpleworkjs/orm');
|
||||
|
||||
// Patch StringField.toSequelize() to pass through primaryKey (same fix as in
|
||||
// standalone_user.js — see that file for details).
|
||||
if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) {
|
||||
const orig = fields.StringField.prototype.toSequelize;
|
||||
fields.StringField.prototype.toSequelize = function () {
|
||||
const def = orig.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
return def;
|
||||
};
|
||||
}
|
||||
|
||||
class StandaloneHost extends Model {
|
||||
static fields = {
|
||||
slug: { type: 'string', primaryKey: true },
|
||||
displayName: { type: 'string' },
|
||||
kind: { type: 'string', default: 'host' },
|
||||
metadata: { type: 'json', default: {} },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = StandaloneHost;
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
|
||||
// ORM model for standalone-mode users. Stored in the configured SQL database
|
||||
// (default SQLite) when conf.standalone.enabled is true. The user_file.js
|
||||
// wrapper translates between this model and the LDAP-client interface that
|
||||
// ssh_server.js and key_inject.js expect.
|
||||
|
||||
const { Model, fields } = require('@simpleworkjs/orm');
|
||||
|
||||
// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass
|
||||
// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them
|
||||
// so string and int primary keys work.
|
||||
const origStringToSeq = fields.StringField.prototype.toSequelize;
|
||||
fields.StringField.prototype.toSequelize = function () {
|
||||
const def = origStringToSeq.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
return def;
|
||||
};
|
||||
const origIntToSeq = fields.IntegerField.prototype.toSequelize;
|
||||
fields.IntegerField.prototype.toSequelize = function () {
|
||||
const def = origIntToSeq.call(this);
|
||||
if (this.primaryKey) def.primaryKey = true;
|
||||
if (this.autoIncrement) def.autoIncrement = true;
|
||||
return def;
|
||||
};
|
||||
|
||||
class StandaloneUser extends Model {
|
||||
static fields = {
|
||||
uid: { type: 'string', primaryKey: true },
|
||||
passwordHash: { type: 'string', isPrivate: true },
|
||||
sshPublicKeys: { type: 'json', default: [] },
|
||||
groups: { type: 'json', default: [] },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = StandaloneUser;
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
// ORM-backed user store for standalone mode. Implements the same interface as
|
||||
// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work
|
||||
// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine).
|
||||
//
|
||||
// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect).
|
||||
// DNs are synthetic: uid=<uid>,ou=people,dc=standalone,dc=local — the real
|
||||
// identity is the uid; the DN exists only for interface compatibility with
|
||||
// callers that thread user.dn through to checkPassword / addSshKey.
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const StandaloneUser = require('./standalone_user');
|
||||
|
||||
const DN_PREFIX = 'uid=';
|
||||
const DN_SUFFIX = ',ou=people,dc=standalone,dc=local';
|
||||
|
||||
function dnFor(uid) {
|
||||
return `${DN_PREFIX}${uid}${DN_SUFFIX}`;
|
||||
}
|
||||
|
||||
function uidFromDn(dn) {
|
||||
if (!dn || typeof dn !== 'string') return null;
|
||||
const m = dn.match(/^uid=([^,]+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
async function getUser(uid) {
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return null;
|
||||
return {
|
||||
dn: dnFor(user.uid),
|
||||
uid: user.uid,
|
||||
sshPublicKeys: user.sshPublicKeys || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function getGroups(dn) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return [];
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return [];
|
||||
return user.groups || [];
|
||||
}
|
||||
|
||||
async function checkPassword(dn, pw) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return false;
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user || !user.passwordHash) return false;
|
||||
return bcrypt.compare(pw, user.passwordHash);
|
||||
}
|
||||
|
||||
async function addSshKey(dn, keyLine) {
|
||||
const uid = uidFromDn(dn);
|
||||
if (!uid) return;
|
||||
const user = await StandaloneUser.get(uid);
|
||||
if (!user) return;
|
||||
const keys = [...(user.sshPublicKeys || [])];
|
||||
if (keys.includes(keyLine)) return; // idempotent
|
||||
keys.push(keyLine);
|
||||
await user.update({ sshPublicKeys: keys });
|
||||
}
|
||||
|
||||
module.exports = { getUser, getGroups, checkPassword, addSshKey };
|
||||
+17
-16
@@ -1,22 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the
|
||||
// shared @simpleworkjs/ldap package:
|
||||
// User authentication backend — LDAP in production, ORM-backed file store in
|
||||
// standalone mode. Both export the same interface:
|
||||
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
|
||||
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
|
||||
// checkPassword(dn, pw) -> bool (simple bind as the user)
|
||||
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
|
||||
//
|
||||
// Behavior is unchanged from the previous in-tree implementation: posixAccount
|
||||
// user filter, groupOfNames group filter, bind-as-user password check,
|
||||
// TypeOrValueExists treated as success on key add, and the same loose TLS
|
||||
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
|
||||
// getGroups(dn) -> [cn, ...]
|
||||
// checkPassword(dn, pw) -> bool
|
||||
// addSshKey(dn, keyLine) -> void (idempotent)
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||
|
||||
const ldapConf = conf.ldap || {};
|
||||
module.exports = createLdapClient({
|
||||
...ldapConf,
|
||||
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
||||
});
|
||||
if (conf.standalone && conf.standalone.enabled) {
|
||||
// Standalone mode: use the ORM-backed user store.
|
||||
module.exports = require('./user_file');
|
||||
} else {
|
||||
// Production mode: use the LDAP directory.
|
||||
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||
const ldapConf = conf.ldap || {};
|
||||
module.exports = createLdapClient({
|
||||
...ldapConf,
|
||||
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user