sc-idp/test/ldapMultiTenantGate.js
2026-06-01 16:40:54 -05:00

231 lines
8 KiB
JavaScript

// Multi-tenant LDAP gate against the Postgres instance (LDAPS :1637). Verifies
// tenant-in-DN binding + cross-tenant denial: a tenant user binds under
// dc=<tenant>,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);
});