From 12c4499ae6be834eb570d71f0bd57c972f75511e Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Mon, 23 Feb 2026 13:45:49 -0500 Subject: [PATCH 01/14] feat: provider options passthrough --- npm/src/AuthProvider.js | 7 ++++ npm/src/DirectoryProvider.js | 7 ++++ npm/test/fixtures/mockProviders.js | 6 ++-- server/backends/custom-auth.example.js | 10 +++--- server/backends/custom-directory.example.js | 10 +++--- server/backends/ldap.auth.js | 11 +++--- server/backends/mongodb.auth.js | 8 ++--- server/backends/mongodb.directory.js | 11 +++--- server/backends/notification.auth.js | 7 ++-- server/backends/proxmox.auth.js | 8 ++--- server/backends/proxmox.directory.js | 13 +++---- server/backends/sql.auth.js | 27 +++++--------- server/backends/sql.directory.js | 39 +++++++++------------ server/backends/template.js | 20 ++++------- server/providers.js | 4 +-- server/services/notificationService.js | 11 ++++-- server/utils/sqlUtils.js | 21 +++++++++++ 17 files changed, 122 insertions(+), 98 deletions(-) create mode 100644 server/utils/sqlUtils.js diff --git a/npm/src/AuthProvider.js b/npm/src/AuthProvider.js index a067acf..fa52f6c 100644 --- a/npm/src/AuthProvider.js +++ b/npm/src/AuthProvider.js @@ -3,6 +3,13 @@ * Implement this interface to add custom authentication backends */ class AuthProvider { + /** + * @param {Object} options - Provider configuration options (overrides env vars) + */ + constructor(options = {}) { + this.options = options; + } + /** * Authenticate a user with username and password * @param {string} username - The username to authenticate diff --git a/npm/src/DirectoryProvider.js b/npm/src/DirectoryProvider.js index fa0f625..5a888ff 100644 --- a/npm/src/DirectoryProvider.js +++ b/npm/src/DirectoryProvider.js @@ -3,6 +3,13 @@ * Implement this interface to add custom directory backends */ class DirectoryProvider { + /** + * @param {Object} options - Provider configuration options (overrides env vars) + */ + constructor(options = {}) { + this.options = options; + } + /** * Find a single user by username * @param {string} username - The username to search for diff --git a/npm/test/fixtures/mockProviders.js b/npm/test/fixtures/mockProviders.js index 4218cc9..e9ca52a 100644 --- a/npm/test/fixtures/mockProviders.js +++ b/npm/test/fixtures/mockProviders.js @@ -11,7 +11,7 @@ const { testUsers, testGroups } = require('./testData'); // Simple implementation for testing auth flows class MockAuthProvider extends AuthProvider { constructor(options = {}) { - super(); + super(options); this.name = options.name || 'mock-auth'; this.shouldSucceed = options.shouldSucceed !== undefined ? options.shouldSucceed : true; this.delay = options.delay || 0; @@ -56,7 +56,7 @@ class MockAuthProvider extends AuthProvider { // Simple implementation for testing directory lookups class MockDirectoryProvider extends DirectoryProvider { constructor(options = {}) { - super(); + super(options); this.name = options.name || 'mock-directory'; this.users = options.users || testUsers.map(u => ({ ...u })); this.groups = options.groups || testGroups.map(g => ({ ...g })); @@ -155,7 +155,7 @@ class MockDirectoryProvider extends DirectoryProvider { // Always succeeds if notification succeeds, regardless of password class MockNotificationAuthProvider extends AuthProvider { constructor(options = {}) { - super(); + super(options); this.name = 'mock-notification'; this.notificationShouldSucceed = options.notificationShouldSucceed !== undefined ? options.notificationShouldSucceed diff --git a/server/backends/custom-auth.example.js b/server/backends/custom-auth.example.js index 2563347..ea3ae3c 100644 --- a/server/backends/custom-auth.example.js +++ b/server/backends/custom-auth.example.js @@ -17,12 +17,12 @@ const http = require('http'); class ApiAuthBackend extends AuthProvider { constructor(options = {}) { - super(); + super(options); - // Load configuration from environment - this.apiUrl = process.env.API_AUTH_URL || 'https://api.example.com/auth'; - this.apiToken = process.env.API_AUTH_TOKEN; - this.timeout = parseInt(process.env.API_AUTH_TIMEOUT || '5000', 10); + // Use options with env var fallback — enables multi-realm support + this.apiUrl = options.apiUrl ?? process.env.API_AUTH_URL ?? 'https://api.example.com/auth'; + this.apiToken = options.apiToken ?? process.env.API_AUTH_TOKEN; + this.timeout = options.timeout ?? parseInt(process.env.API_AUTH_TIMEOUT || '5000', 10); if (!this.apiUrl) { console.warn('[ApiAuthBackend] No API_AUTH_URL configured'); diff --git a/server/backends/custom-directory.example.js b/server/backends/custom-directory.example.js index 17b55fb..cf3a1ba 100644 --- a/server/backends/custom-directory.example.js +++ b/server/backends/custom-directory.example.js @@ -40,11 +40,11 @@ const path = require('path'); class JsonDirectoryBackend extends DirectoryProvider { constructor(options = {}) { - super(); + super(options); - // Load configuration from environment - this.usersPath = process.env.JSON_USERS_PATH || path.join(process.cwd(), 'users.json'); - this.groupsPath = process.env.JSON_GROUPS_PATH || path.join(process.cwd(), 'groups.json'); + // Use options with env var fallback — enables multi-realm support + this.usersPath = options.usersPath ?? process.env.JSON_USERS_PATH ?? path.join(process.cwd(), 'users.json'); + this.groupsPath = options.groupsPath ?? process.env.JSON_GROUPS_PATH ?? path.join(process.cwd(), 'groups.json'); // Cache for loaded data this.usersCache = null; @@ -52,7 +52,7 @@ class JsonDirectoryBackend extends DirectoryProvider { this.lastLoad = null; // Cache duration (5 minutes) - this.cacheDuration = parseInt(process.env.JSON_CACHE_DURATION || '300000', 10); + this.cacheDuration = options.cacheDuration ?? parseInt(process.env.JSON_CACHE_DURATION || '300000', 10); console.log(`[JsonDirectoryBackend] Initialized with users: ${this.usersPath}, groups: ${this.groupsPath}`); } diff --git a/server/backends/ldap.auth.js b/server/backends/ldap.auth.js index fedc056..69d7687 100644 --- a/server/backends/ldap.auth.js +++ b/server/backends/ldap.auth.js @@ -5,8 +5,11 @@ const resolveLDAPHosts = require('../utils/resolveLdapHosts'); const logger = require('../utils/logger'); class LDAPBackend extends AuthProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); + this.ldapBindDn = options.ldapBindDn ?? process.env.LDAP_BIND_DN; + this.ldapBindPassword = options.ldapBindPassword ?? process.env.LDAP_BIND_PASSWORD; + this.ldapAuthBaseDn = options.ldapAuthBaseDn ?? process.env.LDAP_AUTH_BASE_DN; this.serverPool = []; this.failedServers = new Map(); this.initialized = false; @@ -111,7 +114,7 @@ class LDAPBackend extends AuthProvider { attributes: ['dn'] }; - client.bind(process.env.LDAP_BIND_DN, process.env.LDAP_BIND_PASSWORD, (err) => { + client.bind(this.ldapBindDn, this.ldapBindPassword, (err) => { if (err) { logger.error("Service bind failed", err); return reject(new Error("Service bind failed: " + err)); @@ -119,7 +122,7 @@ class LDAPBackend extends AuthProvider { logger.debug("Service bind successful, searching for user..."); let foundDN = null; - client.search(process.env.LDAP_AUTH_BASE_DN, opts, (err, res) => { + client.search(this.ldapAuthBaseDn, opts, (err, res) => { if (err) return reject(err); res.on('searchEntry', (entry) => { diff --git a/server/backends/mongodb.auth.js b/server/backends/mongodb.auth.js index 42adb6b..0041e38 100644 --- a/server/backends/mongodb.auth.js +++ b/server/backends/mongodb.auth.js @@ -8,12 +8,12 @@ const bcrypt = require('bcrypt'); * Handles user authentication against MongoDB database */ class MongoDBAuthProvider extends AuthProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); this.config = { type: 'mongodb', - uri: process.env.MONGO_URI || "mongodb://localhost:27017/ldap_user_db", - database: process.env.MONGO_DATABASE || "ldap_user_db" + uri: options.mongoUri ?? process.env.MONGO_URI ?? "mongodb://localhost:27017/ldap_user_db", + database: options.mongoDatabase ?? process.env.MONGO_DATABASE ?? "ldap_user_db" }; this.initialized = false; } diff --git a/server/backends/mongodb.directory.js b/server/backends/mongodb.directory.js index 9b28829..7f5def6 100644 --- a/server/backends/mongodb.directory.js +++ b/server/backends/mongodb.directory.js @@ -7,13 +7,14 @@ const logger = require('../utils/logger'); * Handles user and group directory operations against MongoDB database */ class MongoDBDirectoryProvider extends DirectoryProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); this.config = { type: 'mongodb', - uri: process.env.MONGO_URI || "mongodb://localhost:27017/ldap_user_db", - database: process.env.MONGO_DATABASE || "ldap_user_db" + uri: options.mongoUri ?? process.env.MONGO_URI ?? "mongodb://localhost:27017/ldap_user_db", + database: options.mongoDatabase ?? process.env.MONGO_DATABASE ?? "ldap_user_db" }; + this.ldapBaseDn = options.ldapBaseDn ?? process.env.LDAP_BASE_DN; this.initialized = false; } @@ -99,7 +100,7 @@ class MongoDBDirectoryProvider extends DirectoryProvider { memberUids: [user.username], gid_number: gidNum, gidNumber: gidNum, - dn: `cn=${user.username},${process.env.LDAP_BASE_DN}`, + dn: `cn=${user.username},${this.ldapBaseDn}`, objectClass: ["posixGroup"], }]; } diff --git a/server/backends/notification.auth.js b/server/backends/notification.auth.js index 0737ba7..ea1ac7e 100644 --- a/server/backends/notification.auth.js +++ b/server/backends/notification.auth.js @@ -7,8 +7,9 @@ const logger = require('../utils/logger'); * Works as a standalone auth provider in the chain (doesn't wrap other providers) */ class NotificationAuthProvider extends AuthProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); + this.notificationUrl = options.notificationUrl ?? process.env.NOTIFICATION_URL ?? null; this.initialized = false; } @@ -23,7 +24,7 @@ class NotificationAuthProvider extends AuthProvider { try { logger.debug(`[NotificationAuthProvider] Sending MFA notification for ${username}`); - const response = await NotificationService.sendAuthenticationNotification(username); + const response = await NotificationService.sendAuthenticationNotification(username, this.notificationUrl); if (response.action === "approve") { logger.debug(`[NotificationAuthProvider] MFA approved for ${username}`); diff --git a/server/backends/proxmox.auth.js b/server/backends/proxmox.auth.js index ddd897e..36f82d3 100644 --- a/server/backends/proxmox.auth.js +++ b/server/backends/proxmox.auth.js @@ -6,14 +6,14 @@ const { AuthProvider } = require('@ldap-gateway/core'); const logger = require('../utils/logger'); class ProxmoxBackend extends AuthProvider { - constructor(directoryProvider = null) { - super(); - this.shadowPath = process.env.PROXMOX_SHADOW_CFG || null; + constructor(options = {}) { + super(options); + this.shadowPath = options.proxmoxShadowCfg ?? process.env.PROXMOX_SHADOW_CFG ?? null; this.shadowCache = null; this.fileWatcher = null; this.reloadTimeout = null; this.initialized = false; - this.directoryProvider = directoryProvider; + this.directoryProvider = options.directoryProvider ?? null; } async initialize() { diff --git a/server/backends/proxmox.directory.js b/server/backends/proxmox.directory.js index b29b0e0..1f62def 100644 --- a/server/backends/proxmox.directory.js +++ b/server/backends/proxmox.directory.js @@ -7,9 +7,10 @@ const logger = require('../utils/logger'); const { name } = require('./proxmox.auth'); class ProxmoxDirectory extends DirectoryProvider { - constructor() { - super(); - this.configPath = process.env.PROXMOX_USER_CFG || null; + constructor(options = {}) { + super(options); + this.configPath = options.proxmoxUserCfg ?? process.env.PROXMOX_USER_CFG ?? null; + this.ldapBaseDn = options.ldapBaseDn ?? process.env.LDAP_BASE_DN; this.users = []; this.groups = []; this.watcher = null; @@ -201,7 +202,7 @@ class ProxmoxDirectory extends DirectoryProvider { memberUids, gid_number: gidBase, gidNumber: gidBase, - dn: `cn=${groupName},${process.env.LDAP_BASE_DN}`, + dn: `cn=${groupName},${this.ldapBaseDn}`, objectClass: ["posixGroup"], }); gidBase++; @@ -218,7 +219,7 @@ class ProxmoxDirectory extends DirectoryProvider { memberUids: allUsernames, gid_number: 9999, gidNumber: 9999, - dn: `cn=proxmox-sudo,${process.env.LDAP_BASE_DN}`, + dn: `cn=proxmox-sudo,${this.ldapBaseDn}`, objectClass: ["posixGroup"], }); @@ -274,7 +275,7 @@ class ProxmoxDirectory extends DirectoryProvider { memberUids: [user.username], gid_number: gidNum, gidNumber: gidNum, - dn: `cn=${user.username},${process.env.LDAP_BASE_DN}`, + dn: `cn=${user.username},${this.ldapBaseDn}`, objectClass: ["posixGroup"], }]; } diff --git a/server/backends/sql.auth.js b/server/backends/sql.auth.js index 9b6de13..01bccdf 100644 --- a/server/backends/sql.auth.js +++ b/server/backends/sql.auth.js @@ -1,34 +1,23 @@ const { AuthProvider } = require('@ldap-gateway/core'); const logger = require('../utils/logger'); const { Sequelize } = require('sequelize'); +const { buildSequelizeOptions } = require('../utils/sqlUtils'); const argon2 = require('argon2'); const bcrypt = require('bcrypt'); const unixcrypt = require('unixcrypt'); -/** - * Build Sequelize options with optional SSL configuration - * Set SQL_SSL=false to disable TLS for testing with local databases - */ -function buildSequelizeOptions() { - const options = { logging: msg => logger.debug(msg) }; - - if (process.env.SQL_SSL === 'false') { - options.dialectOptions = { ssl: false }; - } - - return options; -} - /** * SQL Authentication Provider * Handles user authentication against SQL database */ class SQLAuthProvider extends AuthProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); + this.sqlUri = options.sqlUri ?? process.env.SQL_URI; + this.sqlQueryOneUser = options.sqlQueryOneUser ?? process.env.SQL_QUERY_ONE_USER; this.sequelize = new Sequelize( - process.env.SQL_URI, - buildSequelizeOptions() + this.sqlUri, + buildSequelizeOptions(options) ); } @@ -84,7 +73,7 @@ class SQLAuthProvider extends AuthProvider { try { logger.debug(`[SQLAuthProvider] Authenticating user: ${username}`); const [results, _] = await this.sequelize.query( - process.env.SQL_QUERY_ONE_USER, + this.sqlQueryOneUser, { replacements: [username] } ); diff --git a/server/backends/sql.directory.js b/server/backends/sql.directory.js index bc424cc..c8c91f0 100644 --- a/server/backends/sql.directory.js +++ b/server/backends/sql.directory.js @@ -1,20 +1,7 @@ const { DirectoryProvider, filterUtils } = require('@ldap-gateway/core'); const logger = require('../utils/logger'); const { Sequelize } = require('sequelize'); - -/** - * Build Sequelize options with optional SSL configuration - * Set SQL_SSL=false to disable TLS for testing with local databases - */ -function buildSequelizeOptions() { - const options = { logging: msg => logger.debug(msg) }; - - if (process.env.SQL_SSL === 'false') { - options.dialectOptions = { ssl: false }; - } - - return options; -} +const { buildSequelizeOptions } = require('../utils/sqlUtils'); /** * Normalize member_uids field from database @@ -40,11 +27,17 @@ function normalizeMemberUids(group) { * Handles user and group directory operations against SQL database */ class SQLDirectoryProvider extends DirectoryProvider { - constructor() { - super(); + constructor(options = {}) { + super(options); + this.sqlUri = options.sqlUri ?? process.env.SQL_URI; + this.sqlQueryOneUser = options.sqlQueryOneUser ?? process.env.SQL_QUERY_ONE_USER; + this.sqlQueryAllUsers = options.sqlQueryAllUsers ?? process.env.SQL_QUERY_ALL_USERS; + this.sqlQueryAllGroups = options.sqlQueryAllGroups ?? process.env.SQL_QUERY_ALL_GROUPS; + this.sqlQueryGroupsByMember = options.sqlQueryGroupsByMember ?? process.env.SQL_QUERY_GROUPS_BY_MEMBER; + this.ldapBaseDn = options.ldapBaseDn ?? process.env.LDAP_BASE_DN; this.sequelize = new Sequelize( - process.env.SQL_URI, - buildSequelizeOptions() + this.sqlUri, + buildSequelizeOptions(options) ); } @@ -55,7 +48,7 @@ class SQLDirectoryProvider extends DirectoryProvider { try { logger.debug(`[SQLDirectoryProvider] Finding user: ${username}`); const [results, _] = await this.sequelize.query( - process.env.SQL_QUERY_ONE_USER, + this.sqlQueryOneUser, { replacements: [username] } ); @@ -85,7 +78,7 @@ class SQLDirectoryProvider extends DirectoryProvider { const username = filterConditions.memberUid; logger.debug(`[SQLDirectoryProvider] Finding groups for member: ${username}`); const [groups, _] = await this.sequelize.query( - process.env.SQL_QUERY_GROUPS_BY_MEMBER, + this.sqlQueryGroupsByMember, { replacements: [username] } ); @@ -128,7 +121,7 @@ class SQLDirectoryProvider extends DirectoryProvider { memberUids: [user.username], gid_number: gidNum, gidNumber: gidNum, - dn: `cn=${user.username},${process.env.LDAP_BASE_DN}`, + dn: `cn=${user.username},${this.ldapBaseDn}`, objectClass: ["posixGroup"], }]; } @@ -147,7 +140,7 @@ class SQLDirectoryProvider extends DirectoryProvider { async getAllUsers() { try { logger.debug('[SQLDirectoryProvider] Getting all users'); - const [users, _] = await this.sequelize.query(process.env.SQL_QUERY_ALL_USERS); + const [users, _] = await this.sequelize.query(this.sqlQueryAllUsers); logger.debug(`[SQLDirectoryProvider] Found ${users.length} users`); return users; @@ -163,7 +156,7 @@ class SQLDirectoryProvider extends DirectoryProvider { await this.initialize(); logger.debug('[SQLDirectoryProvider] Getting all groups'); - const [groups, _] = await this.sequelize.query(process.env.SQL_QUERY_ALL_GROUPS); + const [groups, _] = await this.sequelize.query(this.sqlQueryAllGroups); // Normalize member_uids from JSON strings to arrays const normalizedGroups = groups.map(normalizeMemberUids); diff --git a/server/backends/template.js b/server/backends/template.js index 2befe64..e5c9dcf 100644 --- a/server/backends/template.js +++ b/server/backends/template.js @@ -16,15 +16,11 @@ const { AuthProvider, DirectoryProvider } = require('@ldap-gateway/core'); class MyAuthBackend extends AuthProvider { constructor(options = {}) { - super(); + super(options); - // Initialize your backend with options - // Options may include: databaseService, ldapServerPool, or custom config - this.options = options; - - // Access environment variables for configuration - this.apiUrl = process.env.MY_API_URL; - this.apiKey = process.env.MY_API_KEY; + // Use options with env var fallback — enables multi-realm support + this.apiUrl = options.apiUrl ?? process.env.MY_API_URL; + this.apiKey = options.apiKey ?? process.env.MY_API_KEY; // Initialize any connections, clients, or state here } @@ -66,12 +62,10 @@ class MyAuthBackend extends AuthProvider { class MyDirectoryBackend extends DirectoryProvider { constructor(options = {}) { - super(); - - this.options = options; + super(options); - // Access environment variables - this.dataPath = process.env.MY_DATA_PATH; + // Use options with env var fallback — enables multi-realm support + this.dataPath = options.dataPath ?? process.env.MY_DATA_PATH; // Initialize your data source } diff --git a/server/providers.js b/server/providers.js index b24f7c8..0df3f6f 100644 --- a/server/providers.js +++ b/server/providers.js @@ -13,12 +13,12 @@ class ProviderFactory { createAuthProvider(type, options = {}) { const AuthProvider = this.backendLoader.getAuthBackend(type); - return new AuthProvider(); + return new AuthProvider(options); } createDirectoryProvider(type, options = {}) { const DirectoryProvider = this.backendLoader.getDirectoryBackend(type); - return new DirectoryProvider(); + return new DirectoryProvider(options); } /** diff --git a/server/services/notificationService.js b/server/services/notificationService.js index 8e726a7..69b4157 100644 --- a/server/services/notificationService.js +++ b/server/services/notificationService.js @@ -1,10 +1,17 @@ const axios = require("axios"); class NotificationService { - static async sendAuthenticationNotification(username) { + /** + * Send an authentication push notification + * @param {string} username - The username requesting authentication + * @param {string} [notificationUrl] - Override URL (falls back to NOTIFICATION_URL env var) + * @returns {Promise} Response data with action field + */ + static async sendAuthenticationNotification(username, notificationUrl = null) { + const url = notificationUrl ?? process.env.NOTIFICATION_URL; try { const response = await axios.post( - process.env.NOTIFICATION_URL, + url, { username: username, title: "SSH Authentication Request", diff --git a/server/utils/sqlUtils.js b/server/utils/sqlUtils.js new file mode 100644 index 0000000..6028c66 --- /dev/null +++ b/server/utils/sqlUtils.js @@ -0,0 +1,21 @@ +const logger = require('./logger'); + +/** + * Build Sequelize options with optional SSL configuration + * Shared between SQL auth and directory providers to avoid duplication. + * + * @param {Object} options - Provider options (sqlSsl overrides env var) + * @returns {Object} Sequelize constructor options + */ +function buildSequelizeOptions(options = {}) { + const seqOptions = { logging: msg => logger.debug(msg) }; + + const sqlSsl = options.sqlSsl ?? process.env.SQL_SSL; + if (sqlSsl === 'false') { + seqOptions.dialectOptions = { ssl: false }; + } + + return seqOptions; +} + +module.exports = { buildSequelizeOptions }; From cff994ebdf2a1e31d0b0d26d1a1603f60d292d0a Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Mon, 23 Feb 2026 15:07:53 -0500 Subject: [PATCH 02/14] feat: multi-realm engine with per-baseDN routing --- npm/src/LdapEngine.js | 461 ++++++++++------ npm/test/unit/LdapEngine.realms.test.js | 495 ++++++++++++++++++ server/config/configurationLoader.js | 199 +++++++ server/serverMain.js | 51 +- .../unit/configurationLoader.realms.test.js | 193 +++++++ 5 files changed, 1227 insertions(+), 172 deletions(-) create mode 100644 npm/test/unit/LdapEngine.realms.test.js create mode 100644 server/test/unit/configurationLoader.realms.test.js diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index 4d303e5..c179cc2 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -10,7 +10,14 @@ const { /** * Core LDAP Engine for the LDAP Gateway - * Handles LDAP server setup, bind operations, and search operations + * Handles LDAP server setup, bind operations, and search operations. + * + * Supports multi-realm mode: each realm pairs a directory backend + auth chain + * with a baseDN. Searches are routed by baseDN; binds locate the user across + * realms sharing a baseDN and apply the correct auth chain. + * + * Backward compatible: when no `realms` option is provided, the engine wraps + * the legacy `authProviders`/`directoryProvider`/`baseDn` into one implicit realm. */ class LdapEngine extends EventEmitter { constructor(options = {}) { @@ -29,21 +36,69 @@ class LdapEngine extends EventEmitter { ...options }; - this.authProviders = options.authProviders; - this.directoryProvider = options.directoryProvider; + // Build realm data structures + this._initRealms(options); + + // Legacy single-provider refs (for backward compat in tests/external code) + this.authProviders = options.authProviders || this.allRealms[0]?.authProviders; + this.directoryProvider = options.directoryProvider || this.allRealms[0]?.directoryProvider; + this.server = null; this.logger = options.logger || console; this._stopping = false; } + /** + * Initialize realm data structures from options + * @private + */ + _initRealms(options) { + if (options.realms && Array.isArray(options.realms) && options.realms.length > 0) { + // Multi-realm mode: realms provided explicitly + this.allRealms = options.realms.map(r => ({ + name: r.name, + baseDn: r.baseDn, + directoryProvider: r.directoryProvider, + authProviders: r.authProviders || [] + })); + } else if (options.authProviders && options.directoryProvider) { + // Legacy single-realm mode: wrap into one implicit realm + const baseDn = options.baseDn || 'dc=localhost'; + this.allRealms = [{ + name: 'default', + baseDn, + directoryProvider: options.directoryProvider, + authProviders: options.authProviders + }]; + } else { + this.allRealms = []; + } + + // Index realms by baseDN (lowercased) for O(1) routing + this.realmsByBaseDn = new Map(); + for (const realm of this.allRealms) { + const key = realm.baseDn.toLowerCase(); + const existing = this.realmsByBaseDn.get(key) || []; + existing.push(realm); + this.realmsByBaseDn.set(key, existing); + } + } + /** * Initialize and start the LDAP server * @returns {Promise} */ async start() { - this.directoryProvider.initialize(); - for (const authProvider of this.authProviders) { - authProvider.initialize(); + // Initialize all realm providers + for (const realm of this.allRealms) { + if (realm.directoryProvider && typeof realm.directoryProvider.initialize === 'function') { + realm.directoryProvider.initialize(); + } + for (const authProvider of realm.authProviders) { + if (typeof authProvider.initialize === 'function') { + authProvider.initialize(); + } + } } // Create server options @@ -98,9 +153,12 @@ class LdapEngine extends EventEmitter { reject(normalizedError); } else { this.logger.info(`LDAP Server listening on port ${this.config.port}`); + const baseDns = [...new Set(this.allRealms.map(r => r.baseDn))]; this.emit('started', { port: this.config.port, baseDn: this.config.baseDn, + baseDns, + realms: this.allRealms.map(r => r.name), hasCertificate: !!(this.config.certificate && this.config.key) }); resolve(); @@ -141,31 +199,31 @@ class LdapEngine extends EventEmitter { } /** - * Cleanup all configured providers + * Cleanup all configured providers across all realms * @private */ async _cleanupProviders() { - // Cleanup directory provider - if (this.directoryProvider && typeof this.directoryProvider.cleanup === 'function') { - this.logger.debug('Cleaning up directory provider...'); - try { - await this.directoryProvider.cleanup(); - this.logger.debug('Directory provider cleaned up'); - } catch (err) { - this.logger.error('Error cleaning up directory provider:', err); + for (const realm of this.allRealms) { + // Cleanup directory provider + if (realm.directoryProvider && typeof realm.directoryProvider.cleanup === 'function') { + this.logger.debug(`Cleaning up directory provider for realm '${realm.name}'...`); + try { + await realm.directoryProvider.cleanup(); + this.logger.debug(`Directory provider for realm '${realm.name}' cleaned up`); + } catch (err) { + this.logger.error(`Error cleaning up directory provider for realm '${realm.name}':`, err); + } } - } - // Cleanup all auth providers - if (this.authProviders && Array.isArray(this.authProviders)) { - for (const [index, authProvider] of this.authProviders.entries()) { + // Cleanup auth providers + for (const [index, authProvider] of realm.authProviders.entries()) { if (authProvider && typeof authProvider.cleanup === 'function') { - this.logger.debug(`Cleaning up auth provider ${index + 1}...`); + this.logger.debug(`Cleaning up auth provider ${index + 1} for realm '${realm.name}'...`); try { await authProvider.cleanup(); - this.logger.debug(`Auth provider ${index + 1} cleaned up`); + this.logger.debug(`Auth provider ${index + 1} for realm '${realm.name}' cleaned up`); } catch (err) { - this.logger.error(`Error cleaning up auth provider ${index + 1}:`, err); + this.logger.error(`Error cleaning up auth provider ${index + 1} for realm '${realm.name}':`, err); } } } @@ -174,6 +232,7 @@ class LdapEngine extends EventEmitter { /** * Setup bind handlers for authentication + * Registers one handler per unique baseDN across all realms. * @private */ _setupBindHandlers() { @@ -186,54 +245,100 @@ class LdapEngine extends EventEmitter { return next(); }); - // Authenticated bind - catch all DNs under our base - this.server.bind(this.config.baseDn, (req, res, next) => { - const { username, password } = this._extractCredentials(req); - this.logger.debug("Authenticated bind request", { username, dn: req.dn.toString() }); + // Register one bind handler per unique baseDN + for (const [baseDn, realms] of this.realmsByBaseDn) { + this.server.bind(baseDn, (req, res, next) => { + const { username, password } = this._extractCredentials(req); + this.logger.debug("Authenticated bind request", { username, dn: req.dn.toString() }); - this.emit('bindRequest', { username, anonymous: false }); - - // Authenticate against all auth providers sequentially - all must return true - // Stop on first failure to prevent subsequent providers from executing - const authenticateSequentially = async () => { - for (const provider of this.authProviders) { - const result = await provider.authenticate(username, password, req); - if (result !== true) { - return false; + this.emit('bindRequest', { username, anonymous: false }); + + this._authenticateAcrossRealms(realms, username, password, req) + .then(({ authenticated, realmName }) => { + if (!authenticated) { + this.emit('bindFail', { username, reason: 'invalid_credentials' }); + const error = new ldap.InvalidCredentialsError('Invalid credentials'); + return next(error); + } + + this.logger.debug(`User ${username} authenticated via realm '${realmName}'`); + this.emit('bindSuccess', { username, anonymous: false, realm: realmName }); + res.end(); + return next(); + }) + .catch(error => { + this.logger.error("Bind error", { error, username }); + const { normalizeAuthError } = require('./utils/errorUtils'); + const normalizedError = normalizeAuthError(error); + this.emit('bindError', { username, error: normalizedError }); + return next(normalizedError); + }); + }); + } + } + + /** + * Find the user across realms sharing a baseDN and authenticate with the + * matching realm's auth chain. + * + * Flow: iterate realms in config order → first findUser() hit determines + * which realm's auth chain to use → authenticate sequentially against that + * realm's providers. + * + * @private + * @param {Array} realms - Realms sharing this baseDN + * @param {string} username + * @param {string} password + * @param {Object} req - LDAP request + * @returns {Promise<{authenticated: boolean, realmName: string|null}>} + */ + async _authenticateAcrossRealms(realms, username, password, req) { + // Find which realm owns this user + let matchedRealm = null; + let matchCount = 0; + + for (const realm of realms) { + try { + const user = await realm.directoryProvider.findUser(username); + if (user) { + matchCount++; + if (!matchedRealm) { + matchedRealm = realm; + } else { + this.logger.warn( + `User '${username}' found in multiple realms: '${matchedRealm.name}' and '${realm.name}'. ` + + `Using first match '${matchedRealm.name}'.` + ); } } - return true; - }; - - return authenticateSequentially() - .then(isAuthenticated => { - if (!isAuthenticated) { - this.emit('bindFail', { username, reason: 'invalid_credentials' }); - const error = new ldap.InvalidCredentialsError('Invalid credentials'); - return next(error); - } + } catch (err) { + this.logger.error(`Error finding user '${username}' in realm '${realm.name}':`, err); + } + } - this.emit('bindSuccess', { username, anonymous: false }); - res.end(); - return next(); - }) - .catch(error => { - this.logger.error("Bind error", { error, username }); - const { normalizeAuthError } = require('./utils/errorUtils'); - const normalizedError = normalizeAuthError(error); - this.emit('bindError', { username, error: normalizedError }); - return next(normalizedError); - }); - }); + if (!matchedRealm) { + this.logger.debug(`User '${username}' not found in any realm`); + return { authenticated: false, realmName: null }; + } + + // Authenticate sequentially against the matched realm's auth chain + for (const provider of matchedRealm.authProviders) { + const result = await provider.authenticate(username, password, req); + if (result !== true) { + return { authenticated: false, realmName: matchedRealm.name }; + } + } + + return { authenticated: true, realmName: matchedRealm.name }; } /** - * Setup search handlers for directory operations + * Setup search handlers for directory operations. + * Registers one handler per unique baseDN across all realms. * @private */ _setupSearchHandlers() { // RootDSE handler - handles queries to empty base DN ("") per RFC 4512 section 5.1 - // This must be registered as a separate route handler this.server.search('', (req, res, next) => this._handleRootDSE(req, res, next)); // Authorization middleware (if enabled) for normal searches @@ -242,7 +347,6 @@ class LdapEngine extends EventEmitter { return next(); } - // Check if connection has authenticated bindDN (not anonymous) const bindDN = req.connection.ldap.bindDN; const bindDNStr = bindDN ? bindDN.toString() : 'null'; const isAnonymous = !bindDN || bindDNStr === 'cn=anonymous'; @@ -256,46 +360,48 @@ class LdapEngine extends EventEmitter { return next(); }; - // Search handler with authorization middleware for normal directory searches - this.server.search(this.config.baseDn, authorizeSearch, async (req, res, next) => { - const filterStr = req.filter.toString(); - this.logger.debug(`LDAP Search - Filter: ${filterStr}, Attributes: ${req.attributes}`); - - let entryCount = 0; - const startTime = Date.now(); - - try { - this.emit('searchRequest', { - filter: filterStr, - attributes: req.attributes, - baseDn: req.baseObject.toString(), - scope: req.scope - }); - - entryCount = await this._handleSearch(filterStr, req.attributes, res); - - const duration = Date.now() - startTime; - this.emit('searchResponse', { - filter: filterStr, - attributes: req.attributes, - entryCount, - duration - }); - - this.logger.debug(`Search completed: ${entryCount} entries in ${duration}ms`); - res.end(); - } catch (error) { - this.logger.error("Search error", { error, filter: filterStr }); - const { normalizeSearchError } = require('./utils/errorUtils'); - const normalizedError = normalizeSearchError(error); - this.emit('searchError', { - filter: filterStr, - error: normalizedError, - duration: Date.now() - startTime - }); - return next(normalizedError); - } - }); + // Register one search handler per unique baseDN + for (const [baseDn, realms] of this.realmsByBaseDn) { + this.server.search(baseDn, authorizeSearch, async (req, res, next) => { + const filterStr = req.filter.toString(); + this.logger.debug(`LDAP Search - Filter: ${filterStr}, Attributes: ${req.attributes}`); + + let entryCount = 0; + const startTime = Date.now(); + + try { + this.emit('searchRequest', { + filter: filterStr, + attributes: req.attributes, + baseDn: req.baseObject.toString(), + scope: req.scope + }); + + entryCount = await this._handleMultiRealmSearch(realms, filterStr, req.attributes, res); + + const duration = Date.now() - startTime; + this.emit('searchResponse', { + filter: filterStr, + attributes: req.attributes, + entryCount, + duration + }); + + this.logger.debug(`Search completed: ${entryCount} entries in ${duration}ms`); + res.end(); + } catch (error) { + this.logger.error("Search error", { error, filter: filterStr }); + const { normalizeSearchError } = require('./utils/errorUtils'); + const normalizedError = normalizeSearchError(error); + this.emit('searchError', { + filter: filterStr, + error: normalizedError, + duration: Date.now() - startTime + }); + return next(normalizedError); + } + }); + } } /** @@ -319,72 +425,109 @@ class LdapEngine extends EventEmitter { } /** - * Handle search operations with proper filter parsing and entry creation + * Handle search across multiple realms sharing a baseDN. + * Queries each realm's directory provider, deduplicates entries by DN, + * and sends merged results. * @private + * @param {Array} realms - Realms sharing the same baseDN + * @param {string} filterStr - LDAP filter string + * @param {Array} attributes - Requested attributes + * @param {Object} res - ldapjs response object * @returns {number} Number of entries sent */ - async _handleSearch(filterStr, attributes, res) { + async _handleMultiRealmSearch(realms, filterStr, attributes, res) { + // Collect entries from all realms in parallel + const realmResults = await Promise.all( + realms.map(realm => this._handleRealmSearch(realm, filterStr, attributes)) + ); + + // Deduplicate entries by DN (first realm wins for same DN) + const seenDNs = new Set(); let entryCount = 0; + + for (const { entries, realmName } of realmResults) { + for (const entry of entries) { + const dnLower = entry.dn.toLowerCase(); + if (seenDNs.has(dnLower)) { + this.logger.debug(`Skipping duplicate DN from realm '${realmName}': ${entry.dn}`); + continue; + } + seenDNs.add(dnLower); + this.emit('entryFound', { type: entry._type || 'user', entry: entry.dn, realm: realmName }); + res.send(entry); + entryCount++; + } + } + + return entryCount; + } + + /** + * Handle search operations for a single realm. + * Returns entries array instead of sending directly to res. + * @private + * @param {Object} realm - Realm object with directoryProvider and baseDn + * @param {string} filterStr - LDAP filter string + * @param {Array} attributes - Requested attributes + * @returns {{ entries: Array, realmName: string }} + */ + async _handleRealmSearch(realm, filterStr, attributes) { + const entries = []; + const { directoryProvider, baseDn, name: realmName } = realm; const username = getUsernameFromFilter(filterStr); // Handle specific user requests if (username) { - this.logger.debug(`Searching for specific user: ${username}`); - const user = await this.directoryProvider.findUser(username); + this.logger.debug(`[${realmName}] Searching for specific user: ${username}`); + const user = await directoryProvider.findUser(username); if (user) { - const entry = createLdapEntry(user, this.config.baseDn); - this.emit('entryFound', { type: 'user', entry: entry.dn }); - res.send(entry); - entryCount = 1; + const entry = createLdapEntry(user, baseDn); + entry._type = 'user'; + entries.push(entry); } - return entryCount; + return { entries, realmName }; } // Handle all users requests if (isAllUsersRequest(filterStr, attributes)) { - this.logger.debug(`Searching for all users with filter: ${filterStr}`); - const users = await this.directoryProvider.getAllUsers(); - this.logger.debug(`Found ${users.length} users`); + this.logger.debug(`[${realmName}] Searching for all users with filter: ${filterStr}`); + const users = await directoryProvider.getAllUsers(); + this.logger.debug(`[${realmName}] Found ${users.length} users`); for (const user of users) { - const entry = createLdapEntry(user, this.config.baseDn); - this.emit('entryFound', { type: 'user', entry: entry.dn }); - res.send(entry); - entryCount++; + const entry = createLdapEntry(user, baseDn); + entry._type = 'user'; + entries.push(entry); } - return entryCount; + return { entries, realmName }; } // Handle group search requests if (isGroupSearchRequest(filterStr, attributes)) { - this.logger.debug(`Searching for groups with filter: ${filterStr}`); - const groups = await this.directoryProvider.findGroups(filterStr); - this.logger.debug(`Found ${groups.length} groups`); + this.logger.debug(`[${realmName}] Searching for groups with filter: ${filterStr}`); + const groups = await directoryProvider.findGroups(filterStr); + this.logger.debug(`[${realmName}] Found ${groups.length} groups`); for (const group of groups) { - const entry = createLdapGroupEntry(group, this.config.baseDn); - this.emit('entryFound', { type: 'group', entry: entry.dn }); - res.send(entry); - entryCount++; + const entry = createLdapGroupEntry(group, baseDn); + entry._type = 'group'; + entries.push(entry); } - return entryCount; + return { entries, realmName }; } // Handle mixed searches (both users and groups) if (isMixedSearchRequest(filterStr)) { - this.logger.debug(`Mixed search request with filter: ${filterStr}`); + this.logger.debug(`[${realmName}] Mixed search request with filter: ${filterStr}`); - // Parse cn value from filter for filtering const cnMatch = filterStr.match(/cn=([^)&|]+)/i); const cnFilter = cnMatch ? cnMatch[1].trim() : null; const isWildcard = cnFilter === '*'; - // Return users first - const users = await this.directoryProvider.getAllUsers(); - this.logger.debug(`Found ${users.length} users for mixed search`); + const users = await directoryProvider.getAllUsers(); + this.logger.debug(`[${realmName}] Found ${users.length} users for mixed search`); for (const user of users) { - // Filter by cn if specified (cn is the user's common name) if (cnFilter && !isWildcard) { const userCn = user.firstname && user.lastname ? `${user.firstname} ${user.lastname}` @@ -394,32 +537,28 @@ class LdapEngine extends EventEmitter { } } - const entry = createLdapEntry(user, this.config.baseDn); - this.emit('entryFound', { type: 'user', entry: entry.dn }); - res.send(entry); - entryCount++; + const entry = createLdapEntry(user, baseDn); + entry._type = 'user'; + entries.push(entry); } - // Then return groups - const groups = await this.directoryProvider.getAllGroups(); - this.logger.debug(`Found ${groups.length} groups for mixed search`); + const groups = await directoryProvider.getAllGroups(); + this.logger.debug(`[${realmName}] Found ${groups.length} groups for mixed search`); for (const group of groups) { - // Filter by cn if specified (cn is the group name) if (cnFilter && !isWildcard && group.name.toLowerCase() !== cnFilter.toLowerCase()) { continue; } - const entry = createLdapGroupEntry(group, this.config.baseDn); - this.emit('entryFound', { type: 'group', entry: entry.dn }); - res.send(entry); - entryCount++; + const entry = createLdapGroupEntry(group, baseDn); + entry._type = 'group'; + entries.push(entry); } - return entryCount; + return { entries, realmName }; } - this.logger.debug(`No matching search pattern found for filter: ${filterStr}`); - return entryCount; + this.logger.debug(`[${realmName}] No matching search pattern found for filter: ${filterStr}`); + return { entries, realmName }; } /** @@ -439,39 +578,39 @@ class LdapEngine extends EventEmitter { try { // Check scope - ldapjs uses numeric constants: 0='base', 1='one', 2='sub' - // We check both forms for compatibility with different ldapjs versions if (scope === 'base' || scope === 0) { this.emit('rootDSERequest', { filter: filterStr, attributes: requestedAttrs }); - // Determine which attributes to return based on request - // RootDSE attribute filtering rules (per RFC 4512): - // - No attributes = all attributes (both user and operational) - // - '*' with '+' = all user and operational attributes - // - '+' only = operational attributes only (namingContexts, supportedLDAPVersion) + objectClass - // - '*' only or '*' with specific names = user attributes + any specifically requested operational attributes - // - Specific names only = only those attributes + objectClass (which is always returned) + // Collect unique baseDNs (preserving original casing from realm config) + const seenDns = new Set(); + const allBaseDns = []; + for (const realm of this.allRealms) { + const key = realm.baseDn.toLowerCase(); + if (!seenDns.has(key)) { + seenDns.add(key); + allBaseDns.push(realm.baseDn); + } + } + + // RootDSE attribute filtering rules (per RFC 4512) const hasWildcard = requestedAttrs.includes('*'); const hasPlus = requestedAttrs.includes('+'); - const noAttrsRequested = requestedAttrs.length === 0; - // Build the entry attributes const attributes = { - objectClass: ['top'] // objectClass is always returned + objectClass: ['top'] }; - // Determine what to include if (hasWildcard && !hasPlus) { - // Specific attributes requested (no wildcards) requestedAttrs.forEach(attr => { const attrLower = attr.toLowerCase(); if (attrLower === 'namingcontexts') { - attributes.namingContexts = [this.config.baseDn]; + attributes.namingContexts = allBaseDns; } else if (attrLower === 'supportedldapversion') { attributes.supportedLDAPVersion = ['3']; } }); } else { - attributes.namingContexts = [this.config.baseDn]; + attributes.namingContexts = allBaseDns; attributes.supportedLDAPVersion = ['3']; } diff --git a/npm/test/unit/LdapEngine.realms.test.js b/npm/test/unit/LdapEngine.realms.test.js new file mode 100644 index 0000000..df511d0 --- /dev/null +++ b/npm/test/unit/LdapEngine.realms.test.js @@ -0,0 +1,495 @@ +// Unit Tests for LdapEngine Multi-Realm Support +// Tests realm routing, search aggregation, and backward compatibility + +const LdapEngine = require('../../src/LdapEngine'); +const { MockAuthProvider, MockDirectoryProvider } = require('../fixtures/mockProviders'); +const { baseDN } = require('../fixtures/testData'); +const net = require('net'); +const ldap = require('ldapjs'); + +function canConnect(port, host = '127.0.0.1', timeoutMs = 500) { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + const cleanup = () => { socket.removeAllListeners(); socket.destroy(); }; + socket.setTimeout(timeoutMs); + socket.once('connect', () => { cleanup(); resolve(true); }); + socket.once('timeout', () => { cleanup(); reject(new Error('Timeout')); }); + socket.once('error', (err) => { cleanup(); reject(err); }); + socket.connect(port, host); + }); +} + +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() +}; + +// Helper to create an ldap client for testing +function createClient(port) { + return ldap.createClient({ + url: `ldap://127.0.0.1:${port}`, + timeout: 5000, + connectTimeout: 5000 + }); +} + +// Promisified ldap client operations +function bindAsync(client, dn, password) { + return new Promise((resolve, reject) => { + client.bind(dn, password, (err) => err ? reject(err) : resolve()); + }); +} + +function searchAsync(client, base, opts) { + return new Promise((resolve, reject) => { + const entries = []; + client.search(base, opts, (err, res) => { + if (err) return reject(err); + res.on('searchEntry', (entry) => entries.push(entry)); + res.on('error', (err) => reject(err)); + res.on('end', () => resolve(entries)); + }); + }); +} + +function unbindAsync(client) { + return new Promise((resolve) => { + client.unbind((err) => resolve()); + }); +} + +describe('LdapEngine Multi-Realm', () => { + let engine; + const TEST_PORT = 3895; + + afterEach(async () => { + if (engine && engine.server) { + await engine.stop(); + } + }); + + describe('Initialization (_initRealms)', () => { + test('should initialize with explicit realms array', () => { + const auth1 = new MockAuthProvider({ name: 'realm1-auth' }); + const dir1 = new MockDirectoryProvider({ name: 'realm1-dir' }); + const auth2 = new MockAuthProvider({ name: 'realm2-auth' }); + const dir2 = new MockDirectoryProvider({ name: 'realm2-dir' }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { + name: 'company-a', + baseDn: 'dc=company-a,dc=com', + directoryProvider: dir1, + authProviders: [auth1] + }, + { + name: 'company-b', + baseDn: 'dc=company-b,dc=com', + directoryProvider: dir2, + authProviders: [auth2] + } + ] + }); + + expect(engine.allRealms).toHaveLength(2); + expect(engine.realmsByBaseDn.size).toBe(2); + expect(engine.realmsByBaseDn.has('dc=company-a,dc=com')).toBe(true); + expect(engine.realmsByBaseDn.has('dc=company-b,dc=com')).toBe(true); + }); + + test('should support multiple realms sharing the same baseDN', () => { + const auth1 = new MockAuthProvider({ name: 'realm1-auth' }); + const dir1 = new MockDirectoryProvider({ name: 'realm1-dir' }); + const auth2 = new MockAuthProvider({ name: 'realm2-auth' }); + const dir2 = new MockDirectoryProvider({ name: 'realm2-dir' }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { + name: 'realm-1', + baseDn: baseDN, + directoryProvider: dir1, + authProviders: [auth1] + }, + { + name: 'realm-2', + baseDn: baseDN, + directoryProvider: dir2, + authProviders: [auth2] + } + ] + }); + + expect(engine.allRealms).toHaveLength(2); + expect(engine.realmsByBaseDn.size).toBe(1); + expect(engine.realmsByBaseDn.get(baseDN)).toHaveLength(2); + }); + + test('should auto-wrap legacy options into single default realm', () => { + const auth = new MockAuthProvider(); + const dir = new MockDirectoryProvider(); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + baseDn: baseDN, + authProviders: [auth], + directoryProvider: dir, + logger: mockLogger + }); + + expect(engine.allRealms).toHaveLength(1); + expect(engine.allRealms[0].name).toBe('default'); + expect(engine.allRealms[0].baseDn).toBe(baseDN); + expect(engine.realmsByBaseDn.size).toBe(1); + // Legacy references should be preserved + expect(engine.authProviders).toEqual([auth]); + expect(engine.directoryProvider).toBe(dir); + }); + + test('should normalize baseDN to lowercase in realmsByBaseDn keys', () => { + const auth = new MockAuthProvider(); + const dir = new MockDirectoryProvider(); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { + name: 'test', + baseDn: 'DC=Example,DC=Com', + directoryProvider: dir, + authProviders: [auth] + } + ] + }); + + expect(engine.realmsByBaseDn.has('dc=example,dc=com')).toBe(true); + }); + }); + + describe('Multi-Realm Bind', () => { + test('should authenticate against the realm that owns the user', async () => { + const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; + const usersB = [{ username: 'bob', uid_number: 3001, gid_number: 3000, first_name: 'Bob', last_name: 'B' }]; + + const authA = new MockAuthProvider({ + name: 'auth-a', + validCredentials: new Map([['alice', 'pass-a']]) + }); + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: usersA, groups: [] }); + + const authB = new MockAuthProvider({ + name: 'auth-b', + validCredentials: new Map([['bob', 'pass-b']]) + }); + const dirB = new MockDirectoryProvider({ name: 'dir-b', users: usersB, groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [authA] }, + { name: 'realm-b', baseDn: baseDN, directoryProvider: dirB, authProviders: [authB] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + // Alice should authenticate through realm-a + await bindAsync(client, `uid=alice,ou=users,${baseDN}`, 'pass-a'); + expect(authA.callCount).toBe(1); + expect(authB.callCount).toBe(0); // realm-b should NOT be tried + + // Bob should authenticate through realm-b + authA.reset(); + const client2 = createClient(TEST_PORT); + try { + await bindAsync(client2, `uid=bob,ou=users,${baseDN}`, 'pass-b'); + expect(dirB.callCounts.findUser).toBeGreaterThanOrEqual(1); + expect(authB.callCount).toBe(1); + } finally { + await unbindAsync(client2); + } + } finally { + await unbindAsync(client); + } + }); + + test('should reject bind when user not found in any realm', async () => { + const authA = new MockAuthProvider({ name: 'auth-a' }); + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: [], groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [authA] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + await expect( + bindAsync(client, `uid=nonexistent,ou=users,${baseDN}`, 'anypass') + ).rejects.toThrow(); + } finally { + await unbindAsync(client); + } + }); + }); + + describe('Multi-Realm Search', () => { + test('should merge search results from multiple realms sharing same baseDN', async () => { + const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; + const usersB = [{ username: 'bob', uid_number: 3001, gid_number: 3000, first_name: 'Bob', last_name: 'B' }]; + + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: usersA, groups: [] }); + const dirB = new MockDirectoryProvider({ name: 'dir-b', users: usersB, groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + requireAuthForSearch: false, + realms: [ + { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [new MockAuthProvider()] }, + { name: 'realm-b', baseDn: baseDN, directoryProvider: dirB, authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + // Search for all users - should get results from both realms + const entries = await searchAsync(client, baseDN, { + filter: '(objectClass=posixAccount)', + scope: 'sub' + }); + + expect(entries.length).toBe(2); + } finally { + await unbindAsync(client); + } + }); + + test('should deduplicate entries by DN across realms', async () => { + // Same user in both realms - first realm wins + const sharedUser = { username: 'shared', uid_number: 5001, gid_number: 5000, first_name: 'Shared', last_name: 'User' }; + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: [sharedUser], groups: [] }); + const dirB = new MockDirectoryProvider({ name: 'dir-b', users: [sharedUser], groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + requireAuthForSearch: false, + realms: [ + { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [new MockAuthProvider()] }, + { name: 'realm-b', baseDn: baseDN, directoryProvider: dirB, authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + const entries = await searchAsync(client, baseDN, { + filter: '(uid=shared)', + scope: 'sub' + }); + + // Should deduplicate - only 1 entry even though both realms have user + expect(entries.length).toBe(1); + } finally { + await unbindAsync(client); + } + }); + + test('should search different baseDNs independently', async () => { + const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; + const usersB = [{ username: 'bob', uid_number: 3001, gid_number: 3000, first_name: 'Bob', last_name: 'B' }]; + + const baseDnA = 'dc=company-a,dc=com'; + const baseDnB = 'dc=company-b,dc=com'; + + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: usersA, groups: [] }); + const dirB = new MockDirectoryProvider({ name: 'dir-b', users: usersB, groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + requireAuthForSearch: false, + realms: [ + { name: 'company-a', baseDn: baseDnA, directoryProvider: dirA, authProviders: [new MockAuthProvider()] }, + { name: 'company-b', baseDn: baseDnB, directoryProvider: dirB, authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + // Search company-a - should only get alice + const entriesA = await searchAsync(client, baseDnA, { + filter: '(objectClass=posixAccount)', + scope: 'sub' + }); + expect(entriesA.length).toBe(1); + + // Search company-b - should only get bob + const entriesB = await searchAsync(client, baseDnB, { + filter: '(objectClass=posixAccount)', + scope: 'sub' + }); + expect(entriesB.length).toBe(1); + } finally { + await unbindAsync(client); + } + }); + }); + + describe('RootDSE Multi-Realm', () => { + test('should return all baseDNs in namingContexts', async () => { + const baseDnA = 'dc=company-a,dc=com'; + const baseDnB = 'dc=company-b,dc=com'; + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + requireAuthForSearch: false, + realms: [ + { name: 'company-a', baseDn: baseDnA, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] }, + { name: 'company-b', baseDn: baseDnB, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + const entries = await searchAsync(client, '', { + filter: '(objectClass=*)', + scope: 'base', + attributes: ['+'] + }); + + expect(entries.length).toBe(1); + const rootDSE = entries[0]; + + // ldapjs returns attributes as array of {type, values} objects + const ncAttr = rootDSE.attributes.find(a => a.type === 'namingContexts'); + const contexts = ncAttr ? ncAttr.values : []; + + // Should contain both baseDNs + expect(contexts).toContain(baseDnA); + expect(contexts).toContain(baseDnB); + + // Should contain both baseDNs + expect(contexts).toContain(baseDnA); + expect(contexts).toContain(baseDnB); + } finally { + await unbindAsync(client); + } + }); + }); + + describe('Backward Compatibility', () => { + test('should work identically with legacy single-provider options', async () => { + const auth = new MockAuthProvider(); + const testUsers = [ + { username: 'testuser', uid_number: 1001, gid_number: 1001, first_name: 'Test', last_name: 'User' }, + { username: 'admin', uid_number: 1000, gid_number: 1000, first_name: 'Admin', last_name: 'User' } + ]; + const dir = new MockDirectoryProvider({ users: testUsers, groups: [] }); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + baseDn: baseDN, + authProviders: [auth], + directoryProvider: dir, + logger: mockLogger, + requireAuthForSearch: false + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + // Bind should work + await bindAsync(client, `uid=testuser,ou=users,${baseDN}`, 'password123'); + expect(auth.callCount).toBe(1); + + // Search should work + const entries = await searchAsync(client, baseDN, { + filter: '(uid=testuser)', + scope: 'sub' + }); + expect(entries.length).toBe(1); + } finally { + await unbindAsync(client); + } + }); + + test('should preserve legacy directoryProvider and authProviders refs', () => { + const auth = new MockAuthProvider(); + const dir = new MockDirectoryProvider(); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + baseDn: baseDN, + authProviders: [auth], + directoryProvider: dir, + logger: mockLogger + }); + + expect(engine.directoryProvider).toBe(dir); + expect(engine.authProviders).toEqual([auth]); + }); + }); + + describe('Started Event', () => { + test('should emit started event with baseDns and realm names', async () => { + const startedInfo = await new Promise(async (resolve) => { + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'realm-a', baseDn: 'dc=a,dc=com', directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] }, + { name: 'realm-b', baseDn: 'dc=b,dc=com', directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + ] + }); + + engine.on('started', (info) => resolve(info)); + await engine.start(); + }); + + expect(startedInfo.baseDns).toContain('dc=a,dc=com'); + expect(startedInfo.baseDns).toContain('dc=b,dc=com'); + expect(startedInfo.realms).toContain('realm-a'); + expect(startedInfo.realms).toContain('realm-b'); + }); + }); +}); diff --git a/server/config/configurationLoader.js b/server/config/configurationLoader.js index 22b50fe..1405194 100644 --- a/server/config/configurationLoader.js +++ b/server/config/configurationLoader.js @@ -48,6 +48,8 @@ class ConfigurationLoader { unencrypted: process.env.LDAP_UNENCRYPTED === 'true' || process.env.LDAP_UNENCRYPTED === '1', backendDir: process.env.BACKEND_DIR || null, requireAuthForSearch: process.env.REQUIRE_AUTH_FOR_SEARCH !== 'false', + // Load realm configuration (null if not configured) + realms: this._loadRealmConfig(), // Load certificates - this handles all certificate logic ...(await this._loadCertificates()), // Load TLS configuration @@ -55,6 +57,94 @@ class ConfigurationLoader { }; } + /** + * Load realm configuration from REALM_CONFIG env var. + * Supports inline JSON string or file path. + * @private + * @returns {Array|null} Realm config array or null if not configured + */ + _loadRealmConfig() { + const realmConfig = process.env.REALM_CONFIG; + if (!realmConfig) { + return null; + } + + let realms; + const trimmed = realmConfig.trim(); + + // Try parsing as inline JSON first (starts with '[') + if (trimmed.startsWith('[')) { + try { + realms = JSON.parse(trimmed); + } catch (e) { + throw new Error(`Invalid REALM_CONFIG JSON: ${e.message}`); + } + } else { + // Treat as file path + try { + const content = fs.readFileSync(trimmed, 'utf8'); + realms = JSON.parse(content); + } catch (e) { + throw new Error(`Failed to load REALM_CONFIG from '${trimmed}': ${e.message}`); + } + } + + this._validateRealmConfig(realms); + logger.info(`Loaded ${realms.length} realm(s) from REALM_CONFIG`); + return realms; + } + + /** + * Validate realm configuration structure at startup. + * @private + * @param {*} realms - Parsed realm config to validate + * @throws {Error} If validation fails + */ + _validateRealmConfig(realms) { + if (!Array.isArray(realms)) { + throw new Error('REALM_CONFIG must be a JSON array of realm objects'); + } + if (realms.length === 0) { + throw new Error('REALM_CONFIG must contain at least one realm'); + } + + const seenNames = new Set(); + + for (let i = 0; i < realms.length; i++) { + const realm = realms[i]; + const prefix = `Realm[${i}]`; + + if (!realm.name) { + throw new Error(`${prefix}: 'name' is required`); + } + if (seenNames.has(realm.name)) { + throw new Error(`${prefix}: duplicate realm name '${realm.name}'`); + } + seenNames.add(realm.name); + + if (!realm.baseDn) { + throw new Error(`${prefix}: 'baseDn' is required`); + } + if (!realm.directory) { + throw new Error(`${prefix}: 'directory' is required`); + } + if (!realm.directory.backend) { + throw new Error(`${prefix}: 'directory.backend' is required`); + } + if (!realm.auth) { + throw new Error(`${prefix}: 'auth' is required`); + } + if (!Array.isArray(realm.auth.backends) || realm.auth.backends.length === 0) { + throw new Error(`${prefix}: 'auth.backends' must be a non-empty array`); + } + for (let j = 0; j < realm.auth.backends.length; j++) { + if (!realm.auth.backends[j].type) { + throw new Error(`${prefix}: 'auth.backends[${j}].type' is required`); + } + } + } + } + /** * Build LDAP Base DN from common name * @private @@ -67,6 +157,115 @@ class ConfigurationLoader { return commonName.split('.').map(part => `dc=${part}`).join(','); } + /** + * Load realm configuration from REALM_CONFIG env var. + * Supports both inline JSON strings and file paths. + * Returns null if REALM_CONFIG is not set (single-realm backward compat). + * @private + * @returns {Array|null} Array of realm config objects or null + */ + _loadRealmConfig() { + const realmConfigValue = process.env.REALM_CONFIG; + if (!realmConfigValue) { + return null; + } + + let realms; + const trimmed = realmConfigValue.trim(); + + // Try inline JSON first (starts with [ for an array) + if (trimmed.startsWith('[')) { + try { + realms = JSON.parse(trimmed); + } catch (err) { + logger.error(`Failed to parse REALM_CONFIG as JSON: ${err.message}`); + throw new Error(`Invalid REALM_CONFIG JSON: ${err.message}`); + } + } else { + // Treat as file path + const filePath = path.resolve(trimmed); + try { + const content = fs.readFileSync(filePath, 'utf8'); + realms = JSON.parse(content); + } catch (err) { + logger.error(`Failed to load REALM_CONFIG from file '${filePath}': ${err.message}`); + throw new Error(`Failed to load REALM_CONFIG from '${filePath}': ${err.message}`); + } + } + + // Validate realm config + this._validateRealmConfig(realms); + return realms; + } + + /** + * Validate realm configuration array. + * @private + * @param {*} realms - Parsed realm configuration + * @throws {Error} If validation fails + */ + _validateRealmConfig(realms) { + if (!Array.isArray(realms)) { + throw new Error('REALM_CONFIG must be a JSON array of realm objects'); + } + + if (realms.length === 0) { + throw new Error('REALM_CONFIG must contain at least one realm'); + } + + const names = new Set(); + for (let i = 0; i < realms.length; i++) { + const realm = realms[i]; + const prefix = `REALM_CONFIG[${i}]`; + + if (!realm || typeof realm !== 'object') { + throw new Error(`${prefix}: must be an object`); + } + + if (!realm.name || typeof realm.name !== 'string') { + throw new Error(`${prefix}: 'name' is required and must be a string`); + } + + if (names.has(realm.name)) { + throw new Error(`${prefix}: duplicate realm name '${realm.name}'`); + } + names.add(realm.name); + + if (!realm.baseDn || typeof realm.baseDn !== 'string') { + throw new Error(`${prefix} (${realm.name}): 'baseDn' is required and must be a string`); + } + + if (!realm.directory || typeof realm.directory !== 'object') { + throw new Error(`${prefix} (${realm.name}): 'directory' is required and must be an object`); + } + + if (!realm.directory.backend || typeof realm.directory.backend !== 'string') { + throw new Error(`${prefix} (${realm.name}): 'directory.backend' is required`); + } + + if (!realm.auth) { + throw new Error(`${prefix} (${realm.name}): 'auth' is required`); + } + + if (!Array.isArray(realm.auth.backends) || realm.auth.backends.length === 0) { + throw new Error(`${prefix} (${realm.name}): 'auth.backends' must be a non-empty array`); + } + + for (let j = 0; j < realm.auth.backends.length; j++) { + const backend = realm.auth.backends[j]; + if (!backend || typeof backend !== 'object') { + throw new Error(`${prefix} (${realm.name}): 'auth.backends[${j}]' must be an object`); + } + if (!backend.type || typeof backend.type !== 'string') { + throw new Error(`${prefix} (${realm.name}): 'auth.backends[${j}].type' is required`); + } + } + + logger.info(`Realm '${realm.name}' configured with baseDN '${realm.baseDn}', ` + + `directory: ${realm.directory.backend}, auth: [${realm.auth.backends.map(b => b.type).join(', ')}]`); + } + } + /** * Load SSL/TLS certificates (handles all certificate logic) * @private diff --git a/server/serverMain.js b/server/serverMain.js index 9fa3038..f7c069f 100644 --- a/server/serverMain.js +++ b/server/serverMain.js @@ -39,14 +39,8 @@ async function startServer(config) { logger.debug('Available auth backends:', availableBackends.auth); logger.debug('Available directory backends:', availableBackends.directory); - const selectedDirectory = providerFactory.createDirectoryProvider(config.directoryBackend); - const selectedBackends = config.authBackends.map((authBackend) => { - return providerFactory.createAuthProvider(authBackend); - }); - - // Create and configure LDAP engine - const ldapEngine = new LdapEngine({ - baseDn: config.ldapBaseDn, + // Build LdapEngine options + const engineOptions = { bindIp: config.bindIp, port: config.port, certificate: config.certContent, @@ -55,10 +49,45 @@ async function startServer(config) { tlsMaxVersion: config.tlsMaxVersion, tlsCiphers: config.tlsCiphers, logger: logger, - authProviders: selectedBackends, - directoryProvider: selectedDirectory, requireAuthForSearch: config.requireAuthForSearch - }); + }; + + if (config.realms) { + // Multi-realm mode: build realm objects from config + logger.info(`Initializing multi-realm mode with ${config.realms.length} realm(s)`); + engineOptions.realms = config.realms.map(realmCfg => { + const directoryProvider = providerFactory.createDirectoryProvider( + realmCfg.directory.backend, + realmCfg.directory.options || {} + ); + + const authProviders = realmCfg.auth.backends.map(backendCfg => + providerFactory.createAuthProvider(backendCfg.type, backendCfg.options || {}) + ); + + logger.info(`Realm '${realmCfg.name}': baseDN=${realmCfg.baseDn}, ` + + `directory=${realmCfg.directory.backend}, auth=[${realmCfg.auth.backends.map(b => b.type).join(', ')}]`); + + return { + name: realmCfg.name, + baseDn: realmCfg.baseDn, + directoryProvider, + authProviders + }; + }); + } else { + // Legacy single-realm mode + const selectedDirectory = providerFactory.createDirectoryProvider(config.directoryBackend); + const selectedBackends = config.authBackends.map((authBackend) => { + return providerFactory.createAuthProvider(authBackend); + }); + engineOptions.baseDn = config.ldapBaseDn; + engineOptions.authProviders = selectedBackends; + engineOptions.directoryProvider = selectedDirectory; + } + + // Create and configure LDAP engine + const ldapEngine = new LdapEngine(engineOptions); // Set up event listeners for logging and monitoring ldapEngine.on('started', (info) => { diff --git a/server/test/unit/configurationLoader.realms.test.js b/server/test/unit/configurationLoader.realms.test.js new file mode 100644 index 0000000..41f8262 --- /dev/null +++ b/server/test/unit/configurationLoader.realms.test.js @@ -0,0 +1,193 @@ +// Unit Tests for ConfigurationLoader Realm Config +// Tests _loadRealmConfig and _validateRealmConfig + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +// We need to require ConfigurationLoader after setting up env +let ConfigurationLoader; + +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() +}; + +// Mock logger module +jest.mock('../../utils/logger', () => mockLogger); + +// Mock dotenv to prevent loading actual .env files +jest.mock('dotenv', () => ({ config: jest.fn() })); + +beforeEach(() => { + jest.clearAllMocks(); + // Reset module cache so each test gets fresh ConfigurationLoader + jest.resetModules(); + ConfigurationLoader = require('../../config/configurationLoader'); +}); + +describe('ConfigurationLoader._loadRealmConfig', () => { + test('should return null when REALM_CONFIG is not set', () => { + delete process.env.REALM_CONFIG; + const loader = new ConfigurationLoader(); + const result = loader._loadRealmConfig(); + expect(result).toBeNull(); + }); + + test('should parse inline JSON array', () => { + const realms = [ + { + name: 'test-realm', + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + } + ]; + process.env.REALM_CONFIG = JSON.stringify(realms); + const loader = new ConfigurationLoader(); + const result = loader._loadRealmConfig(); + expect(result).toEqual(realms); + delete process.env.REALM_CONFIG; + }); + + test('should load from file path', () => { + const realms = [ + { + name: 'file-realm', + baseDn: 'dc=file,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + } + ]; + const tmpFile = path.join(os.tmpdir(), `realm-config-test-${Date.now()}.json`); + fs.writeFileSync(tmpFile, JSON.stringify(realms)); + + try { + process.env.REALM_CONFIG = tmpFile; + const loader = new ConfigurationLoader(); + const result = loader._loadRealmConfig(); + expect(result).toEqual(realms); + } finally { + fs.unlinkSync(tmpFile); + delete process.env.REALM_CONFIG; + } + }); + + test('should throw on invalid JSON string', () => { + process.env.REALM_CONFIG = '[{invalid json}]'; + const loader = new ConfigurationLoader(); + expect(() => loader._loadRealmConfig()).toThrow('Invalid REALM_CONFIG JSON'); + delete process.env.REALM_CONFIG; + }); + + test('should throw on non-existent file path', () => { + process.env.REALM_CONFIG = '/nonexistent/path/realms.json'; + const loader = new ConfigurationLoader(); + expect(() => loader._loadRealmConfig()).toThrow('Failed to load REALM_CONFIG'); + delete process.env.REALM_CONFIG; + }); +}); + +describe('ConfigurationLoader._validateRealmConfig', () => { + let loader; + + beforeEach(() => { + loader = new ConfigurationLoader(); + }); + + test('should accept valid realm config', () => { + const realms = [ + { + name: 'valid-realm', + baseDn: 'dc=valid,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + } + ]; + expect(() => loader._validateRealmConfig(realms)).not.toThrow(); + }); + + test('should reject non-array', () => { + expect(() => loader._validateRealmConfig({})).toThrow('must be a JSON array'); + }); + + test('should reject empty array', () => { + expect(() => loader._validateRealmConfig([])).toThrow('at least one realm'); + }); + + test('should reject missing name', () => { + expect(() => loader._validateRealmConfig([{ + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + }])).toThrow("'name' is required"); + }); + + test('should reject duplicate realm names', () => { + expect(() => loader._validateRealmConfig([ + { name: 'dup', baseDn: 'dc=a,dc=com', directory: { backend: 'sql' }, auth: { backends: [{ type: 'sql' }] } }, + { name: 'dup', baseDn: 'dc=b,dc=com', directory: { backend: 'sql' }, auth: { backends: [{ type: 'sql' }] } } + ])).toThrow("duplicate realm name 'dup'"); + }); + + test('should reject missing baseDn', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + directory: { backend: 'sql' }, + auth: { backends: [{ type: 'sql' }] } + }])).toThrow("'baseDn' is required"); + }); + + test('should reject missing directory', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + auth: { backends: [{ type: 'sql' }] } + }])).toThrow("'directory' is required"); + }); + + test('should reject missing directory.backend', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + directory: {}, + auth: { backends: [{ type: 'sql' }] } + }])).toThrow("'directory.backend' is required"); + }); + + test('should reject missing auth', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' } + }])).toThrow("'auth' is required"); + }); + + test('should reject empty auth.backends', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [] } + }])).toThrow("'auth.backends' must be a non-empty array"); + }); + + test('should reject auth backend without type', () => { + expect(() => loader._validateRealmConfig([{ + name: 'test', + baseDn: 'dc=test,dc=com', + directory: { backend: 'sql' }, + auth: { backends: [{}] } + }])).toThrow("'auth.backends[0].type' is required"); + }); + + test('should accept multiple valid realms with shared baseDN', () => { + const realms = [ + { name: 'realm-a', baseDn: 'dc=shared,dc=com', directory: { backend: 'sql' }, auth: { backends: [{ type: 'sql' }] } }, + { name: 'realm-b', baseDn: 'dc=shared,dc=com', directory: { backend: 'mongodb' }, auth: { backends: [{ type: 'mongodb' }] } } + ]; + expect(() => loader._validateRealmConfig(realms)).not.toThrow(); + }); +}); From 1a667e5a0eb29f4238e38bc98510bafbdd2de0ea Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Mon, 23 Feb 2026 18:41:46 -0500 Subject: [PATCH 03/14] Add per-user auth_backends override to bypass or customize the authentication chain on a per-user basis. --- docker/sql/init.sql | 14 +- npm/src/LdapEngine.js | 72 +++++- npm/test/fixtures/testData.js | 13 ++ npm/test/unit/LdapEngine.realms.test.js | 285 +++++++++++++++++++++++- server/realms.example.json | 58 +++++ server/serverMain.js | 14 ++ 6 files changed, 444 insertions(+), 12 deletions(-) create mode 100644 server/realms.example.json diff --git a/docker/sql/init.sql b/docker/sql/init.sql index 94da52c..fbdd9ba 100644 --- a/docker/sql/init.sql +++ b/docker/sql/init.sql @@ -28,16 +28,18 @@ CREATE TABLE IF NOT EXISTS users ( uid_number INT UNIQUE, gid_number INT, home_directory VARCHAR(200), + auth_backends VARCHAR(255) DEFAULT NULL, FOREIGN KEY (gid_number) REFERENCES `groups`(gid_number) ); -- 4. Now insert users (gid_number matches existing groups) +-- Passwords are bcrypt-hashed: ann=maya, abrol=abrol, evan=evan, hrits=maya, chris=chris INSERT INTO users (username, password, full_name, email, uid_number, gid_number, home_directory) VALUES - ('ann', 'maya', 'Ann', 'ann@mieweb.com', 1001, 1001, '/home/ann'), - ('abrol','abrol', 'Abrol', 'abrol@mieweb.com', 1002, 1002, '/home/abrol'), - ('evan', 'evan', 'Evan Pant', 'evan@mieweb.com', 1003, 1003, '/home/evan'), - ('hrits', 'maya','Hrits Pant', 'hrits@mieweb.com', 1004, 1004, '/home/hrits'), - ('chris', 'chris','Chris Evans', 'chris@mieweb.com', 1005, 1005, '/home/chris'); + ('ann', '$2b$10$cA9hzCdUMRdyCW/Q7uXJVuPhVeWsyPgt0iMovTobGEBEeJz0B9EVy', 'Ann', 'ann@mieweb.com', 1001, 1001, '/home/ann'), + ('abrol','$2b$10$gS9ofBYeNBq/OBSHwfZFoehE5v6HcDn1n7ttHORNTMRupFqHyHJt6', 'Abrol', 'abrol@mieweb.com', 1002, 1002, '/home/abrol'), + ('evan', '$2b$10$4jU4zFDYpBvKw1tqWh32c.6FZ5/dVzob7oOh.CxD3meFrUgHdTuAS', 'Evan Pant', 'evan@mieweb.com', 1003, 1003, '/home/evan'), + ('hrits', '$2b$10$QG9DsntCbOa/.eSOXseEIeIhVSi7sPIUcpx/teHN95GIELCQMbl1S','Hrits Pant', 'hrits@mieweb.com', 1004, 1004, '/home/hrits'), + ('chris', '$2b$10$fyBG6ofzr1yAJN9s3j1Jx.v0/JYuNLepnyaHKgYA4Fvf8EYJsEkP.','Chris Evans', 'chris@mieweb.com', 1005, 1005, '/home/chris'); -- 5. Add secondary groups INSERT INTO `groups` (gid_number, name, description, member_uids) VALUES @@ -51,7 +53,7 @@ CREATE TABLE IF NOT EXISTS user_groups ( group_id INT, PRIMARY KEY (user_id, group_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (group_id) REFERENCES `groups`(gid) ON DELETE CASCADE + FOREIGN KEY (group_id) REFERENCES `groups`(gid_number) ON DELETE CASCADE ); INSERT INTO user_groups (user_id, group_id) diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index c179cc2..95bc13b 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -43,6 +43,10 @@ class LdapEngine extends EventEmitter { this.authProviders = options.authProviders || this.allRealms[0]?.authProviders; this.directoryProvider = options.directoryProvider || this.allRealms[0]?.directoryProvider; + // Auth provider registry for per-user auth override (Phase 3) + // Maps provider type name → AuthProvider instance + this.authProviderRegistry = options.authProviderRegistry || new Map(); + this.server = null; this.logger = options.logger || console; this._stopping = false; @@ -279,11 +283,12 @@ class LdapEngine extends EventEmitter { /** * Find the user across realms sharing a baseDN and authenticate with the - * matching realm's auth chain. + * matching realm's auth chain (or per-user override if auth_backends is set). * * Flow: iterate realms in config order → first findUser() hit determines - * which realm's auth chain to use → authenticate sequentially against that - * realm's providers. + * which realm's auth chain to use → if the user record has `auth_backends`, + * resolve an override chain from the authProviderRegistry → otherwise fall + * back to the realm's default auth providers → authenticate sequentially. * * @private * @param {Array} realms - Realms sharing this baseDN @@ -295,6 +300,7 @@ class LdapEngine extends EventEmitter { async _authenticateAcrossRealms(realms, username, password, req) { // Find which realm owns this user let matchedRealm = null; + let matchedUser = null; let matchCount = 0; for (const realm of realms) { @@ -304,6 +310,7 @@ class LdapEngine extends EventEmitter { matchCount++; if (!matchedRealm) { matchedRealm = realm; + matchedUser = user; } else { this.logger.warn( `User '${username}' found in multiple realms: '${matchedRealm.name}' and '${realm.name}'. ` + @@ -321,8 +328,11 @@ class LdapEngine extends EventEmitter { return { authenticated: false, realmName: null }; } - // Authenticate sequentially against the matched realm's auth chain - for (const provider of matchedRealm.authProviders) { + // Resolve the auth chain: per-user override or realm default + const authChain = this._resolveAuthChain(matchedRealm, matchedUser, username); + + // Authenticate sequentially against the resolved auth chain + for (const provider of authChain) { const result = await provider.authenticate(username, password, req); if (result !== true) { return { authenticated: false, realmName: matchedRealm.name }; @@ -332,6 +342,58 @@ class LdapEngine extends EventEmitter { return { authenticated: true, realmName: matchedRealm.name }; } + /** + * Resolve the auth provider chain for a user. + * + * If the user record has an `auth_backends` field (comma-separated provider + * type names), look up each name in the authProviderRegistry to build a + * per-user override chain. If `auth_backends` is null/undefined/empty, + * fall back to the realm's default auth providers. + * + * @private + * @param {Object} realm - The matched realm + * @param {Object} user - The user record from the directory provider + * @param {string} username - Username (for logging) + * @returns {Array} Array of auth providers to authenticate against + */ + _resolveAuthChain(realm, user, username) { + const userBackends = user.auth_backends; + + if (!userBackends || (typeof userBackends === 'string' && userBackends.trim() === '')) { + // No per-user override — use realm defaults + return realm.authProviders; + } + + // Parse comma-separated backend names + const backendNames = typeof userBackends === 'string' + ? userBackends.split(',').map(s => s.trim()).filter(Boolean) + : []; + + if (backendNames.length === 0) { + return realm.authProviders; + } + + // Resolve each name from the registry + const overrideChain = []; + for (const name of backendNames) { + const provider = this.authProviderRegistry.get(name); + if (!provider) { + this.logger.error( + `User '${username}' has auth_backends='${userBackends}' but backend '${name}' ` + + `is not registered. Failing authentication for security.` + ); + throw new Error(`Unknown auth backend '${name}' for user '${username}'`); + } + overrideChain.push(provider); + } + + this.logger.debug( + `User '${username}' using per-user auth override: [${backendNames.join(', ')}]` + ); + + return overrideChain; + } + /** * Setup search handlers for directory operations. * Registers one handler per unique baseDN across all realms. diff --git a/npm/test/fixtures/testData.js b/npm/test/fixtures/testData.js index 8ad4446..737124d 100644 --- a/npm/test/fixtures/testData.js +++ b/npm/test/fixtures/testData.js @@ -38,6 +38,19 @@ const testUsers = [ userPassword: 'test123', homeDirectory: '/home/jdoe', loginShell: '/bin/bash' + }, + { + username: 'mfauser', + uid: 1003, + gidNumber: 1001, + cn: 'MFA User', + sn: 'MFA', + givenName: 'MFA', + mail: 'mfauser@example.com', + userPassword: 'mfa123', + homeDirectory: '/home/mfauser', + loginShell: '/bin/bash', + auth_backends: 'mock-auth' } ]; diff --git a/npm/test/unit/LdapEngine.realms.test.js b/npm/test/unit/LdapEngine.realms.test.js index df511d0..f52ff0e 100644 --- a/npm/test/unit/LdapEngine.realms.test.js +++ b/npm/test/unit/LdapEngine.realms.test.js @@ -2,7 +2,7 @@ // Tests realm routing, search aggregation, and backward compatibility const LdapEngine = require('../../src/LdapEngine'); -const { MockAuthProvider, MockDirectoryProvider } = require('../fixtures/mockProviders'); +const { MockAuthProvider, MockDirectoryProvider, MockNotificationAuthProvider } = require('../fixtures/mockProviders'); const { baseDN } = require('../fixtures/testData'); const net = require('net'); const ldap = require('ldapjs'); @@ -492,4 +492,287 @@ describe('LdapEngine Multi-Realm', () => { expect(startedInfo.realms).toContain('realm-b'); }); }); + + describe('Per-User Auth Override (Phase 3)', () => { + describe('_resolveAuthChain', () => { + test('should return realm default providers when user has no auth_backends', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const realm = engine.allRealms[0]; + const user = { username: 'testuser', auth_backends: null }; + + const chain = engine._resolveAuthChain(realm, user, 'testuser'); + expect(chain).toEqual([realmAuth]); + }); + + test('should return realm default providers when auth_backends is empty string', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: '' }, 'testuser'); + expect(chain).toEqual([realmAuth]); + }); + + test('should return realm default providers when auth_backends is undefined', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser' }, 'testuser'); + expect(chain).toEqual([realmAuth]); + }); + + test('should resolve per-user override from registry', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + const overrideAuth = new MockAuthProvider({ name: 'custom-auth' }); + const registry = new Map([['custom-auth', overrideAuth]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'custom-auth' }, 'testuser'); + expect(chain).toEqual([overrideAuth]); + expect(chain).not.toContain(realmAuth); + }); + + test('should resolve multiple comma-separated backends', () => { + const providerA = new MockAuthProvider({ name: 'auth-a' }); + const providerB = new MockAuthProvider({ name: 'auth-b' }); + const registry = new Map([['auth-a', providerA], ['auth-b', providerB]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'auth-a, auth-b' }, 'testuser'); + expect(chain).toHaveLength(2); + expect(chain[0]).toBe(providerA); + expect(chain[1]).toBe(providerB); + }); + + test('should throw for unknown backend in auth_backends (fail-loud)', () => { + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: new Map(), + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + ] + }); + + expect(() => { + engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'nonexistent' }, 'testuser'); + }).toThrow("Unknown auth backend 'nonexistent'"); + }); + }); + + describe('End-to-end per-user auth override', () => { + test('should use per-user auth override for bind when auth_backends is set', async () => { + const overrideAuth = new MockAuthProvider({ + name: 'override-auth', + validCredentials: new Map([['mfauser', 'override-pass']]) + }); + const realmAuth = new MockAuthProvider({ + name: 'realm-auth', + validCredentials: new Map([['testuser', 'password123']]) + }); + + const users = [ + { username: 'testuser', uid_number: 1001, gid_number: 1001, first_name: 'Test', last_name: 'User' }, + { username: 'mfauser', uid_number: 1003, gid_number: 1001, first_name: 'MFA', last_name: 'User', auth_backends: 'override-auth' } + ]; + + const registry = new Map([['override-auth', overrideAuth], ['realm-auth', realmAuth]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [realmAuth] } + ] + }); + + await engine.start(); + + // testuser should use realm default auth (realmAuth) + const client1 = createClient(TEST_PORT); + try { + await bindAsync(client1, `uid=testuser,ou=users,${baseDN}`, 'password123'); + expect(realmAuth.callCount).toBe(1); + expect(overrideAuth.callCount).toBe(0); + } finally { + await unbindAsync(client1); + } + + realmAuth.reset(); + overrideAuth.reset(); + + // mfauser should use per-user override auth (overrideAuth) + const client2 = createClient(TEST_PORT); + try { + await bindAsync(client2, `uid=mfauser,ou=users,${baseDN}`, 'override-pass'); + expect(overrideAuth.callCount).toBe(1); + expect(realmAuth.callCount).toBe(0); // realm auth should NOT be called + } finally { + await unbindAsync(client2); + } + }); + + test('should reject bind when per-user override auth fails', async () => { + const overrideAuth = new MockAuthProvider({ + name: 'override-auth', + validCredentials: new Map([['mfauser', 'correct-pass']]) + }); + + const users = [ + { username: 'mfauser', uid_number: 1003, gid_number: 1001, first_name: 'MFA', last_name: 'User', auth_backends: 'override-auth' } + ]; + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: new Map([['override-auth', overrideAuth]]), + realms: [ + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + await expect( + bindAsync(client, `uid=mfauser,ou=users,${baseDN}`, 'wrong-pass') + ).rejects.toThrow(); + expect(overrideAuth.callCount).toBe(1); + } finally { + await unbindAsync(client); + } + }); + + test('should reject bind when auth_backends references unknown provider', async () => { + const users = [ + { username: 'baduser', uid_number: 1099, gid_number: 1001, first_name: 'Bad', last_name: 'User', auth_backends: 'nonexistent-backend' } + ]; + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: new Map(), + realms: [ + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + await expect( + bindAsync(client, `uid=baduser,ou=users,${baseDN}`, 'anypass') + ).rejects.toThrow(); + } finally { + await unbindAsync(client); + } + }); + + test('should use MFA bypass pattern: per-user override skips notification provider', async () => { + // Realm default: sql + notification (MFA) + const sqlAuth = new MockAuthProvider({ + name: 'sql-auth', + validCredentials: new Map([['normaluser', 'pass123'], ['serviceuser', 'svc-pass']]) + }); + const notificationAuth = new MockNotificationAuthProvider({ + notificationShouldSucceed: true + }); + + // Service user has auth_backends='sql-auth' — skips notification MFA + const users = [ + { username: 'normaluser', uid_number: 2001, gid_number: 2000, first_name: 'Normal', last_name: 'User' }, + { username: 'serviceuser', uid_number: 2002, gid_number: 2000, first_name: 'Service', last_name: 'Account', auth_backends: 'sql-auth' } + ]; + + const registry = new Map([['sql-auth', sqlAuth]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { + name: 'mfa-realm', + baseDn: baseDN, + directoryProvider: new MockDirectoryProvider({ users, groups: [] }), + authProviders: [sqlAuth, notificationAuth] + } + ] + }); + + await engine.start(); + + // Normal user goes through both sql + notification + const client1 = createClient(TEST_PORT); + try { + await bindAsync(client1, `uid=normaluser,ou=users,${baseDN}`, 'pass123'); + expect(sqlAuth.callCount).toBe(1); + expect(notificationAuth.callCount).toBe(1); + } finally { + await unbindAsync(client1); + } + + sqlAuth.reset(); + notificationAuth.callCount = 0; + + // Service user only goes through sql (MFA bypassed) + const client2 = createClient(TEST_PORT); + try { + await bindAsync(client2, `uid=serviceuser,ou=users,${baseDN}`, 'svc-pass'); + expect(sqlAuth.callCount).toBe(1); + expect(notificationAuth.callCount).toBe(0); // MFA skipped! + } finally { + await unbindAsync(client2); + } + }); + }); + }); }); diff --git a/server/realms.example.json b/server/realms.example.json new file mode 100644 index 0000000..ed2d38a --- /dev/null +++ b/server/realms.example.json @@ -0,0 +1,58 @@ +[ + { + "name": "example-corp", + "baseDn": "dc=example,dc=com", + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:password@localhost:3306/ldap_db", + "ldapBaseDn": "dc=example,dc=com", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?", + "sqlQueryAllUsers": "SELECT * FROM users", + "sqlQueryAllGroups": "SELECT * FROM `groups`", + "sqlQueryGroupsByMember": "SELECT * FROM `groups` g WHERE JSON_CONTAINS(g.member_uids, JSON_QUOTE(?))" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:password@localhost:3306/ldap_db" + } + } + ] + } + }, + { + "name": "example-mfa", + "baseDn": "dc=secure,dc=example,dc=com", + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:password@localhost:3306/ldap_db", + "ldapBaseDn": "dc=secure,dc=example,dc=com", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?", + "sqlQueryAllUsers": "SELECT * FROM users", + "sqlQueryAllGroups": "SELECT * FROM `groups`", + "sqlQueryGroupsByMember": "SELECT * FROM `groups` g WHERE JSON_CONTAINS(g.member_uids, JSON_QUOTE(?))" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:password@localhost:3306/ldap_db" + } + }, + { + "type": "notification", + "options": { + "notificationUrl": "https://your-mfa-service.example.com/notify" + } + } + ] + } + } +] diff --git a/server/serverMain.js b/server/serverMain.js index f7c069f..89ecbcd 100644 --- a/server/serverMain.js +++ b/server/serverMain.js @@ -52,6 +52,20 @@ async function startServer(config) { requireAuthForSearch: config.requireAuthForSearch }; + // Build global auth provider registry for per-user auth override (Phase 3) + // Maps provider type name → AuthProvider instance for all available backends + const authProviderRegistry = new Map(); + for (const backendType of availableBackends.auth) { + try { + const provider = providerFactory.createAuthProvider(backendType); + authProviderRegistry.set(backendType, provider); + logger.debug(`Registered auth backend '${backendType}' in provider registry`); + } catch (err) { + logger.debug(`Skipping auth backend '${backendType}' for registry: ${err.message}`); + } + } + engineOptions.authProviderRegistry = authProviderRegistry; + if (config.realms) { // Multi-realm mode: build realm objects from config logger.info(`Initializing multi-realm mode with ${config.realms.length} realm(s)`); From 2915ae58da5360cdb19f284897aaa8e2023b5dfb Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Tue, 24 Feb 2026 13:25:19 -0500 Subject: [PATCH 04/14] fix: build auth provider registry from realm instances --- server/serverMain.js | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/server/serverMain.js b/server/serverMain.js index 89ecbcd..dc2b510 100644 --- a/server/serverMain.js +++ b/server/serverMain.js @@ -55,16 +55,6 @@ async function startServer(config) { // Build global auth provider registry for per-user auth override (Phase 3) // Maps provider type name → AuthProvider instance for all available backends const authProviderRegistry = new Map(); - for (const backendType of availableBackends.auth) { - try { - const provider = providerFactory.createAuthProvider(backendType); - authProviderRegistry.set(backendType, provider); - logger.debug(`Registered auth backend '${backendType}' in provider registry`); - } catch (err) { - logger.debug(`Skipping auth backend '${backendType}' for registry: ${err.message}`); - } - } - engineOptions.authProviderRegistry = authProviderRegistry; if (config.realms) { // Multi-realm mode: build realm objects from config @@ -75,9 +65,15 @@ async function startServer(config) { realmCfg.directory.options || {} ); - const authProviders = realmCfg.auth.backends.map(backendCfg => - providerFactory.createAuthProvider(backendCfg.type, backendCfg.options || {}) - ); + const authProviders = realmCfg.auth.backends.map(backendCfg => { + const provider = providerFactory.createAuthProvider(backendCfg.type, backendCfg.options || {}); + // Register each realm's auth providers in the global registry (first one wins per type) + if (!authProviderRegistry.has(backendCfg.type)) { + authProviderRegistry.set(backendCfg.type, provider); + logger.debug(`Registered auth backend '${backendCfg.type}' in provider registry from realm '${realmCfg.name}'`); + } + return provider; + }); logger.info(`Realm '${realmCfg.name}': baseDN=${realmCfg.baseDn}, ` + `directory=${realmCfg.directory.backend}, auth=[${realmCfg.auth.backends.map(b => b.type).join(', ')}]`); @@ -98,8 +94,19 @@ async function startServer(config) { engineOptions.baseDn = config.ldapBaseDn; engineOptions.authProviders = selectedBackends; engineOptions.directoryProvider = selectedDirectory; + + // Register legacy auth providers in the registry + for (const backendType of config.authBackends) { + const idx = config.authBackends.indexOf(backendType); + if (!authProviderRegistry.has(backendType)) { + authProviderRegistry.set(backendType, selectedBackends[idx]); + logger.debug(`Registered auth backend '${backendType}' in provider registry`); + } + } } + engineOptions.authProviderRegistry = authProviderRegistry; + // Create and configure LDAP engine const ldapEngine = new LdapEngine(engineOptions); From 0bb8a15bf1419ddbf2625292d018e643bdb96e43 Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Mon, 2 Mar 2026 17:53:54 -0500 Subject: [PATCH 05/14] Fix sql tests --- .../test/integration/auth/mysql.auth.test.js | 8 +++++++- .../integration/auth/postgres.auth.test.js | 10 ++++++++-- .../test/integration/auth/sqlite.auth.test.js | 20 +++++++++++++------ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/server/test/integration/auth/mysql.auth.test.js b/server/test/integration/auth/mysql.auth.test.js index be158eb..def090e 100644 --- a/server/test/integration/auth/mysql.auth.test.js +++ b/server/test/integration/auth/mysql.auth.test.js @@ -20,6 +20,12 @@ function configureEnv() { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; } +const directoryStub = { + initialize: async () => {}, + cleanup: async () => {}, + findUser: async (username) => ({ username }), +}; + maybeDescribe('MySQL Auth Backend (real DB) - Integration', () => { let engine; let conn; @@ -28,7 +34,7 @@ maybeDescribe('MySQL Auth Backend (real DB) - Integration', () => { async function startServer() { configureEnv(); const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); client = createClient(); return client; diff --git a/server/test/integration/auth/postgres.auth.test.js b/server/test/integration/auth/postgres.auth.test.js index 588f7ee..b5e4e23 100644 --- a/server/test/integration/auth/postgres.auth.test.js +++ b/server/test/integration/auth/postgres.auth.test.js @@ -14,6 +14,12 @@ const url = process.env.SQL_URI || 'postgres://testuser:testpass@127.0.0.1:25432 function createClient() { return ldap.createClient({ url: `ldap://127.0.0.1:${port}` }); } +const directoryStub = { + initialize: async () => {}, + cleanup: async () => {}, + findUser: async (username) => ({ username }), +}; + async function seedPostgres() { const client = new Client({ connectionString: url }); await client.connect(); @@ -33,7 +39,7 @@ maybeDescribe('PostgreSQL Auth Backend (real DB) - Integration', () => { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); const client = createClient(); @@ -46,7 +52,7 @@ maybeDescribe('PostgreSQL Auth Backend (real DB) - Integration', () => { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); const client = createClient(); diff --git a/server/test/integration/auth/sqlite.auth.test.js b/server/test/integration/auth/sqlite.auth.test.js index 4a9395f..e8e385e 100644 --- a/server/test/integration/auth/sqlite.auth.test.js +++ b/server/test/integration/auth/sqlite.auth.test.js @@ -34,15 +34,25 @@ function createClient() { return ldap.createClient({ url: `ldap://127.0.0.1:${port}` }); } +// Minimal directory stub – only needs findUser so _authenticateAcrossRealms +// can locate the user before delegating to the auth provider. +const directoryStub = { + initialize: async () => {}, + cleanup: async () => {}, + findUser: async (username) => ({ username }), +}; + describe('SQLite Auth Backend (real DB) - Integration', () => { let engine; let dbPath; + let client; beforeAll(() => { // nothing yet }); afterEach(async () => { + if (client) { try { client.unbind(); client.destroy(); } catch (_) {} client = null; } if (engine) { await engine.stop(); engine = null; } if (dbPath && fs.existsSync(dbPath)) { try { fs.unlinkSync(dbPath); } catch (_) {} @@ -60,15 +70,14 @@ describe('SQLite Auth Backend (real DB) - Integration', () => { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); - const client = createClient(); + client = createClient(); const userDN = `uid=testuser,${baseDn}`; await expect(new Promise((resolve, reject) => { client.bind(userDN, 'password123', (err) => err ? reject(err) : resolve()); })).resolves.not.toThrow(); - client.unbind(); }); test('2. Bind with invalid credentials should fail (SQLite)', async () => { @@ -79,14 +88,13 @@ describe('SQLite Auth Backend (real DB) - Integration', () => { process.env.SQL_QUERY_ONE_USER = 'SELECT username, full_name, surname, mail, home_directory, login_shell, uid_number, gid_number, password_hash AS password FROM users WHERE username = ?'; const authProvider = new SQLAuthProvider(); - engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: { initialize: async()=>{}, cleanup: async()=>{} }, logger }); + engine = new LdapEngine({ baseDn, port, authProviders: [authProvider], directoryProvider: directoryStub, logger }); await engine.start(); - const client = createClient(); + client = createClient(); const userDN = `uid=testuser,${baseDn}`; await expect(new Promise((resolve, reject) => { client.bind(userDN, 'wrong', (err) => err ? reject(err) : resolve()); })).rejects.toThrow(); - client.unbind(); }); }); From 350717432c4060897cc1a2bf62492e9b8b64b916 Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Sun, 8 Mar 2026 19:25:49 -0400 Subject: [PATCH 06/14] Code cleanup --- nfpm/scripts/postinstall.sh | 2 +- npm/src/LdapEngine.js | 21 +++--- npm/test/unit/LdapEngine.realms.test.js | 4 -- server/config/configurationLoader.js | 89 +------------------------ server/serverMain.js | 4 +- 5 files changed, 12 insertions(+), 108 deletions(-) diff --git a/nfpm/scripts/postinstall.sh b/nfpm/scripts/postinstall.sh index 0cc63be..04fbbf9 100755 --- a/nfpm/scripts/postinstall.sh +++ b/nfpm/scripts/postinstall.sh @@ -20,7 +20,7 @@ after_install () { } if [ "$1" = "configure" -a -z "$2" ] || \ - [ "$1" = "abort-remove" ] || \ + [ "$1" = "abort-remove" ]; \ then after_install elif [ "$1" = "configure" -a -n "$2" ]; then diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index 95bc13b..33bf061 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -508,14 +508,14 @@ class LdapEngine extends EventEmitter { let entryCount = 0; for (const { entries, realmName } of realmResults) { - for (const entry of entries) { + for (const { entry, type } of entries) { const dnLower = entry.dn.toLowerCase(); if (seenDNs.has(dnLower)) { this.logger.debug(`Skipping duplicate DN from realm '${realmName}': ${entry.dn}`); continue; } seenDNs.add(dnLower); - this.emit('entryFound', { type: entry._type || 'user', entry: entry.dn, realm: realmName }); + this.emit('entryFound', { type: type || 'user', entry: entry.dn, realm: realmName }); res.send(entry); entryCount++; } @@ -531,7 +531,7 @@ class LdapEngine extends EventEmitter { * @param {Object} realm - Realm object with directoryProvider and baseDn * @param {string} filterStr - LDAP filter string * @param {Array} attributes - Requested attributes - * @returns {{ entries: Array, realmName: string }} + * @returns {{ entries: Array<{entry: Object, type: string}>, realmName: string }} */ async _handleRealmSearch(realm, filterStr, attributes) { const entries = []; @@ -544,8 +544,7 @@ class LdapEngine extends EventEmitter { const user = await directoryProvider.findUser(username); if (user) { const entry = createLdapEntry(user, baseDn); - entry._type = 'user'; - entries.push(entry); + entries.push({ entry, type: 'user' }); } return { entries, realmName }; } @@ -558,8 +557,7 @@ class LdapEngine extends EventEmitter { for (const user of users) { const entry = createLdapEntry(user, baseDn); - entry._type = 'user'; - entries.push(entry); + entries.push({ entry, type: 'user' }); } return { entries, realmName }; } @@ -572,8 +570,7 @@ class LdapEngine extends EventEmitter { for (const group of groups) { const entry = createLdapGroupEntry(group, baseDn); - entry._type = 'group'; - entries.push(entry); + entries.push({ entry, type: 'group' }); } return { entries, realmName }; } @@ -600,8 +597,7 @@ class LdapEngine extends EventEmitter { } const entry = createLdapEntry(user, baseDn); - entry._type = 'user'; - entries.push(entry); + entries.push({ entry, type: 'user' }); } const groups = await directoryProvider.getAllGroups(); @@ -613,8 +609,7 @@ class LdapEngine extends EventEmitter { } const entry = createLdapGroupEntry(group, baseDn); - entry._type = 'group'; - entries.push(entry); + entries.push({ entry, type: 'group' }); } return { entries, realmName }; } diff --git a/npm/test/unit/LdapEngine.realms.test.js b/npm/test/unit/LdapEngine.realms.test.js index f52ff0e..36c55dc 100644 --- a/npm/test/unit/LdapEngine.realms.test.js +++ b/npm/test/unit/LdapEngine.realms.test.js @@ -403,10 +403,6 @@ describe('LdapEngine Multi-Realm', () => { // Should contain both baseDNs expect(contexts).toContain(baseDnA); expect(contexts).toContain(baseDnB); - - // Should contain both baseDNs - expect(contexts).toContain(baseDnA); - expect(contexts).toContain(baseDnB); } finally { await unbindAsync(client); } diff --git a/server/config/configurationLoader.js b/server/config/configurationLoader.js index 1405194..0d38e3a 100644 --- a/server/config/configurationLoader.js +++ b/server/config/configurationLoader.js @@ -57,94 +57,6 @@ class ConfigurationLoader { }; } - /** - * Load realm configuration from REALM_CONFIG env var. - * Supports inline JSON string or file path. - * @private - * @returns {Array|null} Realm config array or null if not configured - */ - _loadRealmConfig() { - const realmConfig = process.env.REALM_CONFIG; - if (!realmConfig) { - return null; - } - - let realms; - const trimmed = realmConfig.trim(); - - // Try parsing as inline JSON first (starts with '[') - if (trimmed.startsWith('[')) { - try { - realms = JSON.parse(trimmed); - } catch (e) { - throw new Error(`Invalid REALM_CONFIG JSON: ${e.message}`); - } - } else { - // Treat as file path - try { - const content = fs.readFileSync(trimmed, 'utf8'); - realms = JSON.parse(content); - } catch (e) { - throw new Error(`Failed to load REALM_CONFIG from '${trimmed}': ${e.message}`); - } - } - - this._validateRealmConfig(realms); - logger.info(`Loaded ${realms.length} realm(s) from REALM_CONFIG`); - return realms; - } - - /** - * Validate realm configuration structure at startup. - * @private - * @param {*} realms - Parsed realm config to validate - * @throws {Error} If validation fails - */ - _validateRealmConfig(realms) { - if (!Array.isArray(realms)) { - throw new Error('REALM_CONFIG must be a JSON array of realm objects'); - } - if (realms.length === 0) { - throw new Error('REALM_CONFIG must contain at least one realm'); - } - - const seenNames = new Set(); - - for (let i = 0; i < realms.length; i++) { - const realm = realms[i]; - const prefix = `Realm[${i}]`; - - if (!realm.name) { - throw new Error(`${prefix}: 'name' is required`); - } - if (seenNames.has(realm.name)) { - throw new Error(`${prefix}: duplicate realm name '${realm.name}'`); - } - seenNames.add(realm.name); - - if (!realm.baseDn) { - throw new Error(`${prefix}: 'baseDn' is required`); - } - if (!realm.directory) { - throw new Error(`${prefix}: 'directory' is required`); - } - if (!realm.directory.backend) { - throw new Error(`${prefix}: 'directory.backend' is required`); - } - if (!realm.auth) { - throw new Error(`${prefix}: 'auth' is required`); - } - if (!Array.isArray(realm.auth.backends) || realm.auth.backends.length === 0) { - throw new Error(`${prefix}: 'auth.backends' must be a non-empty array`); - } - for (let j = 0; j < realm.auth.backends.length; j++) { - if (!realm.auth.backends[j].type) { - throw new Error(`${prefix}: 'auth.backends[${j}].type' is required`); - } - } - } - } - /** * Build LDAP Base DN from common name * @private @@ -195,6 +107,7 @@ class ConfigurationLoader { // Validate realm config this._validateRealmConfig(realms); + logger.info(`Loaded ${realms.length} realm(s) from REALM_CONFIG`); return realms; } diff --git a/server/serverMain.js b/server/serverMain.js index dc2b510..2336742 100644 --- a/server/serverMain.js +++ b/server/serverMain.js @@ -96,8 +96,8 @@ async function startServer(config) { engineOptions.directoryProvider = selectedDirectory; // Register legacy auth providers in the registry - for (const backendType of config.authBackends) { - const idx = config.authBackends.indexOf(backendType); + for (let idx = 0; idx < config.authBackends.length; idx++) { + const backendType = config.authBackends[idx]; if (!authProviderRegistry.has(backendType)) { authProviderRegistry.set(backendType, selectedBackends[idx]); logger.debug(`Registered auth backend '${backendType}' in provider registry`); From 33c7959ad5a1530b5860863549407995edce0720 Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Sun, 8 Mar 2026 22:02:32 -0400 Subject: [PATCH 07/14] Code refactor --- npm/src/LdapEngine.js | 38 ++++++++++++++++++++------ server/serverMain.js | 19 ++++++++++--- server/services/notificationService.js | 6 ++++ server/utils/sqlUtils.js | 3 +- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index 33bf061..7f35739 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -301,13 +301,11 @@ class LdapEngine extends EventEmitter { // Find which realm owns this user let matchedRealm = null; let matchedUser = null; - let matchCount = 0; for (const realm of realms) { try { const user = await realm.directoryProvider.findUser(username); if (user) { - matchCount++; if (!matchedRealm) { matchedRealm = realm; matchedUser = user; @@ -373,14 +371,18 @@ class LdapEngine extends EventEmitter { return realm.authProviders; } - // Resolve each name from the registry + // Resolve each name from the registry. + // Try realm-scoped key first ("realm:type"), then fall back to type-only key. const overrideChain = []; for (const name of backendNames) { - const provider = this.authProviderRegistry.get(name); + const namespacedKey = `${realm.name}:${name}`; + const provider = this.authProviderRegistry.get(namespacedKey) + || this.authProviderRegistry.get(name); if (!provider) { this.logger.error( `User '${username}' has auth_backends='${userBackends}' but backend '${name}' ` + - `is not registered. Failing authentication for security.` + `is not registered (tried keys: '${namespacedKey}', '${name}'). ` + + `Failing authentication for security.` ); throw new Error(`Unknown auth backend '${name}' for user '${username}'`); } @@ -649,15 +651,26 @@ class LdapEngine extends EventEmitter { } } - // RootDSE attribute filtering rules (per RFC 4512) + // RootDSE attribute filtering rules (per RFC 4512): + // - No attributes requested = return all (user + operational) + // - '*' + '+' = all user and operational attributes + // - '+' only = operational attributes only + // - '*' only = user attrs + specifically requested operational attrs + // - Specific names only = only those attributes const hasWildcard = requestedAttrs.includes('*'); const hasPlus = requestedAttrs.includes('+'); + const noAttrsRequested = requestedAttrs.length === 0; const attributes = { objectClass: ['top'] }; - if (hasWildcard && !hasPlus) { + if (noAttrsRequested || (hasWildcard && hasPlus) || (hasPlus && !hasWildcard)) { + // Return all operational attributes + attributes.namingContexts = allBaseDns; + attributes.supportedLDAPVersion = ['3']; + } else if (hasWildcard) { + // '*' only: user attrs + specifically requested operational attrs requestedAttrs.forEach(attr => { const attrLower = attr.toLowerCase(); if (attrLower === 'namingcontexts') { @@ -667,8 +680,15 @@ class LdapEngine extends EventEmitter { } }); } else { - attributes.namingContexts = allBaseDns; - attributes.supportedLDAPVersion = ['3']; + // Specific attributes only — return only what was requested + requestedAttrs.forEach(attr => { + const attrLower = attr.toLowerCase(); + if (attrLower === 'namingcontexts') { + attributes.namingContexts = allBaseDns; + } else if (attrLower === 'supportedldapversion') { + attributes.supportedLDAPVersion = ['3']; + } + }); } const rootDSEEntry = { diff --git a/server/serverMain.js b/server/serverMain.js index 2336742..709bb65 100644 --- a/server/serverMain.js +++ b/server/serverMain.js @@ -53,24 +53,35 @@ async function startServer(config) { }; // Build global auth provider registry for per-user auth override (Phase 3) - // Maps provider type name → AuthProvider instance for all available backends + // Maps "realm:type" → AuthProvider instance (realm-scoped) and + // "type" → AuthProvider instance (first-registered fallback) const authProviderRegistry = new Map(); if (config.realms) { // Multi-realm mode: build realm objects from config logger.info(`Initializing multi-realm mode with ${config.realms.length} realm(s)`); engineOptions.realms = config.realms.map(realmCfg => { + // Ensure directory providers receive realm-scoped LDAP base DN so that + // any provider-side DN construction stays consistent with realmCfg.baseDn. + const directoryOptions = { + ldapBaseDn: realmCfg.baseDn, + ...(realmCfg.directory.options || {}) + }; const directoryProvider = providerFactory.createDirectoryProvider( realmCfg.directory.backend, - realmCfg.directory.options || {} + directoryOptions ); const authProviders = realmCfg.auth.backends.map(backendCfg => { const provider = providerFactory.createAuthProvider(backendCfg.type, backendCfg.options || {}); - // Register each realm's auth providers in the global registry (first one wins per type) + // Register with realm-scoped key for accurate per-user auth override + const registryKey = `${realmCfg.name}:${backendCfg.type}`; + authProviderRegistry.set(registryKey, provider); + logger.debug(`Registered auth backend '${registryKey}' in provider registry`); + // Also register type-only key as fallback (first realm wins per type) if (!authProviderRegistry.has(backendCfg.type)) { authProviderRegistry.set(backendCfg.type, provider); - logger.debug(`Registered auth backend '${backendCfg.type}' in provider registry from realm '${realmCfg.name}'`); + logger.debug(`Registered fallback auth backend '${backendCfg.type}' (first realm wins)`); } return provider; }); diff --git a/server/services/notificationService.js b/server/services/notificationService.js index 69b4157..3167847 100644 --- a/server/services/notificationService.js +++ b/server/services/notificationService.js @@ -9,6 +9,12 @@ class NotificationService { */ static async sendAuthenticationNotification(username, notificationUrl = null) { const url = notificationUrl ?? process.env.NOTIFICATION_URL; + if (!url) { + throw new Error( + 'NOTIFICATION_URL must be configured (set NOTIFICATION_URL environment variable ' + + 'or provide notificationUrl option in realm config)' + ); + } try { const response = await axios.post( url, diff --git a/server/utils/sqlUtils.js b/server/utils/sqlUtils.js index 6028c66..f9e6134 100644 --- a/server/utils/sqlUtils.js +++ b/server/utils/sqlUtils.js @@ -11,7 +11,8 @@ function buildSequelizeOptions(options = {}) { const seqOptions = { logging: msg => logger.debug(msg) }; const sqlSsl = options.sqlSsl ?? process.env.SQL_SSL; - if (sqlSsl === 'false') { + // Handle both boolean false and string 'false'/'0' from JSON config or env vars + if (sqlSsl === false || sqlSsl === 'false' || sqlSsl === '0') { seqOptions.dialectOptions = { ssl: false }; } From 08771ec13ccc2cc85758596ee3554604d8430cf5 Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Wed, 11 Mar 2026 18:13:53 -0400 Subject: [PATCH 08/14] Ensures per-user auth override always uses the contextually correct provider --- npm/src/LdapEngine.js | 59 ++++++++++-- npm/test/unit/LdapEngine.realms.test.js | 114 ++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 9 deletions(-) diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index 7f35739..e7ab711 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -348,6 +348,11 @@ class LdapEngine extends EventEmitter { * per-user override chain. If `auth_backends` is null/undefined/empty, * fall back to the realm's default auth providers. * + * Resolution order for each backend name: + * 1. Exact match in realm's own auth providers (by type) + * 2. Registry with realm-scoped key "realm:type" + * 3. Registry with type-only key (cross-realm fallback) + * * @private * @param {Object} realm - The matched realm * @param {Object} user - The user record from the directory provider @@ -371,17 +376,48 @@ class LdapEngine extends EventEmitter { return realm.authProviders; } - // Resolve each name from the registry. - // Try realm-scoped key first ("realm:type"), then fall back to type-only key. + // Build a quick lookup map of realm's own auth providers by type + const realmAuthByType = new Map(); + for (const provider of realm.authProviders) { + // Extract type from constructor name (e.g., SQLAuthProvider -> sql) + const type = provider.constructor.name + .replace(/AuthProvider$/i, '') + .replace(/Backend$/i, '') + .toLowerCase(); + if (!realmAuthByType.has(type)) { + realmAuthByType.set(type, provider); + } + } + + // Resolve each name from realm's providers first, then registry const overrideChain = []; for (const name of backendNames) { - const namespacedKey = `${realm.name}:${name}`; - const provider = this.authProviderRegistry.get(namespacedKey) - || this.authProviderRegistry.get(name); + const normalizedName = name.toLowerCase(); + + // 1. Check realm's own auth providers first + let provider = realmAuthByType.get(normalizedName); + + if (!provider) { + // 2. Try realm-scoped registry key + const namespacedKey = `${realm.name}:${name}`; + provider = this.authProviderRegistry.get(namespacedKey); + } + + if (!provider) { + // 3. Fall back to type-only registry key (cross-realm) + provider = this.authProviderRegistry.get(name); + if (provider) { + this.logger.warn( + `User '${username}' in realm '${realm.name}' using cross-realm auth backend '${name}'. ` + + `Consider using realm-scoped key '${realm.name}:${name}' for clarity.` + ); + } + } + if (!provider) { this.logger.error( `User '${username}' has auth_backends='${userBackends}' but backend '${name}' ` + - `is not registered (tried keys: '${namespacedKey}', '${name}'). ` + + `is not found in realm '${realm.name}' providers or registry. ` + `Failing authentication for security.` ); throw new Error(`Unknown auth backend '${name}' for user '${username}'`); @@ -500,10 +536,15 @@ class LdapEngine extends EventEmitter { * @returns {number} Number of entries sent */ async _handleMultiRealmSearch(realms, filterStr, attributes, res) { - // Collect entries from all realms in parallel - const realmResults = await Promise.all( - realms.map(realm => this._handleRealmSearch(realm, filterStr, attributes)) + // Collect entries from all realms in parallel with graceful degradation + const realmPromises = realms.map(realm => + this._handleRealmSearch(realm, filterStr, attributes) + .catch(err => { + this.logger.error(`Search failed in realm '${realm.name}':`, err); + return { entries: [], realmName: realm.name, error: err }; + }) ); + const realmResults = await Promise.all(realmPromises); // Deduplicate entries by DN (first realm wins for same DN) const seenDNs = new Set(); diff --git a/npm/test/unit/LdapEngine.realms.test.js b/npm/test/unit/LdapEngine.realms.test.js index 36c55dc..c1afe6d 100644 --- a/npm/test/unit/LdapEngine.realms.test.js +++ b/npm/test/unit/LdapEngine.realms.test.js @@ -365,6 +365,46 @@ describe('LdapEngine Multi-Realm', () => { await unbindAsync(client); } }); + + test('should handle realm search failures gracefully (partial results)', async () => { + const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; + + const dirA = new MockDirectoryProvider({ name: 'dir-a', users: usersA, groups: [] }); + // dirB will throw an error when searched + const dirB = new MockDirectoryProvider({ name: 'dir-b', users: [], groups: [] }); + dirB.getAllUsers = async () => { throw new Error('Database connection failed'); }; + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + requireAuthForSearch: false, + realms: [ + { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [new MockAuthProvider()] }, + { name: 'realm-b', baseDn: baseDN, directoryProvider: dirB, authProviders: [new MockAuthProvider()] } + ] + }); + + await engine.start(); + + const client = createClient(TEST_PORT); + try { + // Search should succeed with partial results (realm-a only) + const entries = await searchAsync(client, baseDN, { + filter: '(objectClass=posixAccount)', + scope: 'sub' + }); + + // Should get alice from realm-a despite realm-b failure + expect(entries.length).toBe(1); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining("Search failed in realm 'realm-b'"), + expect.any(Error) + ); + } finally { + await unbindAsync(client); + } + }); }); describe('RootDSE Multi-Realm', () => { @@ -595,6 +635,80 @@ describe('LdapEngine Multi-Realm', () => { engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'nonexistent' }, 'testuser'); }).toThrow("Unknown auth backend 'nonexistent'"); }); + + test('should prioritize realm own provider over registry fallback', () => { + // Realm A has its own 'mock' provider + const realmAProvider = new MockAuthProvider({ name: 'realm-a-mock' }); + // Registry has a different 'mock' provider (from realm B or global) + const registryProvider = new MockAuthProvider({ name: 'registry-mock' }); + const registry = new Map([['mock', registryProvider]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { name: 'realm-a', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAProvider] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'mock' }, 'testuser'); + // Should use realm's own provider, not the registry one + expect(chain).toHaveLength(1); + expect(chain[0]).toBe(realmAProvider); + expect(chain[0]).not.toBe(registryProvider); + }); + + test('should warn when using cross-realm registry fallback', () => { + // No 'sql' provider in realm's own auth chain + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + // Registry has a 'sql' provider from another realm + const sqlProvider = new MockAuthProvider({ name: 'sql-provider' }); + const registry = new Map([['sql', sqlProvider]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'sql' }, 'testuser'); + expect(chain).toEqual([sqlProvider]); + // Should have logged a warning about cross-realm usage + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("using cross-realm auth backend 'sql'") + ); + }); + + test('should prefer realm-scoped registry key over type-only key', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + const realmScopedSql = new MockAuthProvider({ name: 'realm-a:sql' }); + const globalSql = new MockAuthProvider({ name: 'global-sql' }); + const registry = new Map([ + ['realm-a:sql', realmScopedSql], + ['sql', globalSql] + ]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { name: 'realm-a', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'sql' }, 'testuser'); + // Should use realm-scoped registry key, not global fallback + expect(chain).toEqual([realmScopedSql]); + expect(chain).not.toContain(globalSql); + }); }); describe('End-to-end per-user auth override', () => { From 9057c17375bf5bb113369d528df6853af0ca96d8 Mon Sep 17 00:00:00 2001 From: Anisha Pant <44058515+anishapant21@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:16:59 -0400 Subject: [PATCH 09/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docker/sql/init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/sql/init.sql b/docker/sql/init.sql index f501684..e4bc3b4 100644 --- a/docker/sql/init.sql +++ b/docker/sql/init.sql @@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS users ( ); -- 4. Now insert users (gid_number matches existing groups) --- Passwords are bcrypt-hashed: ann=maya, abrol=abrol, evan=evan, hrits=maya, chris=chris +-- Passwords are SHA-512 crypt(3) hashes ($6$): ann=maya, abrol=abrol, evan=evan, hrits=maya, chris=chris INSERT INTO users (username, password, full_name, email, uid_number, gid_number, home_directory) VALUES ('ann', '$6$Zmtz1yzJJyslWIK/$OoLdG1FNvPbSsyqekHGNIKdx.X1IlMQBVqACvr/WI8IFze.jzvLDzB1y3/Tigjk1mzcGKgowZ1lwCVF8EXv2q.', 'Ann', 'ann@mieweb.com', 1001, 1001, '/home/ann'), ('abrol','$6$7KbEtgYeI.FFS7sw$EcFip4Ros8inRQQ2nGhBa32s3qA7h2pFXGfrP8x0NRMIM0bGaZ8bIObVh207yhQ.YW1KkMW2o7RIkEuDWG3wb/', 'Abrol', 'abrol@mieweb.com', 1002, 1002, '/home/abrol'), From ff3ceaebf6c0918a08eb76249572f4e8b34bb7e5 Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Sun, 15 Mar 2026 18:38:45 -0400 Subject: [PATCH 10/14] Add docs --- docs/MULTI-REALM-ARCHITECTURE.md | 624 +++++++++++++++++++++++++++++++ 1 file changed, 624 insertions(+) create mode 100644 docs/MULTI-REALM-ARCHITECTURE.md diff --git a/docs/MULTI-REALM-ARCHITECTURE.md b/docs/MULTI-REALM-ARCHITECTURE.md new file mode 100644 index 0000000..8e2c145 --- /dev/null +++ b/docs/MULTI-REALM-ARCHITECTURE.md @@ -0,0 +1,624 @@ +# Multi-Realm LDAP Gateway Architecture + +## Overview + +The LDAP Gateway supports **multi-realm architecture**, enabling a single gateway instance to serve multiple directory backends, each with its own baseDN and authentication chain. This allows organizations to: + +- Serve users from multiple domains (e.g., `dc=mieweb,dc=com`, `dc=bluehive,dc=com`) +- Apply different authentication requirements per user population (e.g., MFA for employees, password-only for service accounts) +- Isolate directory data across organizational boundaries +- Maintain backward compatibility with single-realm deployments + +## Architecture Components + +```mermaid +flowchart TB + subgraph "Client Layer" + C1["SSSD Client
dc=mieweb,dc=com"] + C2["SSSD Client
dc=bluehive,dc=com"] + C3["Service Account
dc=mieweb,dc=com"] + end + + subgraph "LDAP Gateway - Routing Layer" + GW["LdapEngine
realmsByBaseDn Map"] + GW -->|"baseDN routing"| ROUTE["O(1) lookup:
lowercased baseDN → realms[]"] + end + + subgraph "Realm Layer" + R1["Realm: mieweb
dc=mieweb,dc=com"] + R2["Realm: bluehive
dc=bluehive,dc=com"] + R3["Realm: service-accounts
dc=mieweb,dc=com"] + + R1 --> DIR1["SQLDirectory
users table"] + R1 --> AUTH1["[SQL, MFA]"] + + R2 --> DIR2["SQLDirectory
bluehive_users"] + R2 --> AUTH2["[SQL]"] + + R3 --> DIR3["SQLDirectory
service_accounts"] + R3 --> AUTH3["[SQL only]"] + end + + C1 -->|"bind/search"| GW + C2 -->|"bind/search"| GW + C3 -->|"bind/search"| GW + + ROUTE --> R1 + ROUTE --> R2 + ROUTE --> R3 +``` + +### Core Concepts + +#### Realm + +A **realm** is an isolated authentication/directory domain consisting of: +- **Name**: Unique identifier (e.g., `"mieweb-employees"`) +- **BaseDN**: LDAP subtree root (e.g., `"dc=mieweb,dc=com"`) +- **Directory Provider**: Backend for user/group lookups (SQL, MongoDB, Proxmox, etc.) +- **Auth Providers**: Chain of authentication backends (SQL, LDAP, MFA, etc.) + +Multiple realms can share the same baseDN (e.g., employees and service accounts both under `dc=mieweb,dc=com`). + +#### BaseDN Routing + +The gateway routes LDAP operations by baseDN: +- **Different baseDNs** serve **different subtrees** (completely isolated) +- **Same baseDN** results are **merged** from multiple realms (deduplication applied) +- Routing uses a lowercased baseDN index for O(1) lookups + +#### Authentication Chain + +Each realm defines a sequential authentication chain: +```javascript +authProviders: [SQLAuth, MFAAuth] // Both must succeed +``` + +Individual users can override the realm's default chain via the `auth_backends` database column (Phase 3 feature). + +## Configuration + +### Multi-Realm Mode + +Set the `REALM_CONFIG` environment variable to enable multi-realm mode: + +**Option 1: File Path** +```bash +REALM_CONFIG=/etc/ldap-gateway/realms.json +``` + +**Option 2: Inline JSON** +```bash +REALM_CONFIG='[{"name":"mieweb","baseDn":"dc=mieweb,dc=com",...}]' +``` + +### Realm Configuration Structure + +**`realms.json` example:** + +```json +[ + { + "name": "mieweb-employees", + "baseDn": "dc=mieweb,dc=com", + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.mieweb.com:3306/company_prod", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?", + "sqlQueryAllUsers": "SELECT * FROM users", + "sqlQueryAllGroups": "SELECT * FROM groups", + "sqlQueryGroupsByMember": "SELECT * FROM groups g WHERE JSON_CONTAINS(g.member_uids, JSON_QUOTE(?))" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.mieweb.com:3306/company_prod", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?" + } + }, + { + "type": "notification", + "options": { + "notificationUrl": "https://push.mieweb.com/notify" + } + } + ] + } + }, + { + "name": "service-accounts", + "baseDn": "dc=mieweb,dc=com", + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.mieweb.com:3306/company_prod", + "sqlQueryOneUser": "SELECT * FROM service_accounts WHERE username = ?" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.mieweb.com:3306/company_prod", + "sqlQueryOneUser": "SELECT * FROM service_accounts WHERE username = ?" + } + } + ] + } + }, + { + "name": "bluehive", + "baseDn": "dc=bluehive,dc=com", + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.bluehive.com:3306/hr_system" + } + }, + "auth": { + "backends": [ + { + "type": "sql", + "options": { + "sqlUri": "mysql://user:pass@db.bluehive.com:3306/hr_system" + } + } + ] + } + } +] +``` + +### Legacy Single-Realm Mode (Backward Compatible) + +If `REALM_CONFIG` is **not set**, the gateway operates in legacy mode using flat environment variables: + +```bash +AUTH_BACKENDS=sql,notification +DIRECTORY_BACKEND=sql +LDAP_BASE_DN=dc=mieweb,dc=com +SQL_URI=mysql://user:pass@localhost:3306/ldap_db +# ... other SQL/auth-specific env vars +``` + +The gateway automatically wraps these into a single realm named `"default"`. + +## Operational Flows + +### Startup Sequence + +```mermaid +flowchart TD + START["Server Startup"] --> CONFIG["ConfigurationLoader.loadConfig()"] + + CONFIG --> CHECK{"REALM_CONFIG
set?"} + + CHECK -->|"Yes"| LOAD["Load & validate realm config"] + CHECK -->|"No"| LEGACY["Load flat env vars"] + + LOAD --> MULTI["Multi-realm mode"] + LEGACY --> WRAP["Auto-wrap into single 'default' realm"] + + MULTI --> INSTANTIATE["Instantiate providers per realm"] + WRAP --> INSTANTIATE + + INSTANTIATE --> REGISTRY["Build authProviderRegistry"] + REGISTRY --> ENGINE["Create LdapEngine with realms[]"] + + ENGINE --> INIT["_initRealms():
Build realmsByBaseDn routing map"] + INIT --> HANDLERS["Register LDAP handlers per baseDN"] + + HANDLERS --> LISTEN["Server listening on port"] +``` + +### Authentication Flow + +When a client attempts to bind (authenticate): + +```mermaid +sequenceDiagram + participant Client + participant Engine as LdapEngine + participant Routing as realmsByBaseDn + participant R1 as Realm: employees + participant R2 as Realm: service-accounts + participant Auth as Auth Chain + + Client->>Engine: BIND uid=ldap-reader,dc=mieweb,dc=com + + Engine->>Engine: Extract baseDN: dc=mieweb,dc=com + Engine->>Routing: Get realms for 'dc=mieweb,dc=com' + Routing-->>Engine: [employees, service-accounts] + + Note over Engine: _authenticateAcrossRealms() + + Engine->>R1: findUser('ldap-reader') + R1-->>Engine: null (not found) + + Engine->>R2: findUser('ldap-reader') + R2-->>Engine: { username: 'ldap-reader', auth_backends: null } + + Note over Engine: First match wins!
User found in 'service-accounts' + + Engine->>Engine: _resolveAuthChain(realm, user) + + alt user.auth_backends is null/empty + Engine->>Engine: Use realm default: [SQL] + else user.auth_backends = 'sql,hardware-token' + Engine->>Engine: Resolve override from registry + end + + Engine->>Auth: Sequential auth: SQL.authenticate() + Auth-->>Engine: true + + Note over Engine: No MFA notification! + + Engine-->>Client: BIND SUCCESS (realm='service-accounts') +``` + +**Key Points:** +1. **BaseDN Routing**: O(1) lookup determines which realms to check +2. **First-Match Wins**: Realms are checked in config order; first `findUser()` hit wins +3. **Auth Chain Resolution**: Uses realm default or per-user override +4. **Sequential Authentication**: All providers in chain must succeed + +### Search Flow + +When a client searches for directory entries: + +```mermaid +sequenceDiagram + participant Client + participant Engine as LdapEngine + participant Handler as Search Handler + participant R1 as Realm: employees + participant R2 as Realm: service-accounts + participant Dedupe as Deduplication + + Client->>Engine: SEARCH base=dc=mieweb,dc=com
filter=(objectClass=posixAccount) + + Engine->>Handler: Route to handler for 'dc=mieweb,dc=com' + Handler->>Handler: Lookup realms: [employees, service-accounts] + + Note over Handler: _handleMultiRealmSearch()
Parallel query + + par Query realms in parallel + Handler->>R1: _handleRealmSearch() + R1->>R1: getAllUsers() + R1-->>Handler: [uid=apant, uid=horner] + + Handler->>R2: _handleRealmSearch() + R2->>R2: getAllUsers() + R2-->>Handler: [uid=ldap-reader, uid=monitoring-bot] + end + + Handler->>Dedupe: Merge results + Note over Dedupe: seenDNs Set (lowercased DN keys)
First realm wins for duplicates + + Dedupe-->>Handler: 4 unique entries + + Handler->>Client: Send entries + Handler->>Client: SearchResultDone +``` + +**Key Points:** +1. **Parallel Queries**: All realms sharing the baseDN are queried simultaneously +2. **Graceful Degradation**: Realm failures don't block results from other realms +3. **DN Deduplication**: First realm's entry wins for duplicate DNs +4. **Subtree Isolation**: Different baseDNs query different realm sets + +## Per-User Authentication Override (Phase 3) + +Individual users can override their realm's default authentication chain via the `auth_backends` database column. + +### Database Schema + +Add to your user table: + +```sql +ALTER TABLE users ADD COLUMN auth_backends VARCHAR(255) NULL + COMMENT 'Comma-separated auth backend types to override realm default. NULL = use realm default.'; +``` + +### Resolution Priority + +When authenticating a user, the auth chain is resolved with three-level precedence: + +```mermaid +flowchart TD + START["User found in realm"] --> CHECK{"user.auth_backends
set?"} + + CHECK -->|"NULL/empty"| DEFAULT["Return realm.authProviders"] + + CHECK -->|"'sql,mfa'"| PARSE["Split by comma: ['sql', 'mfa']"] + PARSE --> RESOLVE["For each backend name:"] + + RESOLVE --> STEP1["1. Check realm's own auth providers"] + STEP1 -->|"found"| USE1["Use realm provider"] + STEP1 -->|"not found"| STEP2 + + STEP2["2. Check registry: 'realm:type' key"] + STEP2 -->|"found"| USE2["Use scoped provider"] + STEP2 -->|"not found"| STEP3 + + STEP3["3. Check registry: 'type' key (global)"] + STEP3 -->|"found"| USE3["Use global provider + warn"] + STEP3 -->|"not found"| ERROR["Throw error: Unknown backend"] + + USE1 --> BUILD["Build override chain"] + USE2 --> BUILD + USE3 --> BUILD + BUILD --> RETURN["Return custom chain"] + + DEFAULT --> DONE["Authenticate"] + RETURN --> DONE + ERROR --> FAIL["Authentication fails"] +``` + +### Example Usage + +**Scenario: Bypass MFA for CI/CD service account** + +```sql +-- Service account that needs to skip MFA for automation +UPDATE users SET auth_backends = 'sql' WHERE username = 'ci-deployment-bot'; + +-- Regular employee (NULL = use realm default: sql,mfa) +UPDATE users SET auth_backends = NULL WHERE username = 'apant'; + +-- Executive requiring hardware token +UPDATE users SET auth_backends = 'sql,mfa,hardware-token' WHERE username = 'ceo'; +``` + +**Auth Provider Registry** + +The registry maps backend type names to provider instances: + +```javascript +authProviderRegistry = Map { + // Realm-scoped (preferred) + 'mieweb-employees:sql' => SQLAuthProvider #1, + 'mieweb-employees:mfa' => MFAAuthProvider #1, + 'mieweb-employees:hardware-token' => HardwareTokenAuth #1, + + // Type-only fallback (first registered wins) + 'sql' => SQLAuthProvider #1, + 'mfa' => MFAAuthProvider #1, + 'hardware-token' => HardwareTokenAuth #1 +} +``` + +## Use Cases + +### 1. Multi-Domain Organization + +Serve users from different acquired companies: + +```json +[ + { + "name": "mieweb", + "baseDn": "dc=mieweb,dc=com", + "directory": { "backend": "sql", "options": { "database": "mieweb_users" } }, + "auth": { "backends": [{ "type": "sql" }] } + }, + { + "name": "bluehive", + "baseDn": "dc=bluehive,dc=com", + "directory": { "backend": "sql", "options": { "database": "bluehive_users" } }, + "auth": { "backends": [{ "type": "sql" }] } + } +] +``` + +Different domains → different baseDNs → complete isolation. + +### 2. Service Account MFA Bypass + +Prevent automated tools from triggering MFA notifications: + +```json +[ + { + "name": "employees", + "baseDn": "dc=company,dc=com", + "directory": { "backend": "sql", "options": { "table": "users" } }, + "auth": { "backends": [{ "type": "sql" }, { "type": "mfa" }] } + }, + { + "name": "service-accounts", + "baseDn": "dc=company,dc=com", + "directory": { "backend": "sql", "options": { "table": "service_accounts" } }, + "auth": { "backends": [{ "type": "sql" }] } + } +] +``` + +Same baseDN, different authentication requirements. + +### 3. Gradual MFA Rollout + +Deploy MFA to subsets of users: + +```json +[ + { + "name": "engineering", + "baseDn": "dc=company,dc=com", + "directory": { "backend": "sql", "options": { "department": "Engineering" } }, + "auth": { "backends": [{ "type": "sql" }, { "type": "mfa" }] } + }, + { + "name": "other-departments", + "baseDn": "dc=company,dc=com", + "directory": { "backend": "sql", "options": { "department": "!Engineering" } }, + "auth": { "backends": [{ "type": "sql" }] } + } +] +``` + +### 4. Hybrid Authentication + +Mix database users with LDAP federation: + +```json +[ + { + "name": "local-users", + "baseDn": "dc=company,dc=com", + "directory": { "backend": "sql" }, + "auth": { "backends": [{ "type": "sql" }] } + }, + { + "name": "corporate-ad", + "baseDn": "dc=company,dc=com", + "directory": { "backend": "ldap", "options": { "host": "ad.corp.local" } }, + "auth": { "backends": [{ "type": "ldap" }] } + } +] +``` + +## Migration Guide + +### From Single-Realm to Multi-Realm + +**Before (flat environment variables):** +```bash +AUTH_BACKENDS=sql,notification +DIRECTORY_BACKEND=sql +LDAP_BASE_DN=dc=mieweb,dc=com +SQL_URI=mysql://localhost/ldap_db +``` + +**After (multi-realm config):** + +1. Create `realms.json`: +```json +[ + { + "name": "default", + "baseDn": "dc=mieweb,dc=com", + "directory": { + "backend": "sql", + "options": { + "sqlUri": "mysql://localhost/ldap_db" + } + }, + "auth": { + "backends": [ + { "type": "sql", "options": { "sqlUri": "mysql://localhost/ldap_db" } }, + { "type": "notification" } + ] + } + } +] +``` + +2. Update environment: +```bash +REALM_CONFIG=/path/to/realms.json +# Keep other vars for backward compat if needed +``` + +3. Restart gateway - **zero downtime**, behavior identical. + +### Adding New Realms + +Simply append to the realms array and restart: + +```json +[ + { + "name": "existing-realm", + "baseDn": "dc=company,dc=com", + ... + }, + { + "name": "new-realm", + "baseDn": "dc=partner,dc=com", + ... + } +] +``` + +No changes to existing realm configurations needed. + +## Architecture Decisions + +### Why BaseDN Routing is Not Authorization + +**BaseDN routing = Addressing**, not authorization. It answers "which mailbox?" not "who can access?". + +| Responsibility | Layer | Example | +|---------------|-------|---------| +| **Addressing** | Gateway (baseDN routing) | Deliver search to `dc=mieweb,dc=com` realm | +| **Authentication** | Gateway (auth chain) | Verify user's password and MFA | +| **Authorization** | Client (SSSD filters) | `ldap_access_filter = memberOf=cn=employees,...` | + +The gateway doesn't enforce "who can log into which server" - that's the client's job via group membership filters. + +### Why Registry Uses Three-Level Lookup + +1. **Realm's Own Providers** (highest priority) - Zero ambiguity, guaranteed correct +2. **Realm-Scoped Registry** - Enables dynamic backends without config duplication +3. **Global Registry** - Emergency fallback for truly shared services (logs warnings) + +This prevents accidental cross-realm provider sharing (e.g., SQL auth hitting wrong database). + +### Why First-Match Wins + +Following PAM/nsswitch convention: admin controls priority via config order. If user exists in multiple realms sharing a baseDN, first realm in array wins (with warning logged). + +### Why Parallel Realm Search + +Realms are independent data sources - querying them sequentially would be unnecessarily slow. Parallel queries with graceful degradation (failed realm doesn't block others) maximize performance and reliability. + +## Key Files + +| File | Purpose | +|------|---------| +| [server/config/configurationLoader.js](../server/config/configurationLoader.js) | Loads and validates realm config | +| [server/serverMain.js](../server/serverMain.js) | Instantiates providers per realm | +| [npm/src/LdapEngine.js](../npm/src/LdapEngine.js) | Core routing and auth logic | +| [server/providers.js](../server/providers.js) | Provider factory | +| [server/backends/](../server/backends/) | Auth/directory provider implementations | +| [server/realms.example.json](../server/realms.example.json) | Example realm configuration | + +## Testing + +Multi-realm tests are located in: +- [npm/test/unit/LdapEngine.realms.test.js](../npm/test/unit/LdapEngine.realms.test.js) - Unit tests for realm routing +- [server/test/integration/](../server/test/integration/) - Integration tests with real backends + +## Performance Characteristics + +| Operation | Complexity | Notes | +|-----------|-----------|-------| +| BaseDN routing | O(1) | Map lookup with lowercased key | +| User lookup | O(n) realms | Sequential, stops at first match | +| Search query | O(r) realms | Parallel queries where r = realms sharing baseDN | +| Auth chain resolution | O(p) providers | Sequential validation, short-circuits on failure | + +## Security Considerations + +1. **Unknown backend = fail-loud**: Per-user `auth_backends` referencing unknown backend throws error (no silent fallback) +2. **Cross-realm warnings**: Using global registry fallback logs warnings to detect misconfigurations +3. **Data isolation**: Different baseDNs = complete isolation (different subtrees) +4. **Auth chain integrity**: All providers must succeed (sequential AND logic) + +## Support & Resources + +- **Multi-Realm Planning Document**: [Multi-realm.md](../Multi-realm.md) +- **Provider Backend Guide**: [server/backends/template.js](../server/backends/template.js) +- **Example Configurations**: [server/realms.example.json](../server/realms.example.json) +- **Issues/Questions**: GitHub Issues + +--- + +**Version**: Phase 1-3 Complete (per PR #143) +**Last Updated**: 2026-03-15 From c3694fc24da546818cc4ac0d27253665b1a67a02 Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Mon, 16 Mar 2026 08:36:50 -0400 Subject: [PATCH 11/14] Fix case sensitive registry lookup --- npm/src/LdapEngine.js | 20 ++++++++---- npm/test/unit/LdapEngine.realms.test.js | 43 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index e7ab711..6bcf738 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -45,7 +45,15 @@ class LdapEngine extends EventEmitter { // Auth provider registry for per-user auth override (Phase 3) // Maps provider type name → AuthProvider instance - this.authProviderRegistry = options.authProviderRegistry || new Map(); + // Keys are normalized to lowercase for case-insensitive lookups + const incomingRegistry = options.authProviderRegistry || new Map(); + this.authProviderRegistry = new Map(); + for (const [key, value] of incomingRegistry.entries()) { + const normalizedKey = String(key).toLowerCase(); + if (!this.authProviderRegistry.has(normalizedKey)) { + this.authProviderRegistry.set(normalizedKey, value); + } + } this.server = null; this.logger = options.logger || console; @@ -398,18 +406,18 @@ class LdapEngine extends EventEmitter { let provider = realmAuthByType.get(normalizedName); if (!provider) { - // 2. Try realm-scoped registry key - const namespacedKey = `${realm.name}:${name}`; + // 2. Try realm-scoped registry key (case-insensitive) + const namespacedKey = `${realm.name}:${normalizedName}`.toLowerCase(); provider = this.authProviderRegistry.get(namespacedKey); } if (!provider) { - // 3. Fall back to type-only registry key (cross-realm) - provider = this.authProviderRegistry.get(name); + // 3. Fall back to type-only registry key (cross-realm, case-insensitive) + provider = this.authProviderRegistry.get(normalizedName); if (provider) { this.logger.warn( `User '${username}' in realm '${realm.name}' using cross-realm auth backend '${name}'. ` + - `Consider using realm-scoped key '${realm.name}:${name}' for clarity.` + `Consider using realm-scoped key '${realm.name}:${normalizedName}' for clarity.` ); } } diff --git a/npm/test/unit/LdapEngine.realms.test.js b/npm/test/unit/LdapEngine.realms.test.js index c1afe6d..7b34424 100644 --- a/npm/test/unit/LdapEngine.realms.test.js +++ b/npm/test/unit/LdapEngine.realms.test.js @@ -709,6 +709,49 @@ describe('LdapEngine Multi-Realm', () => { expect(chain).toEqual([realmScopedSql]); expect(chain).not.toContain(globalSql); }); + + test('should resolve auth_backends case-insensitively against registry', () => { + const sqlProvider = new MockAuthProvider({ name: 'sql-provider' }); + // Registry key is lowercase + const registry = new Map([['sql', sqlProvider]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + ] + }); + + // User record has uppercase auth_backends value + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'SQL' }, 'testuser'); + expect(chain).toHaveLength(1); + expect(chain[0]).toBe(sqlProvider); + }); + + test('should resolve realm-scoped registry keys case-insensitively', () => { + const realmAuth = new MockAuthProvider({ name: 'realm-default' }); + const realmScopedProvider = new MockAuthProvider({ name: 'realm-scoped-mfa' }); + // Registry key uses mixed case + const registry = new Map([['MyRealm:MFA', realmScopedProvider]]); + + engine = new LdapEngine({ + port: TEST_PORT, + bindIp: '127.0.0.1', + logger: mockLogger, + authProviderRegistry: registry, + realms: [ + { name: 'MyRealm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + ] + }); + + // User record has lowercase auth_backends value + const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'mfa' }, 'testuser'); + expect(chain).toHaveLength(1); + expect(chain[0]).toBe(realmScopedProvider); + }); }); describe('End-to-end per-user auth override', () => { From ad07d3036bee300b4a32bff95db6451bf089622c Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Mon, 16 Mar 2026 09:12:02 -0400 Subject: [PATCH 12/14] Update docs --- docs/MULTI-REALM-ARCHITECTURE.md | 563 ++++++++++++++----------------- 1 file changed, 252 insertions(+), 311 deletions(-) diff --git a/docs/MULTI-REALM-ARCHITECTURE.md b/docs/MULTI-REALM-ARCHITECTURE.md index 8e2c145..77060ea 100644 --- a/docs/MULTI-REALM-ARCHITECTURE.md +++ b/docs/MULTI-REALM-ARCHITECTURE.md @@ -1,80 +1,168 @@ -# Multi-Realm LDAP Gateway Architecture +# Multi-Realm LDAP Gateway ## Overview -The LDAP Gateway supports **multi-realm architecture**, enabling a single gateway instance to serve multiple directory backends, each with its own baseDN and authentication chain. This allows organizations to: +The LDAP Gateway supports **multi-realm architecture**, enabling a single gateway instance to serve multiple directory backends, each with its own baseDN and authentication chain. +**Capabilities:** - Serve users from multiple domains (e.g., `dc=mieweb,dc=com`, `dc=bluehive,dc=com`) -- Apply different authentication requirements per user population (e.g., MFA for employees, password-only for service accounts) +- Apply different authentication requirements per user population (MFA for employees, password-only for service accounts) - Isolate directory data across organizational boundaries -- Maintain backward compatibility with single-realm deployments +- Override authentication per user via the `auth_backends` database column +- Maintain full backward compatibility with single-realm deployments -## Architecture Components +### Architecture Overview ```mermaid flowchart TB subgraph "Client Layer" - C1["SSSD Client
dc=mieweb,dc=com"] - C2["SSSD Client
dc=bluehive,dc=com"] - C3["Service Account
dc=mieweb,dc=com"] + C1["SSSD Client\ndc=mieweb,dc=com"] + C2["SSSD Client\ndc=bluehive,dc=com"] + C3["Service Account\ndc=mieweb,dc=com"] end - subgraph "LDAP Gateway - Routing Layer" - GW["LdapEngine
realmsByBaseDn Map"] - GW -->|"baseDN routing"| ROUTE["O(1) lookup:
lowercased baseDN → realms[]"] + subgraph "LDAP Gateway" + GW["BaseDN Router"] end subgraph "Realm Layer" - R1["Realm: mieweb
dc=mieweb,dc=com"] - R2["Realm: bluehive
dc=bluehive,dc=com"] - R3["Realm: service-accounts
dc=mieweb,dc=com"] + R1["Realm: mieweb\ndc=mieweb,dc=com"] + R2["Realm: bluehive\ndc=bluehive,dc=com"] + R3["Realm: service-accounts\ndc=mieweb,dc=com"] - R1 --> DIR1["SQLDirectory
users table"] - R1 --> AUTH1["[SQL, MFA]"] + R1 --> DIR1["SQL Directory\nusers table"] + R1 --> AUTH1["Auth: SQL + MFA"] - R2 --> DIR2["SQLDirectory
bluehive_users"] - R2 --> AUTH2["[SQL]"] + R2 --> DIR2["SQL Directory\nbluehive_users"] + R2 --> AUTH2["Auth: SQL"] - R3 --> DIR3["SQLDirectory
service_accounts"] - R3 --> AUTH3["[SQL only]"] + R3 --> DIR3["SQL Directory\nservice_accounts"] + R3 --> AUTH3["Auth: SQL only"] end C1 -->|"bind/search"| GW C2 -->|"bind/search"| GW C3 -->|"bind/search"| GW - ROUTE --> R1 - ROUTE --> R2 - ROUTE --> R3 + GW -->|"dc=mieweb"| R1 + GW -->|"dc=bluehive"| R2 + GW -->|"dc=mieweb"| R3 ``` -### Core Concepts +## Prerequisites -#### Realm +- LDAP Gateway v2.0+ +- A `realms.json` configuration file (or `REALM_CONFIG` environment variable) +- Configured backend databases or directory sources for each realm +- Understanding of your organization's LDAP baseDN structure -A **realm** is an isolated authentication/directory domain consisting of: -- **Name**: Unique identifier (e.g., `"mieweb-employees"`) -- **BaseDN**: LDAP subtree root (e.g., `"dc=mieweb,dc=com"`) -- **Directory Provider**: Backend for user/group lookups (SQL, MongoDB, Proxmox, etc.) -- **Auth Providers**: Chain of authentication backends (SQL, LDAP, MFA, etc.) +## Core Concepts + +### Realm + +A **realm** is an isolated authentication and directory domain consisting of: + +| Property | Description | Example | +|----------|-------------|---------| +| `name` | Unique identifier | `"mieweb-employees"` | +| `baseDn` | LDAP subtree root | `"dc=mieweb,dc=com"` | +| `directory` | Backend for user/group lookups | SQL, MongoDB, Proxmox | +| `auth.backends` | Ordered list of authentication providers | SQL, Notification (MFA) | Multiple realms can share the same baseDN (e.g., employees and service accounts both under `dc=mieweb,dc=com`). -#### BaseDN Routing +### Routing Behavior + +**Different baseDNs (complete isolation):** +- Each baseDN routes to its own set of realms +- No result merging across baseDNs + +**Same baseDN, multiple realms:** + +| Operation | Behavior | +|-----------|----------| +| **Bind (auth)** | Realms checked in config order; first realm containing the user wins | +| **Search** | All realms queried in parallel; results merged with DN deduplication | +| **Duplicate DNs** | First realm's entry wins; duplicates silently dropped | -The gateway routes LDAP operations by baseDN: -- **Different baseDNs** serve **different subtrees** (completely isolated) -- **Same baseDN** results are **merged** from multiple realms (deduplication applied) -- Routing uses a lowercased baseDN index for O(1) lookups +### Authentication Chain -#### Authentication Chain +Each realm defines a sequential authentication chain. **All providers in the chain must succeed** for authentication to pass: -Each realm defines a sequential authentication chain: -```javascript -authProviders: [SQLAuth, MFAAuth] // Both must succeed +```json +"auth": { + "backends": [ + { "type": "sql" }, + { "type": "notification" } + ] +} ``` -Individual users can override the realm's default chain via the `auth_backends` database column (Phase 3 feature). +In the example above, SQL password validation must pass first, then the MFA notification provider must also succeed. + +### Authentication Flow + +When a client binds (authenticates), the gateway routes by baseDN, finds the user across realms, and runs the authentication chain: + +```mermaid +sequenceDiagram + participant Client + participant Gateway as LDAP Gateway + participant R1 as Realm: employees + participant R2 as Realm: service-accounts + participant Auth as Auth Chain + + Client->>Gateway: BIND uid=ldap-reader,dc=mieweb,dc=com + + Gateway->>Gateway: Route by baseDN: dc=mieweb,dc=com + + Gateway->>R1: Find user 'ldap-reader' + R1-->>Gateway: Not found + + Gateway->>R2: Find user 'ldap-reader' + R2-->>Gateway: Found (auth_backends: null) + + Note over Gateway: First match wins + + Gateway->>Gateway: Resolve auth chain for realm + + alt auth_backends is null + Note over Gateway: Use realm default: [SQL] + else auth_backends = 'sql,hardware-token' + Note over Gateway: Use per-user override + end + + Gateway->>Auth: SQL password check + Auth-->>Gateway: Pass + + Gateway-->>Client: BIND SUCCESS (realm: service-accounts) +``` + +### Search Flow + +When a client searches, all realms sharing the baseDN are queried in parallel and results are merged: + +```mermaid +sequenceDiagram + participant Client + participant Gateway as LDAP Gateway + participant R1 as Realm: employees + participant R2 as Realm: service-accounts + + Client->>Gateway: SEARCH base=dc=mieweb,dc=com + + par Query all matching realms + Gateway->>R1: Get users + R1-->>Gateway: [uid=apant, uid=horner] + + Gateway->>R2: Get users + R2-->>Gateway: [uid=ldap-reader, uid=monitoring-bot] + end + + Gateway->>Gateway: Merge & deduplicate by DN + + Gateway-->>Client: 4 unique entries +``` ## Configuration @@ -150,30 +238,24 @@ REALM_CONFIG='[{"name":"mieweb","baseDn":"dc=mieweb,dc=com",...}]' } ] } - }, - { - "name": "bluehive", - "baseDn": "dc=bluehive,dc=com", - "directory": { - "backend": "sql", - "options": { - "sqlUri": "mysql://user:pass@db.bluehive.com:3306/hr_system" - } - }, - "auth": { - "backends": [ - { - "type": "sql", - "options": { - "sqlUri": "mysql://user:pass@db.bluehive.com:3306/hr_system" - } - } - ] - } } ] ``` +A full example configuration is available at [`server/realms.example.json`](../server/realms.example.json). + +### Configuration Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Unique realm identifier | +| `baseDn` | string | Yes | LDAP base DN for this realm | +| `directory.backend` | string | Yes | Directory provider type (`sql`, `mongo`, `proxmox`) | +| `directory.options` | object | No | Provider-specific options (connection strings, queries) | +| `auth.backends[]` | array | Yes | Ordered list of auth backends | +| `auth.backends[].type` | string | Yes | Auth provider type (`sql`, `notification`, etc.) | +| `auth.backends[].options` | object | No | Provider-specific auth options | + ### Legacy Single-Realm Mode (Backward Compatible) If `REALM_CONFIG` is **not set**, the gateway operates in legacy mode using flat environment variables: @@ -183,221 +265,113 @@ AUTH_BACKENDS=sql,notification DIRECTORY_BACKEND=sql LDAP_BASE_DN=dc=mieweb,dc=com SQL_URI=mysql://user:pass@localhost:3306/ldap_db -# ... other SQL/auth-specific env vars -``` - -The gateway automatically wraps these into a single realm named `"default"`. - -## Operational Flows - -### Startup Sequence - -```mermaid -flowchart TD - START["Server Startup"] --> CONFIG["ConfigurationLoader.loadConfig()"] - - CONFIG --> CHECK{"REALM_CONFIG
set?"} - - CHECK -->|"Yes"| LOAD["Load & validate realm config"] - CHECK -->|"No"| LEGACY["Load flat env vars"] - - LOAD --> MULTI["Multi-realm mode"] - LEGACY --> WRAP["Auto-wrap into single 'default' realm"] - - MULTI --> INSTANTIATE["Instantiate providers per realm"] - WRAP --> INSTANTIATE - - INSTANTIATE --> REGISTRY["Build authProviderRegistry"] - REGISTRY --> ENGINE["Create LdapEngine with realms[]"] - - ENGINE --> INIT["_initRealms():
Build realmsByBaseDn routing map"] - INIT --> HANDLERS["Register LDAP handlers per baseDN"] - - HANDLERS --> LISTEN["Server listening on port"] ``` -### Authentication Flow - -When a client attempts to bind (authenticate): +The gateway automatically wraps these into a single realm named `"default"`. No code or client changes are needed. -```mermaid -sequenceDiagram - participant Client - participant Engine as LdapEngine - participant Routing as realmsByBaseDn - participant R1 as Realm: employees - participant R2 as Realm: service-accounts - participant Auth as Auth Chain - - Client->>Engine: BIND uid=ldap-reader,dc=mieweb,dc=com - - Engine->>Engine: Extract baseDN: dc=mieweb,dc=com - Engine->>Routing: Get realms for 'dc=mieweb,dc=com' - Routing-->>Engine: [employees, service-accounts] - - Note over Engine: _authenticateAcrossRealms() - - Engine->>R1: findUser('ldap-reader') - R1-->>Engine: null (not found) - - Engine->>R2: findUser('ldap-reader') - R2-->>Engine: { username: 'ldap-reader', auth_backends: null } +## Per-User Authentication Override - Note over Engine: First match wins!
User found in 'service-accounts' +Individual users can override their realm's default authentication chain via the `auth_backends` database column. This allows fine-grained control without creating additional realms. - Engine->>Engine: _resolveAuthChain(realm, user) - - alt user.auth_backends is null/empty - Engine->>Engine: Use realm default: [SQL] - else user.auth_backends = 'sql,hardware-token' - Engine->>Engine: Resolve override from registry - end - - Engine->>Auth: Sequential auth: SQL.authenticate() - Auth-->>Engine: true +### Database Schema - Note over Engine: No MFA notification! +Add to your user table: - Engine-->>Client: BIND SUCCESS (realm='service-accounts') +```sql +ALTER TABLE users ADD COLUMN auth_backends VARCHAR(255) NULL + COMMENT 'Comma-separated auth backend types. NULL = use realm default.'; ``` -**Key Points:** -1. **BaseDN Routing**: O(1) lookup determines which realms to check -2. **First-Match Wins**: Realms are checked in config order; first `findUser()` hit wins -3. **Auth Chain Resolution**: Uses realm default or per-user override -4. **Sequential Authentication**: All providers in chain must succeed +### Auth Provider Registry -### Search Flow +The registry is a key-value map of backend type names to provider instances, built automatically at startup. It enables per-user overrides by letting the gateway look up any auth provider by name. -When a client searches for directory entries: +**How the registry is built:** -```mermaid -sequenceDiagram - participant Client - participant Engine as LdapEngine - participant Handler as Search Handler - participant R1 as Realm: employees - participant R2 as Realm: service-accounts - participant Dedupe as Deduplication - - Client->>Engine: SEARCH base=dc=mieweb,dc=com
filter=(objectClass=posixAccount) - - Engine->>Handler: Route to handler for 'dc=mieweb,dc=com' - Handler->>Handler: Lookup realms: [employees, service-accounts] +1. For each realm, every auth backend is registered with **two keys**: + - **Realm-scoped key** (`realm-name:type`) — always registered, unique per realm + - **Global key** (`type`) — registered only if no other realm claimed that type first - Note over Handler: _handleMultiRealmSearch()
Parallel query +2. All keys are **normalized to lowercase** for case-insensitive lookups - par Query realms in parallel - Handler->>R1: _handleRealmSearch() - R1->>R1: getAllUsers() - R1-->>Handler: [uid=apant, uid=horner] - - Handler->>R2: _handleRealmSearch() - R2->>R2: getAllUsers() - R2-->>Handler: [uid=ldap-reader, uid=monitoring-bot] - end - - Handler->>Dedupe: Merge results - Note over Dedupe: seenDNs Set (lowercased DN keys)
First realm wins for duplicates +**Example:** Given this configuration with two realms: - Dedupe-->>Handler: 4 unique entries - - Handler->>Client: Send entries - Handler->>Client: SearchResultDone +```json +[ + { + "name": "employees", + "auth": { "backends": [{ "type": "sql" }, { "type": "notification" }] } + }, + { + "name": "service-accounts", + "auth": { "backends": [{ "type": "sql" }] } + } +] ``` -**Key Points:** -1. **Parallel Queries**: All realms sharing the baseDN are queried simultaneously -2. **Graceful Degradation**: Realm failures don't block results from other realms -3. **DN Deduplication**: First realm's entry wins for duplicate DNs -4. **Subtree Isolation**: Different baseDNs query different realm sets - -## Per-User Authentication Override (Phase 3) +The resulting registry contains: -Individual users can override their realm's default authentication chain via the `auth_backends` database column. +| Registry Key | Provider | How Registered | +|-------------|----------|----------------| +| `employees:sql` | SQL provider (employees) | Realm-scoped | +| `employees:notification` | MFA provider (employees) | Realm-scoped | +| `service-accounts:sql` | SQL provider (service-accounts) | Realm-scoped | +| `sql` | SQL provider (employees) | Global fallback (first realm wins) | +| `notification` | MFA provider (employees) | Global fallback | -### Database Schema - -Add to your user table: +### How Override Resolution Works -```sql -ALTER TABLE users ADD COLUMN auth_backends VARCHAR(255) NULL - COMMENT 'Comma-separated auth backend types to override realm default. NULL = use realm default.'; -``` - -### Resolution Priority - -When authenticating a user, the auth chain is resolved with three-level precedence: +When a user has `auth_backends` set (e.g., `'sql,hardware-token'`), each backend name is resolved through a three-level lookup: ```mermaid flowchart TD - START["User found in realm"] --> CHECK{"user.auth_backends
set?"} - - CHECK -->|"NULL/empty"| DEFAULT["Return realm.authProviders"] + START["User has auth_backends = 'sql,mfa'"] --> PARSE["Split: ['sql', 'mfa']"] + PARSE --> LOOP["For each backend name:"] - CHECK -->|"'sql,mfa'"| PARSE["Split by comma: ['sql', 'mfa']"] - PARSE --> RESOLVE["For each backend name:"] - - RESOLVE --> STEP1["1. Check realm's own auth providers"] - STEP1 -->|"found"| USE1["Use realm provider"] + LOOP --> STEP1["1. Check realm's own providers\n(matched by type name)"] + STEP1 -->|"found"| USE["Use this provider"] STEP1 -->|"not found"| STEP2 - STEP2["2. Check registry: 'realm:type' key"] - STEP2 -->|"found"| USE2["Use scoped provider"] + STEP2["2. Check registry:\nrealm-scoped key (realm:type)"] + STEP2 -->|"found"| USE STEP2 -->|"not found"| STEP3 - STEP3["3. Check registry: 'type' key (global)"] - STEP3 -->|"found"| USE3["Use global provider + warn"] - STEP3 -->|"not found"| ERROR["Throw error: Unknown backend"] - - USE1 --> BUILD["Build override chain"] - USE2 --> BUILD - USE3 --> BUILD - BUILD --> RETURN["Return custom chain"] + STEP3["3. Check registry:\nglobal key (type only)"] + STEP3 -->|"found"| WARN["Use provider + log warning"] + STEP3 -->|"not found"| FAIL["Deny authentication:\nunknown backend"] - DEFAULT --> DONE["Authenticate"] - RETURN --> DONE - ERROR --> FAIL["Authentication fails"] + USE --> CHAIN["Add to auth chain"] + WARN --> CHAIN + CHAIN --> NEXT{"More\nbackends?"} + NEXT -->|"yes"| LOOP + NEXT -->|"no"| AUTH["Run auth chain sequentially"] ``` -### Example Usage +**Why three levels?** +- **Level 1** (realm providers) ensures the user gets the exact provider instance configured for their realm +- **Level 2** (realm-scoped registry) enables dynamic/custom backends registered at startup without duplicating config +- **Level 3** (global registry) is a safety net for shared services, but logs a warning since it may use a provider from a different realm -**Scenario: Bypass MFA for CI/CD service account** +If `auth_backends` is `NULL` or empty, the realm's default auth chain is used with no registry lookup. + +### Examples ```sql --- Service account that needs to skip MFA for automation +-- Service account: skip MFA, only validate password UPDATE users SET auth_backends = 'sql' WHERE username = 'ci-deployment-bot'; --- Regular employee (NULL = use realm default: sql,mfa) +-- Regular employee: use realm default (sql + mfa) UPDATE users SET auth_backends = NULL WHERE username = 'apant'; --- Executive requiring hardware token +-- Executive: require additional hardware token UPDATE users SET auth_backends = 'sql,mfa,hardware-token' WHERE username = 'ceo'; ``` -**Auth Provider Registry** - -The registry maps backend type names to provider instances: - -```javascript -authProviderRegistry = Map { - // Realm-scoped (preferred) - 'mieweb-employees:sql' => SQLAuthProvider #1, - 'mieweb-employees:mfa' => MFAAuthProvider #1, - 'mieweb-employees:hardware-token' => HardwareTokenAuth #1, - - // Type-only fallback (first registered wins) - 'sql' => SQLAuthProvider #1, - 'mfa' => MFAAuthProvider #1, - 'hardware-token' => HardwareTokenAuth #1 -} -``` - ## Use Cases ### 1. Multi-Domain Organization -Serve users from different acquired companies: +Serve users from different acquired companies with complete isolation: ```json [ @@ -416,8 +390,6 @@ Serve users from different acquired companies: ] ``` -Different domains → different baseDNs → complete isolation. - ### 2. Service Account MFA Bypass Prevent automated tools from triggering MFA notifications: @@ -439,11 +411,9 @@ Prevent automated tools from triggering MFA notifications: ] ``` -Same baseDN, different authentication requirements. - ### 3. Gradual MFA Rollout -Deploy MFA to subsets of users: +Deploy MFA to subsets of users before full rollout: ```json [ @@ -519,106 +489,77 @@ SQL_URI=mysql://localhost/ldap_db ] ``` -2. Update environment: +2. Set the environment variable: ```bash REALM_CONFIG=/path/to/realms.json -# Keep other vars for backward compat if needed ``` -3. Restart gateway - **zero downtime**, behavior identical. +3. Restart the gateway. Behavior is identical — zero downtime. ### Adding New Realms -Simply append to the realms array and restart: +Append to the realms array and restart. No changes to existing realm configurations are needed. -```json -[ - { - "name": "existing-realm", - "baseDn": "dc=company,dc=com", - ... - }, - { - "name": "new-realm", - "baseDn": "dc=partner,dc=com", - ... - } -] -``` - -No changes to existing realm configurations needed. - -## Architecture Decisions - -### Why BaseDN Routing is Not Authorization - -**BaseDN routing = Addressing**, not authorization. It answers "which mailbox?" not "who can access?". - -| Responsibility | Layer | Example | -|---------------|-------|---------| -| **Addressing** | Gateway (baseDN routing) | Deliver search to `dc=mieweb,dc=com` realm | -| **Authentication** | Gateway (auth chain) | Verify user's password and MFA | -| **Authorization** | Client (SSSD filters) | `ldap_access_filter = memberOf=cn=employees,...` | +## Verifying Your Configuration -The gateway doesn't enforce "who can log into which server" - that's the client's job via group membership filters. +After deploying a new realm configuration: -### Why Registry Uses Three-Level Lookup +**1. Check startup logs for realm initialization:** +``` +Initializing multi-realm mode with 2 realm(s) +Realm 'mieweb-employees': baseDN=dc=mieweb,dc=com, directory=sql, auth=[sql, notification] +Realm 'service-accounts': baseDN=dc=mieweb,dc=com, directory=sql, auth=[sql] +``` -1. **Realm's Own Providers** (highest priority) - Zero ambiguity, guaranteed correct -2. **Realm-Scoped Registry** - Enables dynamic backends without config duplication -3. **Global Registry** - Emergency fallback for truly shared services (logs warnings) +**2. Test search against each baseDN:** +```bash +ldapsearch -x -H ldaps://localhost:636 -b "dc=mieweb,dc=com" "(uid=testuser)" +``` -This prevents accidental cross-realm provider sharing (e.g., SQL auth hitting wrong database). +**3. Test authentication (bind):** +```bash +ldapwhoami -x -H ldaps://localhost:636 \ + -D "uid=testuser,ou=users,dc=mieweb,dc=com" -w password +``` -### Why First-Match Wins +**4. Verify realm isolation** (different baseDN should not return users from another realm): +```bash +ldapsearch -x -H ldaps://localhost:636 -b "dc=bluehive,dc=com" "(uid=mieweb-user)" +# Should return 0 results +``` -Following PAM/nsswitch convention: admin controls priority via config order. If user exists in multiple realms sharing a baseDN, first realm in array wins (with warning logged). +## Troubleshooting -### Why Parallel Realm Search +### User not found during authentication -Realms are independent data sources - querying them sequentially would be unnecessarily slow. Parallel queries with graceful degradation (failed realm doesn't block others) maximize performance and reliability. +- **Check realm order** in `realms.json` — first realm with a matching user wins +- **Verify baseDN** matches what the client is sending (case-insensitive) +- **Check directory backend connectivity** — database connection errors are logged -## Key Files +### User authenticates against wrong realm -| File | Purpose | -|------|---------| -| [server/config/configurationLoader.js](../server/config/configurationLoader.js) | Loads and validates realm config | -| [server/serverMain.js](../server/serverMain.js) | Instantiates providers per realm | -| [npm/src/LdapEngine.js](../npm/src/LdapEngine.js) | Core routing and auth logic | -| [server/providers.js](../server/providers.js) | Provider factory | -| [server/backends/](../server/backends/) | Auth/directory provider implementations | -| [server/realms.example.json](../server/realms.example.json) | Example realm configuration | +- Realms sharing a baseDN are checked in config order; move the intended realm earlier in the array +- Check server logs for `"User found in multiple realms"` warnings -## Testing +### Per-user `auth_backends` override not working -Multi-realm tests are located in: -- [npm/test/unit/LdapEngine.realms.test.js](../npm/test/unit/LdapEngine.realms.test.js) - Unit tests for realm routing -- [server/test/integration/](../server/test/integration/) - Integration tests with real backends +- Verify the backend type name matches exactly (lookups are case-insensitive) +- Check logs for `"Unknown auth backend"` errors — the backend must be registered in the realm or global registry +- Ensure the `auth_backends` column value is comma-separated with no extra whitespace -## Performance Characteristics +### MFA still triggering for service accounts -| Operation | Complexity | Notes | -|-----------|-----------|-------| -| BaseDN routing | O(1) | Map lookup with lowercased key | -| User lookup | O(n) realms | Sequential, stops at first match | -| Search query | O(r) realms | Parallel queries where r = realms sharing baseDN | -| Auth chain resolution | O(p) providers | Sequential validation, short-circuits on failure | +- Confirm the service account is in a realm without the `notification` backend, **OR** set `auth_backends = 'sql'` on the user record +- Verify the user is being found in the correct realm (check logs for realm name) ## Security Considerations -1. **Unknown backend = fail-loud**: Per-user `auth_backends` referencing unknown backend throws error (no silent fallback) -2. **Cross-realm warnings**: Using global registry fallback logs warnings to detect misconfigurations -3. **Data isolation**: Different baseDNs = complete isolation (different subtrees) -4. **Auth chain integrity**: All providers must succeed (sequential AND logic) - -## Support & Resources - -- **Multi-Realm Planning Document**: [Multi-realm.md](../Multi-realm.md) -- **Provider Backend Guide**: [server/backends/template.js](../server/backends/template.js) -- **Example Configurations**: [server/realms.example.json](../server/realms.example.json) -- **Issues/Questions**: GitHub Issues +- **Unknown backends fail loudly**: Per-user `auth_backends` referencing an unknown backend throws an error and denies authentication. There is no silent fallback. +- **Data isolation**: Different baseDNs provide complete directory isolation. Realms on different baseDNs never share search results. +- **Auth chain integrity**: All providers in the chain must succeed (sequential AND logic). A single failure rejects the bind. ---- +## Further Reading -**Version**: Phase 1-3 Complete (per PR #143) -**Last Updated**: 2026-03-15 +- [Example realm configuration](../server/realms.example.json) +- [Custom backend template](../server/backends/template.js) +- [Multi-realm planning document](../Multi-realm.md) From 683eb195174fc4bde5ae3c0e903320380fcfddea Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Fri, 20 Mar 2026 15:01:14 -0400 Subject: [PATCH 13/14] Enforce 1:1 baseDN-to-realm and remove cross-realm fallback --- docs/MULTI-REALM-ARCHITECTURE.md | 264 ++++++--------- npm/src/LdapEngine.js | 308 +++++++----------- npm/test/unit/LdapEngine.realms.test.js | 268 ++------------- server/config/configurationLoader.js | 24 +- server/realms.example.json | 1 + server/serverMain.js | 53 +-- .../test/integration/auth/sqlite.auth.test.js | 2 +- .../unit/configurationLoader.realms.test.js | 4 +- 8 files changed, 297 insertions(+), 627 deletions(-) diff --git a/docs/MULTI-REALM-ARCHITECTURE.md b/docs/MULTI-REALM-ARCHITECTURE.md index 77060ea..4007910 100644 --- a/docs/MULTI-REALM-ARCHITECTURE.md +++ b/docs/MULTI-REALM-ARCHITECTURE.md @@ -18,35 +18,28 @@ flowchart TB subgraph "Client Layer" C1["SSSD Client\ndc=mieweb,dc=com"] C2["SSSD Client\ndc=bluehive,dc=com"] - C3["Service Account\ndc=mieweb,dc=com"] end subgraph "LDAP Gateway" - GW["BaseDN Router"] + GW["BaseDN Router\n1 baseDN = 1 Realm"] end subgraph "Realm Layer" R1["Realm: mieweb\ndc=mieweb,dc=com"] R2["Realm: bluehive\ndc=bluehive,dc=com"] - R3["Realm: service-accounts\ndc=mieweb,dc=com"] R1 --> DIR1["SQL Directory\nusers table"] R1 --> AUTH1["Auth: SQL + MFA"] R2 --> DIR2["SQL Directory\nbluehive_users"] R2 --> AUTH2["Auth: SQL"] - - R3 --> DIR3["SQL Directory\nservice_accounts"] - R3 --> AUTH3["Auth: SQL only"] end C1 -->|"bind/search"| GW C2 -->|"bind/search"| GW - C3 -->|"bind/search"| GW - GW -->|"dc=mieweb"| R1 - GW -->|"dc=bluehive"| R2 - GW -->|"dc=mieweb"| R3 + GW -->|"dc=mieweb,dc=com"| R1 + GW -->|"dc=bluehive,dc=com"| R2 ``` ## Prerequisites @@ -65,25 +58,22 @@ A **realm** is an isolated authentication and directory domain consisting of: | Property | Description | Example | |----------|-------------|---------| | `name` | Unique identifier | `"mieweb-employees"` | -| `baseDn` | LDAP subtree root | `"dc=mieweb,dc=com"` | +| `baseDn` | LDAP subtree root (unique per realm) | `"dc=mieweb,dc=com"` | +| `default` | Mark as default for SSSD discovery (max one) | `true` or omitted | | `directory` | Backend for user/group lookups | SQL, MongoDB, Proxmox | | `auth.backends` | Ordered list of authentication providers | SQL, Notification (MFA) | -Multiple realms can share the same baseDN (e.g., employees and service accounts both under `dc=mieweb,dc=com`). - -### Routing Behavior +**Each baseDN maps to exactly one realm (1:1).** The gateway rejects configurations where two realms share the same baseDN at startup. This eliminates cross-realm ambiguity in bind and search operations. -**Different baseDNs (complete isolation):** -- Each baseDN routes to its own set of realms -- No result merging across baseDNs +> **Default realm:** Only one realm may be marked `"default": true`. If no realm is explicitly marked, the first realm in the configuration array is used as the default for SSSD discovery (`defaultNamingContext`). -**Same baseDN, multiple realms:** +### Routing Behavior | Operation | Behavior | |-----------|----------| -| **Bind (auth)** | Realms checked in config order; first realm containing the user wins | -| **Search** | All realms queried in parallel; results merged with DN deduplication | -| **Duplicate DNs** | First realm's entry wins; duplicates silently dropped | +| **Bind (auth)** | ldapjs routes by DN suffix to the single realm owning that baseDN | +| **Search** | Queries the single realm's directory provider directly | +| **RootDSE** | Returns all baseDNs in `namingContexts`, plus `defaultNamingContext` for the default realm | ### Authentication Chain @@ -102,66 +92,56 @@ In the example above, SQL password validation must pass first, then the MFA noti ### Authentication Flow -When a client binds (authenticates), the gateway routes by baseDN, finds the user across realms, and runs the authentication chain: +When a client binds (authenticates), the gateway routes by baseDN to the single owning realm, looks up the user, and runs the authentication chain: ```mermaid sequenceDiagram participant Client participant Gateway as LDAP Gateway - participant R1 as Realm: employees - participant R2 as Realm: service-accounts + participant Realm as Realm: mieweb participant Auth as Auth Chain - Client->>Gateway: BIND uid=ldap-reader,dc=mieweb,dc=com - - Gateway->>Gateway: Route by baseDN: dc=mieweb,dc=com + Client->>Gateway: BIND uid=apant,dc=mieweb,dc=com - Gateway->>R1: Find user 'ldap-reader' - R1-->>Gateway: Not found + Gateway->>Gateway: Route by baseDN → realm 'mieweb' - Gateway->>R2: Find user 'ldap-reader' - R2-->>Gateway: Found (auth_backends: null) + Gateway->>Realm: findUser('apant') + Realm-->>Gateway: Found (auth_backends: null) - Note over Gateway: First match wins - - Gateway->>Gateway: Resolve auth chain for realm + Gateway->>Gateway: Resolve auth chain alt auth_backends is null - Note over Gateway: Use realm default: [SQL] - else auth_backends = 'sql,hardware-token' - Note over Gateway: Use per-user override + Note over Gateway: Use realm default: [SQL, MFA] + else auth_backends = 'sql' + Note over Gateway: Per-user override: [SQL only] end Gateway->>Auth: SQL password check Auth-->>Gateway: Pass + Gateway->>Auth: MFA notification + Auth-->>Gateway: Approved - Gateway-->>Client: BIND SUCCESS (realm: service-accounts) + Gateway-->>Client: BIND SUCCESS (realm: mieweb) ``` ### Search Flow -When a client searches, all realms sharing the baseDN are queried in parallel and results are merged: +When a client searches, the query is routed to the single realm owning that baseDN: ```mermaid sequenceDiagram participant Client participant Gateway as LDAP Gateway - participant R1 as Realm: employees - participant R2 as Realm: service-accounts + participant Realm as Realm: mieweb Client->>Gateway: SEARCH base=dc=mieweb,dc=com - par Query all matching realms - Gateway->>R1: Get users - R1-->>Gateway: [uid=apant, uid=horner] - - Gateway->>R2: Get users - R2-->>Gateway: [uid=ldap-reader, uid=monitoring-bot] - end + Gateway->>Gateway: Route by baseDN → realm 'mieweb' - Gateway->>Gateway: Merge & deduplicate by DN + Gateway->>Realm: getAllUsers() + Realm-->>Gateway: [uid=apant, uid=horner, uid=ldap-reader] - Gateway-->>Client: 4 unique entries + Gateway-->>Client: 3 entries ``` ## Configuration @@ -189,6 +169,7 @@ REALM_CONFIG='[{"name":"mieweb","baseDn":"dc=mieweb,dc=com",...}]' { "name": "mieweb-employees", "baseDn": "dc=mieweb,dc=com", + "default": true, "directory": { "backend": "sql", "options": { @@ -218,13 +199,16 @@ REALM_CONFIG='[{"name":"mieweb","baseDn":"dc=mieweb,dc=com",...}]' } }, { - "name": "service-accounts", - "baseDn": "dc=mieweb,dc=com", + "name": "bluehive", + "baseDn": "dc=bluehive,dc=com", "directory": { "backend": "sql", "options": { - "sqlUri": "mysql://user:pass@db.mieweb.com:3306/company_prod", - "sqlQueryOneUser": "SELECT * FROM service_accounts WHERE username = ?" + "sqlUri": "mysql://user:pass@db.bluehive.com:3306/bluehive_prod", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?", + "sqlQueryAllUsers": "SELECT * FROM users", + "sqlQueryAllGroups": "SELECT * FROM groups", + "sqlQueryGroupsByMember": "SELECT * FROM groups g WHERE JSON_CONTAINS(g.member_uids, JSON_QUOTE(?))" } }, "auth": { @@ -232,8 +216,8 @@ REALM_CONFIG='[{"name":"mieweb","baseDn":"dc=mieweb,dc=com",...}]' { "type": "sql", "options": { - "sqlUri": "mysql://user:pass@db.mieweb.com:3306/company_prod", - "sqlQueryOneUser": "SELECT * FROM service_accounts WHERE username = ?" + "sqlUri": "mysql://user:pass@db.bluehive.com:3306/bluehive_prod", + "sqlQueryOneUser": "SELECT * FROM users WHERE username = ?" } } ] @@ -249,7 +233,8 @@ A full example configuration is available at [`server/realms.example.json`](../s | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Unique realm identifier | -| `baseDn` | string | Yes | LDAP base DN for this realm | +| `baseDn` | string | Yes | LDAP base DN for this realm (must be unique across all realms) | +| `default` | boolean | No | If `true`, this realm's baseDN is advertised as `defaultNamingContext` in RootDSE (for SSSD discovery). Only one realm may be marked as default. | | `directory.backend` | string | Yes | Directory provider type (`sql`, `mongo`, `proxmox`) | | `directory.options` | object | No | Provider-specific options (connection strings, queries) | | `auth.backends[]` | array | Yes | Ordered list of auth backends | @@ -271,7 +256,7 @@ The gateway automatically wraps these into a single realm named `"default"`. No ## Per-User Authentication Override -Individual users can override their realm's default authentication chain via the `auth_backends` database column. This allows fine-grained control without creating additional realms. +Individual users can override their realm's default authentication chain via the `auth_backends` database column. This allows fine-grained control — for example, service accounts can skip MFA while employees go through the full chain. ### Database Schema @@ -282,77 +267,30 @@ ALTER TABLE users ADD COLUMN auth_backends VARCHAR(255) NULL COMMENT 'Comma-separated auth backend types. NULL = use realm default.'; ``` -### Auth Provider Registry - -The registry is a key-value map of backend type names to provider instances, built automatically at startup. It enables per-user overrides by letting the gateway look up any auth provider by name. - -**How the registry is built:** - -1. For each realm, every auth backend is registered with **two keys**: - - **Realm-scoped key** (`realm-name:type`) — always registered, unique per realm - - **Global key** (`type`) — registered only if no other realm claimed that type first - -2. All keys are **normalized to lowercase** for case-insensitive lookups - -**Example:** Given this configuration with two realms: - -```json -[ - { - "name": "employees", - "auth": { "backends": [{ "type": "sql" }, { "type": "notification" }] } - }, - { - "name": "service-accounts", - "auth": { "backends": [{ "type": "sql" }] } - } -] -``` - -The resulting registry contains: - -| Registry Key | Provider | How Registered | -|-------------|----------|----------------| -| `employees:sql` | SQL provider (employees) | Realm-scoped | -| `employees:notification` | MFA provider (employees) | Realm-scoped | -| `service-accounts:sql` | SQL provider (service-accounts) | Realm-scoped | -| `sql` | SQL provider (employees) | Global fallback (first realm wins) | -| `notification` | MFA provider (employees) | Global fallback | - ### How Override Resolution Works -When a user has `auth_backends` set (e.g., `'sql,hardware-token'`), each backend name is resolved through a three-level lookup: +When a user authenticates, the gateway checks the `auth_backends` field on their user record. Resolution is **strictly realm-scoped** — the override can only reference backend types configured in the user's own realm: ```mermaid flowchart TD - START["User has auth_backends = 'sql,mfa'"] --> PARSE["Split: ['sql', 'mfa']"] + START["User has auth_backends = 'sql'"] --> PARSE["Split: ['sql']"] PARSE --> LOOP["For each backend name:"] - LOOP --> STEP1["1. Check realm's own providers\n(matched by type name)"] + LOOP --> STEP1["Look up in realm.authBackendTypes\n(type name → provider instance)"] STEP1 -->|"found"| USE["Use this provider"] - STEP1 -->|"not found"| STEP2 - - STEP2["2. Check registry:\nrealm-scoped key (realm:type)"] - STEP2 -->|"found"| USE - STEP2 -->|"not found"| STEP3 - - STEP3["3. Check registry:\nglobal key (type only)"] - STEP3 -->|"found"| WARN["Use provider + log warning"] - STEP3 -->|"not found"| FAIL["Deny authentication:\nunknown backend"] + STEP1 -->|"not found"| FAIL["Deny authentication:\nunknown backend"] USE --> CHAIN["Add to auth chain"] - WARN --> CHAIN CHAIN --> NEXT{"More\nbackends?"} NEXT -->|"yes"| LOOP NEXT -->|"no"| AUTH["Run auth chain sequentially"] ``` -**Why three levels?** -- **Level 1** (realm providers) ensures the user gets the exact provider instance configured for their realm -- **Level 2** (realm-scoped registry) enables dynamic/custom backends registered at startup without duplicating config -- **Level 3** (global registry) is a safety net for shared services, but logs a warning since it may use a provider from a different realm +Each realm maintains an `authBackendTypes` map built at startup from the `auth.backends[].type` values in the realm config. The type names come directly from the backend module exports (e.g., `"sql"`, `"notification"`, `"ldap"`). -If `auth_backends` is `NULL` or empty, the realm's default auth chain is used with no registry lookup. +**Key security property**: If `auth_backends` references a backend type not configured in the user's realm, authentication is **immediately denied**. There is no cross-realm fallback. + +If `auth_backends` is `NULL` or empty, the realm's default auth chain is used. ### Examples @@ -367,6 +305,8 @@ UPDATE users SET auth_backends = NULL WHERE username = 'apant'; UPDATE users SET auth_backends = 'sql,mfa,hardware-token' WHERE username = 'ceo'; ``` +> **Note:** Backend names in `auth_backends` must match the `type` values from your `auth.backends` configuration (case-insensitive). For example, if your realm has `{"type": "sql"}` and `{"type": "notification"}`, valid override values are `'sql'`, `'notification'`, or `'sql,notification'`. + ## Use Cases ### 1. Multi-Domain Organization @@ -378,6 +318,7 @@ Serve users from different acquired companies with complete isolation: { "name": "mieweb", "baseDn": "dc=mieweb,dc=com", + "default": true, "directory": { "backend": "sql", "options": { "database": "mieweb_users" } }, "auth": { "backends": [{ "type": "sql" }] } }, @@ -390,63 +331,46 @@ Serve users from different acquired companies with complete isolation: ] ``` -### 2. Service Account MFA Bypass +### 2. Service Account MFA Bypass (Per-User Override) -Prevent automated tools from triggering MFA notifications: +Use the `auth_backends` database column to let service accounts skip MFA within the same realm: ```json [ { - "name": "employees", + "name": "company", "baseDn": "dc=company,dc=com", - "directory": { "backend": "sql", "options": { "table": "users" } }, - "auth": { "backends": [{ "type": "sql" }, { "type": "mfa" }] } - }, - { - "name": "service-accounts", - "baseDn": "dc=company,dc=com", - "directory": { "backend": "sql", "options": { "table": "service_accounts" } }, - "auth": { "backends": [{ "type": "sql" }] } + "default": true, + "directory": { "backend": "sql" }, + "auth": { "backends": [{ "type": "sql" }, { "type": "notification" }] } } ] ``` -### 3. Gradual MFA Rollout - -Deploy MFA to subsets of users before full rollout: +```sql +-- Service account: skip MFA, only validate password +UPDATE users SET auth_backends = 'sql' WHERE username = 'ci-deployment-bot'; -```json -[ - { - "name": "engineering", - "baseDn": "dc=company,dc=com", - "directory": { "backend": "sql", "options": { "department": "Engineering" } }, - "auth": { "backends": [{ "type": "sql" }, { "type": "mfa" }] } - }, - { - "name": "other-departments", - "baseDn": "dc=company,dc=com", - "directory": { "backend": "sql", "options": { "department": "!Engineering" } }, - "auth": { "backends": [{ "type": "sql" }] } - } -] +-- Regular employee: use realm default (sql + notification MFA) +UPDATE users SET auth_backends = NULL WHERE username = 'apant'; ``` -### 4. Hybrid Authentication +### 3. Hybrid Authentication -Mix database users with LDAP federation: +Mix database users with LDAP federation using separate baseDNs: ```json [ { "name": "local-users", "baseDn": "dc=company,dc=com", + "default": true, "directory": { "backend": "sql" }, "auth": { "backends": [{ "type": "sql" }] } }, { "name": "corporate-ad", - "baseDn": "dc=company,dc=com", + "baseDn": "dc=corp,dc=company,dc=com", "directory": { "backend": "ldap", "options": { "host": "ad.corp.local" } }, "auth": { "backends": [{ "type": "ldap" }] } } @@ -473,6 +397,7 @@ SQL_URI=mysql://localhost/ldap_db { "name": "default", "baseDn": "dc=mieweb,dc=com", + "default": true, "directory": { "backend": "sql", "options": { @@ -498,7 +423,7 @@ REALM_CONFIG=/path/to/realms.json ### Adding New Realms -Append to the realms array and restart. No changes to existing realm configurations are needed. +Append to the realms array with a **unique baseDN** and restart. No changes to existing realm configurations are needed. ## Verifying Your Configuration @@ -507,22 +432,28 @@ After deploying a new realm configuration: **1. Check startup logs for realm initialization:** ``` Initializing multi-realm mode with 2 realm(s) -Realm 'mieweb-employees': baseDN=dc=mieweb,dc=com, directory=sql, auth=[sql, notification] -Realm 'service-accounts': baseDN=dc=mieweb,dc=com, directory=sql, auth=[sql] +Realm 'mieweb-employees': baseDN=dc=mieweb,dc=com, directory=sql, auth=[sql, notification] (default) +Realm 'bluehive': baseDN=dc=bluehive,dc=com, directory=sql, auth=[sql] ``` -**2. Test search against each baseDN:** +**2. Verify RootDSE discovery (SSSD compatibility):** +```bash +ldapsearch -x -H ldaps://localhost:636 -b "" -s base "(objectClass=*)" namingContexts defaultNamingContext +# Should show all baseDNs in namingContexts and the default realm's baseDN in defaultNamingContext +``` + +**3. Test search against each baseDN:** ```bash ldapsearch -x -H ldaps://localhost:636 -b "dc=mieweb,dc=com" "(uid=testuser)" ``` -**3. Test authentication (bind):** +**4. Test authentication (bind):** ```bash ldapwhoami -x -H ldaps://localhost:636 \ -D "uid=testuser,ou=users,dc=mieweb,dc=com" -w password ``` -**4. Verify realm isolation** (different baseDN should not return users from another realm): +**5. Verify realm isolation** (different baseDN should not return users from another realm): ```bash ldapsearch -x -H ldaps://localhost:636 -b "dc=bluehive,dc=com" "(uid=mieweb-user)" # Should return 0 results @@ -532,31 +463,36 @@ ldapsearch -x -H ldaps://localhost:636 -b "dc=bluehive,dc=com" "(uid=mieweb-user ### User not found during authentication -- **Check realm order** in `realms.json` — first realm with a matching user wins -- **Verify baseDN** matches what the client is sending (case-insensitive) +- **Verify baseDN** matches what the client is sending (case-insensitive) — each baseDN routes to exactly one realm - **Check directory backend connectivity** — database connection errors are logged - -### User authenticates against wrong realm - -- Realms sharing a baseDN are checked in config order; move the intended realm earlier in the array -- Check server logs for `"User found in multiple realms"` warnings +- **Ensure the user exists** in the realm's directory backend ### Per-user `auth_backends` override not working -- Verify the backend type name matches exactly (lookups are case-insensitive) -- Check logs for `"Unknown auth backend"` errors — the backend must be registered in the realm or global registry +- Verify the backend type name matches a type configured in the user's realm (lookups are case-insensitive) +- Check logs for `"Unknown auth backend"` errors — the backend must be configured in the realm's `auth.backends` array - Ensure the `auth_backends` column value is comma-separated with no extra whitespace ### MFA still triggering for service accounts -- Confirm the service account is in a realm without the `notification` backend, **OR** set `auth_backends = 'sql'` on the user record -- Verify the user is being found in the correct realm (check logs for realm name) +- Set `auth_backends = 'sql'` on the service account's user record to skip the `notification` backend +- Verify the user record actually has the column set (not `NULL`) + +### SSSD auto-discovery not working with multiple realms + +- SSSD requires a single `defaultNamingContext` in the RootDSE. Mark one realm as `"default": true` in your `realms.json` +- If no realm is marked as default, the first realm is used + +### Startup failure: duplicate baseDN + +- Each baseDN must be unique across all realms. If you need multiple user populations under one baseDN, use a single realm with per-user `auth_backends` overrides instead ## Security Considerations -- **Unknown backends fail loudly**: Per-user `auth_backends` referencing an unknown backend throws an error and denies authentication. There is no silent fallback. -- **Data isolation**: Different baseDNs provide complete directory isolation. Realms on different baseDNs never share search results. +- **1:1 baseDN-to-realm**: Each baseDN maps to exactly one realm. There is no ambiguity about which realm handles a request — cross-realm user confusion or authentication bypass is not possible. +- **Strictly realm-scoped auth override**: Per-user `auth_backends` can only reference backend types configured in the user's own realm. Unknown backends immediately deny authentication. There is no cross-realm fallback. - **Auth chain integrity**: All providers in the chain must succeed (sequential AND logic). A single failure rejects the bind. +- **Data isolation**: Different baseDNs provide complete directory isolation. Searches on one baseDN never return results from another realm. ## Further Reading diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index 6bcf738..b0a705e 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -13,8 +13,8 @@ const { * Handles LDAP server setup, bind operations, and search operations. * * Supports multi-realm mode: each realm pairs a directory backend + auth chain - * with a baseDN. Searches are routed by baseDN; binds locate the user across - * realms sharing a baseDN and apply the correct auth chain. + * with a baseDN. Each baseDN maps to exactly one realm (1:1). Searches and binds + * are routed by baseDN to the owning realm. * * Backward compatible: when no `realms` option is provided, the engine wraps * the legacy `authProviders`/`directoryProvider`/`baseDn` into one implicit realm. @@ -36,6 +36,8 @@ class LdapEngine extends EventEmitter { ...options }; + this.logger = options.logger || console; + // Build realm data structures this._initRealms(options); @@ -43,20 +45,10 @@ class LdapEngine extends EventEmitter { this.authProviders = options.authProviders || this.allRealms[0]?.authProviders; this.directoryProvider = options.directoryProvider || this.allRealms[0]?.directoryProvider; - // Auth provider registry for per-user auth override (Phase 3) - // Maps provider type name → AuthProvider instance - // Keys are normalized to lowercase for case-insensitive lookups - const incomingRegistry = options.authProviderRegistry || new Map(); - this.authProviderRegistry = new Map(); - for (const [key, value] of incomingRegistry.entries()) { - const normalizedKey = String(key).toLowerCase(); - if (!this.authProviderRegistry.has(normalizedKey)) { - this.authProviderRegistry.set(normalizedKey, value); - } - } + // Default realm for RootDSE defaultNamingContext (SSSD discovery) + this.defaultRealm = options.defaultRealm || this.allRealms[0] || null; this.server = null; - this.logger = options.logger || console; this._stopping = false; } @@ -71,7 +63,9 @@ class LdapEngine extends EventEmitter { name: r.name, baseDn: r.baseDn, directoryProvider: r.directoryProvider, - authProviders: r.authProviders || [] + authProviders: r.authProviders || [], + // Explicit map of backend type name → provider instance for per-user auth override + authBackendTypes: r.authBackendTypes || new Map() })); } else if (options.authProviders && options.directoryProvider) { // Legacy single-realm mode: wrap into one implicit realm @@ -80,19 +74,34 @@ class LdapEngine extends EventEmitter { name: 'default', baseDn, directoryProvider: options.directoryProvider, - authProviders: options.authProviders + authProviders: options.authProviders, + authBackendTypes: options.authBackendTypes || new Map() }]; } else { this.allRealms = []; } - // Index realms by baseDN (lowercased) for O(1) routing + // Warn about realms with auth providers but no type map (per-user overrides won't work) + for (const realm of this.allRealms) { + if (realm.authProviders.length > 0 && realm.authBackendTypes.size === 0) { + this.logger.warn( + `Realm '${realm.name}' has auth providers but no authBackendTypes map — per-user auth overrides will not work` + ); + } + } + + // Index realms by baseDN (lowercased) — each baseDN maps to exactly one realm this.realmsByBaseDn = new Map(); for (const realm of this.allRealms) { const key = realm.baseDn.toLowerCase(); - const existing = this.realmsByBaseDn.get(key) || []; - existing.push(realm); - this.realmsByBaseDn.set(key, existing); + if (this.realmsByBaseDn.has(key)) { + const existing = this.realmsByBaseDn.get(key); + throw new Error( + `Duplicate baseDN '${realm.baseDn}': realm '${realm.name}' conflicts with realm '${existing.name}'. ` + + `Each baseDN must map to exactly one realm.` + ); + } + this.realmsByBaseDn.set(key, realm); } } @@ -101,6 +110,13 @@ class LdapEngine extends EventEmitter { * @returns {Promise} */ async start() { + if (this.allRealms.length === 0) { + throw new Error( + 'Cannot start LDAP server: no realms configured. ' + + 'Provide either a realms array or authProviders/directoryProvider.' + ); + } + // Initialize all realm providers for (const realm of this.allRealms) { if (realm.directoryProvider && typeof realm.directoryProvider.initialize === 'function') { @@ -257,24 +273,24 @@ class LdapEngine extends EventEmitter { return next(); }); - // Register one bind handler per unique baseDN - for (const [baseDn, realms] of this.realmsByBaseDn) { + // Register one bind handler per baseDN (1:1 with realm) + for (const [baseDn, realm] of this.realmsByBaseDn) { this.server.bind(baseDn, (req, res, next) => { const { username, password } = this._extractCredentials(req); this.logger.debug("Authenticated bind request", { username, dn: req.dn.toString() }); this.emit('bindRequest', { username, anonymous: false }); - this._authenticateAcrossRealms(realms, username, password, req) - .then(({ authenticated, realmName }) => { + this._authenticateInRealm(realm, username, password, req) + .then(({ authenticated }) => { if (!authenticated) { this.emit('bindFail', { username, reason: 'invalid_credentials' }); const error = new ldap.InvalidCredentialsError('Invalid credentials'); return next(error); } - this.logger.debug(`User ${username} authenticated via realm '${realmName}'`); - this.emit('bindSuccess', { username, anonymous: false, realm: realmName }); + this.logger.debug(`User ${username} authenticated via realm '${realm.name}'`); + this.emit('bindSuccess', { username, anonymous: false, realm: realm.name }); res.end(); return next(); }) @@ -290,76 +306,55 @@ class LdapEngine extends EventEmitter { } /** - * Find the user across realms sharing a baseDN and authenticate with the - * matching realm's auth chain (or per-user override if auth_backends is set). - * - * Flow: iterate realms in config order → first findUser() hit determines - * which realm's auth chain to use → if the user record has `auth_backends`, - * resolve an override chain from the authProviderRegistry → otherwise fall - * back to the realm's default auth providers → authenticate sequentially. + * Authenticate a user within a single realm. + * Looks up the user in the realm's directory, resolves the auth chain + * (per-user override or realm default), and authenticates sequentially. * * @private - * @param {Array} realms - Realms sharing this baseDN + * @param {Object} realm - The realm to authenticate against * @param {string} username * @param {string} password * @param {Object} req - LDAP request - * @returns {Promise<{authenticated: boolean, realmName: string|null}>} + * @returns {Promise<{authenticated: boolean}>} */ - async _authenticateAcrossRealms(realms, username, password, req) { - // Find which realm owns this user - let matchedRealm = null; - let matchedUser = null; - - for (const realm of realms) { - try { - const user = await realm.directoryProvider.findUser(username); - if (user) { - if (!matchedRealm) { - matchedRealm = realm; - matchedUser = user; - } else { - this.logger.warn( - `User '${username}' found in multiple realms: '${matchedRealm.name}' and '${realm.name}'. ` + - `Using first match '${matchedRealm.name}'.` - ); - } - } - } catch (err) { - this.logger.error(`Error finding user '${username}' in realm '${realm.name}':`, err); - } + async _authenticateInRealm(realm, username, password, req) { + let user; + try { + user = await realm.directoryProvider.findUser(username); + } catch (err) { + this.logger.error(`Error finding user '${username}' in realm '${realm.name}':`, err); + return { authenticated: false }; } - if (!matchedRealm) { - this.logger.debug(`User '${username}' not found in any realm`); - return { authenticated: false, realmName: null }; + if (!user) { + this.logger.debug(`User '${username}' not found in realm '${realm.name}'`); + return { authenticated: false }; } // Resolve the auth chain: per-user override or realm default - const authChain = this._resolveAuthChain(matchedRealm, matchedUser, username); + const authChain = this._resolveAuthChain(realm, user, username); // Authenticate sequentially against the resolved auth chain for (const provider of authChain) { const result = await provider.authenticate(username, password, req); if (result !== true) { - return { authenticated: false, realmName: matchedRealm.name }; + return { authenticated: false }; } } - return { authenticated: true, realmName: matchedRealm.name }; + return { authenticated: true }; } /** * Resolve the auth provider chain for a user. * * If the user record has an `auth_backends` field (comma-separated provider - * type names), look up each name in the authProviderRegistry to build a - * per-user override chain. If `auth_backends` is null/undefined/empty, - * fall back to the realm's default auth providers. + * type names), look up each name in the realm's authBackendTypes map. + * If `auth_backends` is null/undefined/empty, fall back to the realm's + * default auth providers. * - * Resolution order for each backend name: - * 1. Exact match in realm's own auth providers (by type) - * 2. Registry with realm-scoped key "realm:type" - * 3. Registry with type-only key (cross-realm fallback) + * Resolution is strictly realm-scoped — if a backend name cannot be resolved + * within the realm, authentication fails immediately. * * @private * @param {Object} realm - The matched realm @@ -384,48 +379,16 @@ class LdapEngine extends EventEmitter { return realm.authProviders; } - // Build a quick lookup map of realm's own auth providers by type - const realmAuthByType = new Map(); - for (const provider of realm.authProviders) { - // Extract type from constructor name (e.g., SQLAuthProvider -> sql) - const type = provider.constructor.name - .replace(/AuthProvider$/i, '') - .replace(/Backend$/i, '') - .toLowerCase(); - if (!realmAuthByType.has(type)) { - realmAuthByType.set(type, provider); - } - } - - // Resolve each name from realm's providers first, then registry + // Resolve each name strictly from the realm's auth backend types const overrideChain = []; for (const name of backendNames) { const normalizedName = name.toLowerCase(); - - // 1. Check realm's own auth providers first - let provider = realmAuthByType.get(normalizedName); - - if (!provider) { - // 2. Try realm-scoped registry key (case-insensitive) - const namespacedKey = `${realm.name}:${normalizedName}`.toLowerCase(); - provider = this.authProviderRegistry.get(namespacedKey); - } - - if (!provider) { - // 3. Fall back to type-only registry key (cross-realm, case-insensitive) - provider = this.authProviderRegistry.get(normalizedName); - if (provider) { - this.logger.warn( - `User '${username}' in realm '${realm.name}' using cross-realm auth backend '${name}'. ` + - `Consider using realm-scoped key '${realm.name}:${normalizedName}' for clarity.` - ); - } - } + const provider = realm.authBackendTypes.get(normalizedName); if (!provider) { this.logger.error( - `User '${username}' has auth_backends='${userBackends}' but backend '${name}' ` + - `is not found in realm '${realm.name}' providers or registry. ` + + `User '${username}' has auth_backends='${userBackends}' but backend '${normalizedName}' ` + + `is not found in realm '${realm.name}' (available: [${[...realm.authBackendTypes.keys()].join(', ')}]). ` + `Failing authentication for security.` ); throw new Error(`Unknown auth backend '${name}' for user '${username}'`); @@ -468,8 +431,8 @@ class LdapEngine extends EventEmitter { return next(); }; - // Register one search handler per unique baseDN - for (const [baseDn, realms] of this.realmsByBaseDn) { + // Register one search handler per baseDN (1:1 with realm) + for (const [baseDn, realm] of this.realmsByBaseDn) { this.server.search(baseDn, authorizeSearch, async (req, res, next) => { const filterStr = req.filter.toString(); this.logger.debug(`LDAP Search - Filter: ${filterStr}, Attributes: ${req.attributes}`); @@ -485,7 +448,7 @@ class LdapEngine extends EventEmitter { scope: req.scope }); - entryCount = await this._handleMultiRealmSearch(realms, filterStr, req.attributes, res); + entryCount = await this._handleRealmSearch(realm, filterStr, req.attributes, res); const duration = Date.now() - startTime; this.emit('searchResponse', { @@ -533,71 +496,33 @@ class LdapEngine extends EventEmitter { } /** - * Handle search across multiple realms sharing a baseDN. - * Queries each realm's directory provider, deduplicates entries by DN, - * and sends merged results. + * Handle search operations for a single realm and send results. * @private - * @param {Array} realms - Realms sharing the same baseDN + * @param {Object} realm - Realm object with directoryProvider and baseDn * @param {string} filterStr - LDAP filter string * @param {Array} attributes - Requested attributes * @param {Object} res - ldapjs response object * @returns {number} Number of entries sent */ - async _handleMultiRealmSearch(realms, filterStr, attributes, res) { - // Collect entries from all realms in parallel with graceful degradation - const realmPromises = realms.map(realm => - this._handleRealmSearch(realm, filterStr, attributes) - .catch(err => { - this.logger.error(`Search failed in realm '${realm.name}':`, err); - return { entries: [], realmName: realm.name, error: err }; - }) - ); - const realmResults = await Promise.all(realmPromises); - - // Deduplicate entries by DN (first realm wins for same DN) - const seenDNs = new Set(); - let entryCount = 0; - - for (const { entries, realmName } of realmResults) { - for (const { entry, type } of entries) { - const dnLower = entry.dn.toLowerCase(); - if (seenDNs.has(dnLower)) { - this.logger.debug(`Skipping duplicate DN from realm '${realmName}': ${entry.dn}`); - continue; - } - seenDNs.add(dnLower); - this.emit('entryFound', { type: type || 'user', entry: entry.dn, realm: realmName }); - res.send(entry); - entryCount++; - } - } - - return entryCount; - } - - /** - * Handle search operations for a single realm. - * Returns entries array instead of sending directly to res. - * @private - * @param {Object} realm - Realm object with directoryProvider and baseDn - * @param {string} filterStr - LDAP filter string - * @param {Array} attributes - Requested attributes - * @returns {{ entries: Array<{entry: Object, type: string}>, realmName: string }} - */ - async _handleRealmSearch(realm, filterStr, attributes) { - const entries = []; + async _handleRealmSearch(realm, filterStr, attributes, res) { const { directoryProvider, baseDn, name: realmName } = realm; const username = getUsernameFromFilter(filterStr); + let entryCount = 0; + + const sendEntry = (entry, type) => { + this.emit('entryFound', { type, entry: entry.dn, realm: realmName }); + res.send(entry); + entryCount++; + }; // Handle specific user requests if (username) { this.logger.debug(`[${realmName}] Searching for specific user: ${username}`); const user = await directoryProvider.findUser(username); if (user) { - const entry = createLdapEntry(user, baseDn); - entries.push({ entry, type: 'user' }); + sendEntry(createLdapEntry(user, baseDn), 'user'); } - return { entries, realmName }; + return entryCount; } // Handle all users requests @@ -607,10 +532,9 @@ class LdapEngine extends EventEmitter { this.logger.debug(`[${realmName}] Found ${users.length} users`); for (const user of users) { - const entry = createLdapEntry(user, baseDn); - entries.push({ entry, type: 'user' }); + sendEntry(createLdapEntry(user, baseDn), 'user'); } - return { entries, realmName }; + return entryCount; } // Handle group search requests @@ -620,10 +544,9 @@ class LdapEngine extends EventEmitter { this.logger.debug(`[${realmName}] Found ${groups.length} groups`); for (const group of groups) { - const entry = createLdapGroupEntry(group, baseDn); - entries.push({ entry, type: 'group' }); + sendEntry(createLdapGroupEntry(group, baseDn), 'group'); } - return { entries, realmName }; + return entryCount; } // Handle mixed searches (both users and groups) @@ -647,8 +570,7 @@ class LdapEngine extends EventEmitter { } } - const entry = createLdapEntry(user, baseDn); - entries.push({ entry, type: 'user' }); + sendEntry(createLdapEntry(user, baseDn), 'user'); } const groups = await directoryProvider.getAllGroups(); @@ -659,14 +581,13 @@ class LdapEngine extends EventEmitter { continue; } - const entry = createLdapGroupEntry(group, baseDn); - entries.push({ entry, type: 'group' }); + sendEntry(createLdapGroupEntry(group, baseDn), 'group'); } - return { entries, realmName }; + return entryCount; } this.logger.debug(`[${realmName}] No matching search pattern found for filter: ${filterStr}`); - return { entries, realmName }; + return entryCount; } /** @@ -690,15 +611,8 @@ class LdapEngine extends EventEmitter { this.emit('rootDSERequest', { filter: filterStr, attributes: requestedAttrs }); // Collect unique baseDNs (preserving original casing from realm config) - const seenDns = new Set(); - const allBaseDns = []; - for (const realm of this.allRealms) { - const key = realm.baseDn.toLowerCase(); - if (!seenDns.has(key)) { - seenDns.add(key); - allBaseDns.push(realm.baseDn); - } - } + const allBaseDns = this.allRealms.map(r => r.baseDn); + const defaultBaseDn = this.defaultRealm ? this.defaultRealm.baseDn : allBaseDns[0]; // RootDSE attribute filtering rules (per RFC 4512): // - No attributes requested = return all (user + operational) @@ -709,6 +623,17 @@ class LdapEngine extends EventEmitter { const hasWildcard = requestedAttrs.includes('*'); const hasPlus = requestedAttrs.includes('+'); const noAttrsRequested = requestedAttrs.length === 0; + + // Helper to populate operational attributes into the attributes object + const addOperationalAttr = (attrLower, attributes) => { + if (attrLower === 'namingcontexts') { + attributes.namingContexts = allBaseDns; + } else if (attrLower === 'defaultnamingcontext' && defaultBaseDn) { + attributes.defaultNamingContext = defaultBaseDn; + } else if (attrLower === 'supportedldapversion') { + attributes.supportedLDAPVersion = ['3']; + } + }; const attributes = { objectClass: ['top'] @@ -717,27 +642,16 @@ class LdapEngine extends EventEmitter { if (noAttrsRequested || (hasWildcard && hasPlus) || (hasPlus && !hasWildcard)) { // Return all operational attributes attributes.namingContexts = allBaseDns; + if (defaultBaseDn) { + attributes.defaultNamingContext = defaultBaseDn; + } attributes.supportedLDAPVersion = ['3']; } else if (hasWildcard) { // '*' only: user attrs + specifically requested operational attrs - requestedAttrs.forEach(attr => { - const attrLower = attr.toLowerCase(); - if (attrLower === 'namingcontexts') { - attributes.namingContexts = allBaseDns; - } else if (attrLower === 'supportedldapversion') { - attributes.supportedLDAPVersion = ['3']; - } - }); + requestedAttrs.forEach(attr => addOperationalAttr(attr.toLowerCase(), attributes)); } else { // Specific attributes only — return only what was requested - requestedAttrs.forEach(attr => { - const attrLower = attr.toLowerCase(); - if (attrLower === 'namingcontexts') { - attributes.namingContexts = allBaseDns; - } else if (attrLower === 'supportedldapversion') { - attributes.supportedLDAPVersion = ['3']; - } - }); + requestedAttrs.forEach(attr => addOperationalAttr(attr.toLowerCase(), attributes)); } const rootDSEEntry = { @@ -753,7 +667,7 @@ class LdapEngine extends EventEmitter { // Replace '+' with actual operational attribute names (lowercase for ldapjs matching) const idx = res.attributes.indexOf('+'); if (idx !== -1) { - res.attributes.splice(idx, 1, 'namingcontexts', 'supportedldapversion'); + res.attributes.splice(idx, 1, 'namingcontexts', 'defaultnamingcontext', 'supportedldapversion'); } } else if (requestedAttrs.length > 0 && !hasWildcard) { // For specific attribute requests, add them to res.attributes in lowercase diff --git a/npm/test/unit/LdapEngine.realms.test.js b/npm/test/unit/LdapEngine.realms.test.js index 7b34424..387d22c 100644 --- a/npm/test/unit/LdapEngine.realms.test.js +++ b/npm/test/unit/LdapEngine.realms.test.js @@ -103,13 +103,13 @@ describe('LdapEngine Multi-Realm', () => { expect(engine.realmsByBaseDn.has('dc=company-b,dc=com')).toBe(true); }); - test('should support multiple realms sharing the same baseDN', () => { + test('should reject duplicate baseDN across realms', () => { const auth1 = new MockAuthProvider({ name: 'realm1-auth' }); const dir1 = new MockDirectoryProvider({ name: 'realm1-dir' }); const auth2 = new MockAuthProvider({ name: 'realm2-auth' }); const dir2 = new MockDirectoryProvider({ name: 'realm2-dir' }); - engine = new LdapEngine({ + expect(() => new LdapEngine({ port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, @@ -127,11 +127,7 @@ describe('LdapEngine Multi-Realm', () => { authProviders: [auth2] } ] - }); - - expect(engine.allRealms).toHaveLength(2); - expect(engine.realmsByBaseDn.size).toBe(1); - expect(engine.realmsByBaseDn.get(baseDN)).toHaveLength(2); + })).toThrow(/Duplicate baseDN/); }); test('should auto-wrap legacy options into single default realm', () => { @@ -179,10 +175,13 @@ describe('LdapEngine Multi-Realm', () => { }); describe('Multi-Realm Bind', () => { - test('should authenticate against the realm that owns the user', async () => { + test('should authenticate against the correct realm by baseDN', async () => { const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; const usersB = [{ username: 'bob', uid_number: 3001, gid_number: 3000, first_name: 'Bob', last_name: 'B' }]; + const baseDnA = 'dc=company-a,dc=com'; + const baseDnB = 'dc=company-b,dc=com'; + const authA = new MockAuthProvider({ name: 'auth-a', validCredentials: new Map([['alice', 'pass-a']]) @@ -200,8 +199,8 @@ describe('LdapEngine Multi-Realm', () => { bindIp: '127.0.0.1', logger: mockLogger, realms: [ - { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [authA] }, - { name: 'realm-b', baseDn: baseDN, directoryProvider: dirB, authProviders: [authB] } + { name: 'realm-a', baseDn: baseDnA, directoryProvider: dirA, authProviders: [authA] }, + { name: 'realm-b', baseDn: baseDnB, directoryProvider: dirB, authProviders: [authB] } ] }); @@ -210,7 +209,7 @@ describe('LdapEngine Multi-Realm', () => { const client = createClient(TEST_PORT); try { // Alice should authenticate through realm-a - await bindAsync(client, `uid=alice,ou=users,${baseDN}`, 'pass-a'); + await bindAsync(client, `uid=alice,ou=users,${baseDnA}`, 'pass-a'); expect(authA.callCount).toBe(1); expect(authB.callCount).toBe(0); // realm-b should NOT be tried @@ -218,7 +217,7 @@ describe('LdapEngine Multi-Realm', () => { authA.reset(); const client2 = createClient(TEST_PORT); try { - await bindAsync(client2, `uid=bob,ou=users,${baseDN}`, 'pass-b'); + await bindAsync(client2, `uid=bob,ou=users,${baseDnB}`, 'pass-b'); expect(dirB.callCounts.findUser).toBeGreaterThanOrEqual(1); expect(authB.callCount).toBe(1); } finally { @@ -256,73 +255,6 @@ describe('LdapEngine Multi-Realm', () => { }); describe('Multi-Realm Search', () => { - test('should merge search results from multiple realms sharing same baseDN', async () => { - const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; - const usersB = [{ username: 'bob', uid_number: 3001, gid_number: 3000, first_name: 'Bob', last_name: 'B' }]; - - const dirA = new MockDirectoryProvider({ name: 'dir-a', users: usersA, groups: [] }); - const dirB = new MockDirectoryProvider({ name: 'dir-b', users: usersB, groups: [] }); - - engine = new LdapEngine({ - port: TEST_PORT, - bindIp: '127.0.0.1', - logger: mockLogger, - requireAuthForSearch: false, - realms: [ - { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [new MockAuthProvider()] }, - { name: 'realm-b', baseDn: baseDN, directoryProvider: dirB, authProviders: [new MockAuthProvider()] } - ] - }); - - await engine.start(); - - const client = createClient(TEST_PORT); - try { - // Search for all users - should get results from both realms - const entries = await searchAsync(client, baseDN, { - filter: '(objectClass=posixAccount)', - scope: 'sub' - }); - - expect(entries.length).toBe(2); - } finally { - await unbindAsync(client); - } - }); - - test('should deduplicate entries by DN across realms', async () => { - // Same user in both realms - first realm wins - const sharedUser = { username: 'shared', uid_number: 5001, gid_number: 5000, first_name: 'Shared', last_name: 'User' }; - const dirA = new MockDirectoryProvider({ name: 'dir-a', users: [sharedUser], groups: [] }); - const dirB = new MockDirectoryProvider({ name: 'dir-b', users: [sharedUser], groups: [] }); - - engine = new LdapEngine({ - port: TEST_PORT, - bindIp: '127.0.0.1', - logger: mockLogger, - requireAuthForSearch: false, - realms: [ - { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [new MockAuthProvider()] }, - { name: 'realm-b', baseDn: baseDN, directoryProvider: dirB, authProviders: [new MockAuthProvider()] } - ] - }); - - await engine.start(); - - const client = createClient(TEST_PORT); - try { - const entries = await searchAsync(client, baseDN, { - filter: '(uid=shared)', - scope: 'sub' - }); - - // Should deduplicate - only 1 entry even though both realms have user - expect(entries.length).toBe(1); - } finally { - await unbindAsync(client); - } - }); - test('should search different baseDNs independently', async () => { const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; const usersB = [{ username: 'bob', uid_number: 3001, gid_number: 3000, first_name: 'Bob', last_name: 'B' }]; @@ -365,46 +297,6 @@ describe('LdapEngine Multi-Realm', () => { await unbindAsync(client); } }); - - test('should handle realm search failures gracefully (partial results)', async () => { - const usersA = [{ username: 'alice', uid_number: 2001, gid_number: 2000, first_name: 'Alice', last_name: 'A' }]; - - const dirA = new MockDirectoryProvider({ name: 'dir-a', users: usersA, groups: [] }); - // dirB will throw an error when searched - const dirB = new MockDirectoryProvider({ name: 'dir-b', users: [], groups: [] }); - dirB.getAllUsers = async () => { throw new Error('Database connection failed'); }; - - engine = new LdapEngine({ - port: TEST_PORT, - bindIp: '127.0.0.1', - logger: mockLogger, - requireAuthForSearch: false, - realms: [ - { name: 'realm-a', baseDn: baseDN, directoryProvider: dirA, authProviders: [new MockAuthProvider()] }, - { name: 'realm-b', baseDn: baseDN, directoryProvider: dirB, authProviders: [new MockAuthProvider()] } - ] - }); - - await engine.start(); - - const client = createClient(TEST_PORT); - try { - // Search should succeed with partial results (realm-a only) - const entries = await searchAsync(client, baseDN, { - filter: '(objectClass=posixAccount)', - scope: 'sub' - }); - - // Should get alice from realm-a despite realm-b failure - expect(entries.length).toBe(1); - expect(mockLogger.error).toHaveBeenCalledWith( - expect.stringContaining("Search failed in realm 'realm-b'"), - expect.any(Error) - ); - } finally { - await unbindAsync(client); - } - }); }); describe('RootDSE Multi-Realm', () => { @@ -579,18 +471,17 @@ describe('LdapEngine Multi-Realm', () => { expect(chain).toEqual([realmAuth]); }); - test('should resolve per-user override from registry', () => { + test('should resolve per-user override from realm authBackendTypes', () => { const realmAuth = new MockAuthProvider({ name: 'realm-default' }); const overrideAuth = new MockAuthProvider({ name: 'custom-auth' }); - const registry = new Map([['custom-auth', overrideAuth]]); + const authBackendTypes = new Map([['custom-auth', overrideAuth]]); engine = new LdapEngine({ port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, - authProviderRegistry: registry, realms: [ - { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth], authBackendTypes } ] }); @@ -602,15 +493,14 @@ describe('LdapEngine Multi-Realm', () => { test('should resolve multiple comma-separated backends', () => { const providerA = new MockAuthProvider({ name: 'auth-a' }); const providerB = new MockAuthProvider({ name: 'auth-b' }); - const registry = new Map([['auth-a', providerA], ['auth-b', providerB]]); + const authBackendTypes = new Map([['auth-a', providerA], ['auth-b', providerB]]); engine = new LdapEngine({ port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, - authProviderRegistry: registry, realms: [ - { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()], authBackendTypes } ] }); @@ -625,9 +515,8 @@ describe('LdapEngine Multi-Realm', () => { port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, - authProviderRegistry: new Map(), realms: [ - { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()], authBackendTypes: new Map() } ] }); @@ -636,92 +525,16 @@ describe('LdapEngine Multi-Realm', () => { }).toThrow("Unknown auth backend 'nonexistent'"); }); - test('should prioritize realm own provider over registry fallback', () => { - // Realm A has its own 'mock' provider - const realmAProvider = new MockAuthProvider({ name: 'realm-a-mock' }); - // Registry has a different 'mock' provider (from realm B or global) - const registryProvider = new MockAuthProvider({ name: 'registry-mock' }); - const registry = new Map([['mock', registryProvider]]); - - engine = new LdapEngine({ - port: TEST_PORT, - bindIp: '127.0.0.1', - logger: mockLogger, - authProviderRegistry: registry, - realms: [ - { name: 'realm-a', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAProvider] } - ] - }); - - const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'mock' }, 'testuser'); - // Should use realm's own provider, not the registry one - expect(chain).toHaveLength(1); - expect(chain[0]).toBe(realmAProvider); - expect(chain[0]).not.toBe(registryProvider); - }); - - test('should warn when using cross-realm registry fallback', () => { - // No 'sql' provider in realm's own auth chain - const realmAuth = new MockAuthProvider({ name: 'realm-default' }); - // Registry has a 'sql' provider from another realm - const sqlProvider = new MockAuthProvider({ name: 'sql-provider' }); - const registry = new Map([['sql', sqlProvider]]); - - engine = new LdapEngine({ - port: TEST_PORT, - bindIp: '127.0.0.1', - logger: mockLogger, - authProviderRegistry: registry, - realms: [ - { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } - ] - }); - - const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'sql' }, 'testuser'); - expect(chain).toEqual([sqlProvider]); - // Should have logged a warning about cross-realm usage - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining("using cross-realm auth backend 'sql'") - ); - }); - - test('should prefer realm-scoped registry key over type-only key', () => { - const realmAuth = new MockAuthProvider({ name: 'realm-default' }); - const realmScopedSql = new MockAuthProvider({ name: 'realm-a:sql' }); - const globalSql = new MockAuthProvider({ name: 'global-sql' }); - const registry = new Map([ - ['realm-a:sql', realmScopedSql], - ['sql', globalSql] - ]); - - engine = new LdapEngine({ - port: TEST_PORT, - bindIp: '127.0.0.1', - logger: mockLogger, - authProviderRegistry: registry, - realms: [ - { name: 'realm-a', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } - ] - }); - - const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'sql' }, 'testuser'); - // Should use realm-scoped registry key, not global fallback - expect(chain).toEqual([realmScopedSql]); - expect(chain).not.toContain(globalSql); - }); - - test('should resolve auth_backends case-insensitively against registry', () => { + test('should resolve auth_backends case-insensitively', () => { const sqlProvider = new MockAuthProvider({ name: 'sql-provider' }); - // Registry key is lowercase - const registry = new Map([['sql', sqlProvider]]); + const authBackendTypes = new Map([['sql', sqlProvider]]); engine = new LdapEngine({ port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, - authProviderRegistry: registry, realms: [ - { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()] } + { name: 'test', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [new MockAuthProvider()], authBackendTypes } ] }); @@ -730,28 +543,6 @@ describe('LdapEngine Multi-Realm', () => { expect(chain).toHaveLength(1); expect(chain[0]).toBe(sqlProvider); }); - - test('should resolve realm-scoped registry keys case-insensitively', () => { - const realmAuth = new MockAuthProvider({ name: 'realm-default' }); - const realmScopedProvider = new MockAuthProvider({ name: 'realm-scoped-mfa' }); - // Registry key uses mixed case - const registry = new Map([['MyRealm:MFA', realmScopedProvider]]); - - engine = new LdapEngine({ - port: TEST_PORT, - bindIp: '127.0.0.1', - logger: mockLogger, - authProviderRegistry: registry, - realms: [ - { name: 'MyRealm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider(), authProviders: [realmAuth] } - ] - }); - - // User record has lowercase auth_backends value - const chain = engine._resolveAuthChain(engine.allRealms[0], { username: 'testuser', auth_backends: 'mfa' }, 'testuser'); - expect(chain).toHaveLength(1); - expect(chain[0]).toBe(realmScopedProvider); - }); }); describe('End-to-end per-user auth override', () => { @@ -770,15 +561,14 @@ describe('LdapEngine Multi-Realm', () => { { username: 'mfauser', uid_number: 1003, gid_number: 1001, first_name: 'MFA', last_name: 'User', auth_backends: 'override-auth' } ]; - const registry = new Map([['override-auth', overrideAuth], ['realm-auth', realmAuth]]); + const authBackendTypes = new Map([['override-auth', overrideAuth], ['realm-auth', realmAuth]]); engine = new LdapEngine({ port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, - authProviderRegistry: registry, realms: [ - { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [realmAuth] } + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [realmAuth], authBackendTypes } ] }); @@ -818,13 +608,14 @@ describe('LdapEngine Multi-Realm', () => { { username: 'mfauser', uid_number: 1003, gid_number: 1001, first_name: 'MFA', last_name: 'User', auth_backends: 'override-auth' } ]; + const authBackendTypes = new Map([['override-auth', overrideAuth]]); + engine = new LdapEngine({ port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, - authProviderRegistry: new Map([['override-auth', overrideAuth]]), realms: [ - { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [new MockAuthProvider()] } + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [new MockAuthProvider()], authBackendTypes } ] }); @@ -850,9 +641,8 @@ describe('LdapEngine Multi-Realm', () => { port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, - authProviderRegistry: new Map(), realms: [ - { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [new MockAuthProvider()] } + { name: 'test-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), authProviders: [new MockAuthProvider()], authBackendTypes: new Map() } ] }); @@ -884,19 +674,19 @@ describe('LdapEngine Multi-Realm', () => { { username: 'serviceuser', uid_number: 2002, gid_number: 2000, first_name: 'Service', last_name: 'Account', auth_backends: 'sql-auth' } ]; - const registry = new Map([['sql-auth', sqlAuth]]); + const authBackendTypes = new Map([['sql-auth', sqlAuth], ['notification', notificationAuth]]); engine = new LdapEngine({ port: TEST_PORT, bindIp: '127.0.0.1', logger: mockLogger, - authProviderRegistry: registry, realms: [ { name: 'mfa-realm', baseDn: baseDN, directoryProvider: new MockDirectoryProvider({ users, groups: [] }), - authProviders: [sqlAuth, notificationAuth] + authProviders: [sqlAuth, notificationAuth], + authBackendTypes } ] }); diff --git a/server/config/configurationLoader.js b/server/config/configurationLoader.js index 0d38e3a..67d9c45 100644 --- a/server/config/configurationLoader.js +++ b/server/config/configurationLoader.js @@ -127,6 +127,8 @@ class ConfigurationLoader { } const names = new Set(); + const baseDns = new Map(); // lowercased baseDn -> realm name + let defaultCount = 0; for (let i = 0; i < realms.length; i++) { const realm = realms[i]; const prefix = `REALM_CONFIG[${i}]`; @@ -148,6 +150,21 @@ class ConfigurationLoader { throw new Error(`${prefix} (${realm.name}): 'baseDn' is required and must be a string`); } + // Enforce 1:1 baseDN-to-realm mapping + const baseDnKey = realm.baseDn.toLowerCase(); + if (baseDns.has(baseDnKey)) { + throw new Error( + `${prefix} (${realm.name}): duplicate baseDn '${realm.baseDn}' ` + + `(already used by realm '${baseDns.get(baseDnKey)}'). ` + + `Each baseDN must map to exactly one realm.` + ); + } + baseDns.set(baseDnKey, realm.name); + + if (realm.default === true) { + defaultCount++; + } + if (!realm.directory || typeof realm.directory !== 'object') { throw new Error(`${prefix} (${realm.name}): 'directory' is required and must be an object`); } @@ -175,7 +192,12 @@ class ConfigurationLoader { } logger.info(`Realm '${realm.name}' configured with baseDN '${realm.baseDn}', ` + - `directory: ${realm.directory.backend}, auth: [${realm.auth.backends.map(b => b.type).join(', ')}]`); + `directory: ${realm.directory.backend}, auth: [${realm.auth.backends.map(b => b.type).join(', ')}]` + + (realm.default ? ' (default)' : '')); + } + + if (defaultCount > 1) { + throw new Error('REALM_CONFIG: only one realm may be marked as "default": true'); } } diff --git a/server/realms.example.json b/server/realms.example.json index ed2d38a..3844fe9 100644 --- a/server/realms.example.json +++ b/server/realms.example.json @@ -2,6 +2,7 @@ { "name": "example-corp", "baseDn": "dc=example,dc=com", + "default": true, "directory": { "backend": "sql", "options": { diff --git a/server/serverMain.js b/server/serverMain.js index 709bb65..050969f 100644 --- a/server/serverMain.js +++ b/server/serverMain.js @@ -52,14 +52,11 @@ async function startServer(config) { requireAuthForSearch: config.requireAuthForSearch }; - // Build global auth provider registry for per-user auth override (Phase 3) - // Maps "realm:type" → AuthProvider instance (realm-scoped) and - // "type" → AuthProvider instance (first-registered fallback) - const authProviderRegistry = new Map(); - if (config.realms) { // Multi-realm mode: build realm objects from config logger.info(`Initializing multi-realm mode with ${config.realms.length} realm(s)`); + let defaultRealmObj = null; + engineOptions.realms = config.realms.map(realmCfg => { // Ensure directory providers receive realm-scoped LDAP base DN so that // any provider-side DN construction stays consistent with realmCfg.baseDn. @@ -72,30 +69,40 @@ async function startServer(config) { directoryOptions ); + // Build per-realm auth backend type map using explicit type names from config + const authBackendTypes = new Map(); const authProviders = realmCfg.auth.backends.map(backendCfg => { const provider = providerFactory.createAuthProvider(backendCfg.type, backendCfg.options || {}); - // Register with realm-scoped key for accurate per-user auth override - const registryKey = `${realmCfg.name}:${backendCfg.type}`; - authProviderRegistry.set(registryKey, provider); - logger.debug(`Registered auth backend '${registryKey}' in provider registry`); - // Also register type-only key as fallback (first realm wins per type) - if (!authProviderRegistry.has(backendCfg.type)) { - authProviderRegistry.set(backendCfg.type, provider); - logger.debug(`Registered fallback auth backend '${backendCfg.type}' (first realm wins)`); + const typeKey = backendCfg.type.toLowerCase(); + if (!authBackendTypes.has(typeKey)) { + authBackendTypes.set(typeKey, provider); } + logger.debug(`Realm '${realmCfg.name}': registered auth backend '${typeKey}'`); return provider; }); logger.info(`Realm '${realmCfg.name}': baseDN=${realmCfg.baseDn}, ` + - `directory=${realmCfg.directory.backend}, auth=[${realmCfg.auth.backends.map(b => b.type).join(', ')}]`); + `directory=${realmCfg.directory.backend}, auth=[${realmCfg.auth.backends.map(b => b.type).join(', ')}]` + + (realmCfg.default ? ' (default)' : '')); - return { + const realmObj = { name: realmCfg.name, baseDn: realmCfg.baseDn, directoryProvider, - authProviders + authProviders, + authBackendTypes }; + + if (realmCfg.default) { + defaultRealmObj = realmObj; + } + + return realmObj; }); + + if (defaultRealmObj) { + engineOptions.defaultRealm = defaultRealmObj; + } } else { // Legacy single-realm mode const selectedDirectory = providerFactory.createDirectoryProvider(config.directoryBackend); @@ -106,18 +113,18 @@ async function startServer(config) { engineOptions.authProviders = selectedBackends; engineOptions.directoryProvider = selectedDirectory; - // Register legacy auth providers in the registry + // Build auth backend types map for legacy mode + const authBackendTypes = new Map(); for (let idx = 0; idx < config.authBackends.length; idx++) { - const backendType = config.authBackends[idx]; - if (!authProviderRegistry.has(backendType)) { - authProviderRegistry.set(backendType, selectedBackends[idx]); - logger.debug(`Registered auth backend '${backendType}' in provider registry`); + const typeKey = config.authBackends[idx].toLowerCase(); + if (!authBackendTypes.has(typeKey)) { + authBackendTypes.set(typeKey, selectedBackends[idx]); + logger.debug(`Registered auth backend '${typeKey}' in provider type map`); } } + engineOptions.authBackendTypes = authBackendTypes; } - engineOptions.authProviderRegistry = authProviderRegistry; - // Create and configure LDAP engine const ldapEngine = new LdapEngine(engineOptions); diff --git a/server/test/integration/auth/sqlite.auth.test.js b/server/test/integration/auth/sqlite.auth.test.js index e8e385e..02f769e 100644 --- a/server/test/integration/auth/sqlite.auth.test.js +++ b/server/test/integration/auth/sqlite.auth.test.js @@ -34,7 +34,7 @@ function createClient() { return ldap.createClient({ url: `ldap://127.0.0.1:${port}` }); } -// Minimal directory stub – only needs findUser so _authenticateAcrossRealms +// Minimal directory stub – only needs findUser so _authenticateInRealm // can locate the user before delegating to the auth provider. const directoryStub = { initialize: async () => {}, diff --git a/server/test/unit/configurationLoader.realms.test.js b/server/test/unit/configurationLoader.realms.test.js index 41f8262..1f19fa1 100644 --- a/server/test/unit/configurationLoader.realms.test.js +++ b/server/test/unit/configurationLoader.realms.test.js @@ -183,11 +183,11 @@ describe('ConfigurationLoader._validateRealmConfig', () => { }])).toThrow("'auth.backends[0].type' is required"); }); - test('should accept multiple valid realms with shared baseDN', () => { + test('should reject multiple realms with same baseDN', () => { const realms = [ { name: 'realm-a', baseDn: 'dc=shared,dc=com', directory: { backend: 'sql' }, auth: { backends: [{ type: 'sql' }] } }, { name: 'realm-b', baseDn: 'dc=shared,dc=com', directory: { backend: 'mongodb' }, auth: { backends: [{ type: 'mongodb' }] } } ]; - expect(() => loader._validateRealmConfig(realms)).not.toThrow(); + expect(() => loader._validateRealmConfig(realms)).toThrow(/duplicate baseDn/); }); }); From fd9a28897017d109bec59473b591adb88cf3f52d Mon Sep 17 00:00:00 2001 From: anishapant21 Date: Sun, 22 Mar 2026 15:45:25 -0400 Subject: [PATCH 14/14] Defense when no auth providers defined --- npm/src/LdapEngine.js | 6 ++++++ server/serverMain.js | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/npm/src/LdapEngine.js b/npm/src/LdapEngine.js index b0a705e..07f089b 100644 --- a/npm/src/LdapEngine.js +++ b/npm/src/LdapEngine.js @@ -334,6 +334,12 @@ class LdapEngine extends EventEmitter { // Resolve the auth chain: per-user override or realm default const authChain = this._resolveAuthChain(realm, user, username); + // Reject auth if no providers are configured (directory-only realm) + if (authChain.length === 0) { + this.logger.warn(`Realm '${realm.name}' has no auth providers configured — rejecting bind for '${username}'`); + return { authenticated: false }; + } + // Authenticate sequentially against the resolved auth chain for (const provider of authChain) { const result = await provider.authenticate(username, password, req); diff --git a/server/serverMain.js b/server/serverMain.js index 050969f..b3e2f0b 100644 --- a/server/serverMain.js +++ b/server/serverMain.js @@ -71,7 +71,8 @@ async function startServer(config) { // Build per-realm auth backend type map using explicit type names from config const authBackendTypes = new Map(); - const authProviders = realmCfg.auth.backends.map(backendCfg => { + const authBackends = realmCfg.auth?.backends || []; + const authProviders = authBackends.map(backendCfg => { const provider = providerFactory.createAuthProvider(backendCfg.type, backendCfg.options || {}); const typeKey = backendCfg.type.toLowerCase(); if (!authBackendTypes.has(typeKey)) { @@ -81,8 +82,12 @@ async function startServer(config) { return provider; }); + if (authBackends.length === 0) { + logger.warn(`Realm '${realmCfg.name}': no auth backends configured — bind requests will be rejected`); + } + logger.info(`Realm '${realmCfg.name}': baseDN=${realmCfg.baseDn}, ` + - `directory=${realmCfg.directory.backend}, auth=[${realmCfg.auth.backends.map(b => b.type).join(', ')}]` + + `directory=${realmCfg.directory.backend}, auth=[${authBackends.map(b => b.type).join(', ')}]` + (realmCfg.default ? ' (default)' : '')); const realmObj = {