// Multi-tenant LDAP gate against the Postgres instance (LDAPS :1637). Verifies // tenant-in-DN binding + cross-tenant denial: a tenant user binds under // dc=,dc=saltcorn,dc=local and resolves only that tenant's directory; the // same credentials under a different tenant fail; a user bound under one tenant // cannot search another tenant's subtree; an unknown tenant is denied. Bootstraps // each tenant admin over HTTP (Host header) first. Self-skips (exit 0) when the // PG LDAP port is not reachable, so it is a no-op on SQLite-only setups. // Run: node idp/test/ldapMultiTenantGate.js (PG :3002 up with SALTCORN_IDP_LDAP_PORT). const ldap = require("ldapjs"); const http = require("http"); const net = require("net"); const PG_PORT = 3002; const LDAP_PORT = 1637; const URL = "ldaps://127.0.0.1:" + LDAP_PORT; const TENANTS = ["t1", "t2"]; const ADMIN_PW = "AdminP@ss1"; const jars = { t1: {}, t2: {} }; let pass = 0; let fail = 0; const ok = (cond, msg) => { if (cond) { pass++; console.log(" PASS " + msg); } else { fail++; console.log(" FAIL " + msg); } }; const HOST = (t) => t + ".localhost.localdomain:" + PG_PORT; const ADMIN = (t) => "admin@" + t + ".local"; const userDn = (email, tenant) => "uid=" + email + ",ou=people,dc=" + tenant + ",dc=saltcorn,dc=local"; const peopleBase = (tenant) => "ou=people,dc=" + tenant + ",dc=saltcorn,dc=local"; // --- LDAP helpers --- const newClient = () => { return ldap.createClient({ url: URL, tlsOptions: { rejectUnauthorized: false }, connectTimeout: 5000, timeout: 8000 }); }; const doBind = (client, dn, pw) => { return new Promise((resolve) => { client.bind(dn, pw, (err) => resolve(err)); }); }; const doSearch = (client, base, opts) => { return new Promise((resolve, reject) => { client.search(base, opts, (err, res) => { if (err) { reject(err); return; } const entries = []; res.on("searchEntry", (e) => entries.push(e.pojo)); res.on("error", (e) => reject(e)); res.on("end", (r) => resolve({ status: r ? r.status : -1, entries: entries })); }); }); }; // --- minimal HTTP (Host-routed) to bootstrap each tenant admin --- const storeCookies = (t, headers) => { const sc = headers["set-cookie"]; if (!sc) { return; } for (const line of sc) { const pair = line.split(";")[0]; const eq = pair.indexOf("="); if (eq > 0) { jars[t][pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); } } }; const httpReq = (t, method, path, opts) => { const options = opts || {}; return new Promise((resolve, reject) => { const headers = { Host: HOST(t) }; const jar = jars[t]; if (Object.keys(jar).length > 0) { headers["Cookie"] = Object.keys(jar).map((k) => k + "=" + jar[k]).join("; "); } let data = null; if (options.body) { data = new URLSearchParams(options.body).toString(); headers["Content-Type"] = "application/x-www-form-urlencoded"; headers["Content-Length"] = Buffer.byteLength(data); } const r = http.request({ host: "127.0.0.1", port: PG_PORT, method: method, path: path, headers: headers }, (resp) => { storeCookies(t, resp.headers); let body = ""; resp.on("data", (c) => { body += c; }); resp.on("end", () => resolve({ status: resp.statusCode, body: body })); }); r.on("error", reject); if (data !== null) { r.write(data); } r.end(); }); }; const csrfOf = (html) => { const m = html.match(/name="_csrf" value="([^"]+)"/); return m ? m[1] : ""; }; const authed = async (t) => { return /true/.test((await httpReq(t, "GET", "/auth/authenticated")).body); }; const bootstrapTenant = async (t) => { const lp = await httpReq(t, "GET", "/auth/login"); await httpReq(t, "POST", "/auth/login", { body: { email: ADMIN(t), password: ADMIN_PW, _csrf: csrfOf(lp.body) } }); if (await authed(t)) { return true; } const cp = await httpReq(t, "GET", "/auth/create_first_user"); const cc = csrfOf(cp.body); if (cc) { await httpReq(t, "POST", "/auth/create_first_user", { body: { email: ADMIN(t), password: ADMIN_PW, default_language: "en", _csrf: cc } }); } return await authed(t); }; const portOpen = (host, port) => { return new Promise((resolve) => { const s = net.connect({ host: host, port: port }); s.setTimeout(2500); s.on("connect", () => { s.destroy(); resolve(true); }); s.on("error", () => resolve(false)); s.on("timeout", () => { s.destroy(); resolve(false); }); }); }; const attrMap = (entry) => { const out = {}; if (entry && entry.attributes) { for (const a of entry.attributes) { out[a.type.toLowerCase()] = a.values; } } return out; }; const run = async () => { if (!(await portOpen("127.0.0.1", LDAP_PORT))) { console.log(" SKIP PG LDAPS :" + LDAP_PORT + " not reachable -- multi-tenant LDAP gate skipped"); console.log("\nRESULT: " + pass + " passed, " + fail + " failed (skipped)"); process.exit(0); } // Ensure both tenant admins exist (created over HTTP if missing). for (const t of TENANTS) { ok(await bootstrapTenant(t), t + " admin bootstrapped (" + ADMIN(t) + ")"); } // 1. bind under the correct tenant succeeds + resolves that tenant's user let c = newClient(); let err = await doBind(c, userDn(ADMIN("t1"), "t1"), ADMIN_PW); ok(!err, "bind admin@t1.local under dc=t1 succeeds" + (err ? " (" + err.name + ")" : "")); if (!err) { const r = await doSearch(c, peopleBase("t1"), { scope: "sub", filter: "(uid=" + ADMIN("t1") + ")", attributes: ["mail", "memberof"] }); const a = attrMap(r.entries[0] || {}); ok(r.entries.length === 1 && a.mail && a.mail[0] === ADMIN("t1"), "dc=t1 search resolves t1's user only"); } c.unbind(() => {}); // 2. same uid under the OTHER tenant fails (user not present in t2) c = newClient(); err = await doBind(c, userDn(ADMIN("t1"), "t2"), ADMIN_PW); ok(!!err && /InvalidCredentials/i.test(err.name || ""), "admin@t1.local under dc=t2 rejected (" + (err && err.name) + ")"); c.unbind(() => {}); // 3. cross-tenant search denied (bound under dc=t1, search base dc=t2) c = newClient(); err = await doBind(c, userDn(ADMIN("t1"), "t1"), ADMIN_PW); let crossErr = null; if (!err) { try { await doSearch(c, peopleBase("t2"), { scope: "sub", filter: "(uid=" + ADMIN("t2") + ")", attributes: ["mail"] }); } catch (e) { crossErr = e; } } ok(!!crossErr && /InsufficientAccessRights/i.test(crossErr.name || ""), "cross-tenant search (bound t1, base t2) denied (" + (crossErr && crossErr.name) + ")"); c.unbind(() => {}); // 4. unknown tenant denied c = newClient(); err = await doBind(c, userDn(ADMIN("t1"), "bogus"), ADMIN_PW); ok(!!err && /InvalidCredentials/i.test(err.name || ""), "bind under unknown tenant dc=bogus denied (" + (err && err.name) + ")"); c.unbind(() => {}); // 5. groupOfNames within a tenant c = newClient(); err = await doBind(c, userDn(ADMIN("t1"), "t1"), ADMIN_PW); if (!err) { const r = await doSearch(c, "dc=t1,dc=saltcorn,dc=local", { scope: "sub", filter: "(objectclass=groupOfNames)", attributes: ["cn", "member"] }); const grp = r.entries.map(attrMap).find((g) => Array.isArray(g.member) && g.member.some((m) => m.indexOf("dc=t1,dc=saltcorn,dc=local") >= 0)); ok(r.entries.length >= 1 && !!grp, "dc=t1 groupOfNames entries carry tenant-scoped member DNs"); } c.unbind(() => {}); console.log("\nRESULT: " + pass + " passed, " + fail + " failed"); process.exit(fail ? 1 : 0); }; run().catch((e) => { console.error("LDAP MT GATE ERROR:", e); process.exit(2); });