231 lines
8 KiB
JavaScript
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);
|
|
});
|