// Multi-tenant ISOLATION gate (Postgres only). Creates two real Saltcorn tenants in // the throwaway database runPg.sh provisions, installs site-sync in each, and proves // nothing crosses the tenant boundary: identity, peers, plans, nonces, captured // design state, the file index / file store, the admin gate, and HMAC host binding. // // ./test/runPg.sh pgGate.js // // Tenant setup follows upstream's own tenant test (saltcorn-admin-models // tests/tenant.test.ts): add_tenant -> insertTenant -> switchToTenant -> // create_tenant, then db.runWithTenant for every tenant-scoped call. Multi-tenant // mode itself comes from SALTCORN_MULTI_TENANT=true (set by runPg.sh). const db = require("@saltcorn/data/db"); const Plugin = require("@saltcorn/data/models/plugin"); const Table = require("@saltcorn/data/models/table"); const Field = require("@saltcorn/data/models/field"); const File = require("@saltcorn/data/models/file"); const { add_tenant, getState } = require("@saltcorn/data/db/state"); const { create_tenant, switchToTenant, insertTenant } = require("@saltcorn/admin-models/models/tenant"); const { ok, section, step, summary, installPlugin, bootstrapPlugin, mkRes, mkAdminReq, mkPeerReq, routeOf } = require("./harness"); const protocol = require("../lib/common/peerProtocol"); const peers = require("../lib/peers"); const env = require("../lib/env"); const routes = require("../lib/routes"); const capture = require("../lib/capture"); const planLib = require("../lib/plan"); const rowsLib = require("../lib/rows"); const files = require("../lib/common/fileStore"); const { requirePeerAuth } = require("../lib/peerAuth"); const { API_PATHS } = require("../lib/transport"); const { sha256Hex } = require("../lib/common/hash"); const { randomUuid } = require("../lib/ids"); const T1 = "sst1"; const T2 = "sst2"; const HOST = (t) => `${t}.example.com`; const inT = (t, fn) => db.runWithTenant(t, fn); const rawTableIn = async (schema, name) => { const rs = await db.query(`SELECT 1 FROM information_schema.tables WHERE table_schema=$1 AND table_name=$2`, [schema, name]); return rs.rows.length === 1; }; const createTenant = async (t) => { add_tenant(t); await switchToTenant(await insertTenant(t, `admin@${t}.local`, "site-sync isolation gate"), `http://${HOST(t)}/`); await create_tenant({ t, loadAndSaveNewPlugin() {}, plugin_loader() {} }); }; // Install site-sync into a tenant. It is installed by location (source "local"), // which Saltcorn loads on a tenant only when the ROOT allows non-store plugins; and a // tenant's loadAllPlugins reloads the root state's config cache, so the root flag is // (re-)asserted in root context right before the install. const bootstrapTenant = (t) => inT(t, async () => { await Plugin.loadAllPlugins(); await inT(db.connectObj.default_schema, () => getState().setConfig("tenants_unsafe_plugins", true)); await installPlugin(); }); const main = async () => { if (db.isSQLite || !db.is_it_multi_tenant()) { console.log("pgGate needs Postgres in multi-tenant mode (SALTCORN_MULTI_TENANT=true); run it via ./test/runPg.sh pgGate.js"); process.exit(1); } await bootstrapPlugin(); await createTenant(T1); await createTenant(T2); await bootstrapTenant(T1); await bootstrapTenant(T2); section("identity + raw tables are per tenant"); const e1 = await inT(T1, () => env.getEnv()); const e2 = await inT(T2, () => env.getEnv()); ok("each tenant has its own env_id", !!e1 && !!e2 && e1.env_id !== e2.env_id); for (const t of [T1, T2]) { for (const name of ["_ss_seen_nonces", "_ss_plans", "_ss_rollback", "_ss_peers", "_ss_table_modes"]) { ok(`${t}: ${name} lives in schema ${t}`, await rawTableIn(t, name)); } } await inT(T1, () => env.setEnvFields({ env_label: "only-t1" })); ok("settings saved in t1 do not touch t2", (await inT(T2, () => env.getEnv())).env_label !== "only-t1"); section("peers, plans and captured state never cross tenants"); const sharedEnv = randomUuid(); const sharedSecret = protocol.randomSecret(); await inT(T1, () => peers.addPeer({ envId: sharedEnv, label: "shared", baseUrl: "http://src.example.com", existingSecret: sharedSecret })); ok("a peer paired in t1 is invisible in t2", (await inT(T2, () => peers.findPeerByEnvId(sharedEnv))) === null); await inT(T2, async () => { const t = await Table.create("only_in_t2", { min_role_read: 1, min_role_write: 1 }); await Field.create({ table: t, name: "v", label: "v", type: "String" }); }); const cap1 = await inT(T1, () => capture.buildDesiredState()); const cap2 = await inT(T2, () => capture.buildDesiredState()); ok("t1's desired state does not contain t2's table", !cap1.pack.tables.some((x) => x.name === "only_in_t2")); ok("t2's desired state does contain it", cap2.pack.tables.some((x) => x.name === "only_in_t2")); const built = await inT(T1, async () => planLib.buildPlan({ peerRow: null, env: await env.getEnv(), desired: cap1.pack, desiredHash: cap1.hash, sourceEnvId: "src" })); ok("a plan built in t1 loads in t1", !!(await inT(T1, () => planLib.loadPlan(built.plan_id)))); ok("a plan built in t1 is unknown in t2", (await inT(T2, () => planLib.loadPlan(built.plan_id))) === null); section("admin gate: a session admin of one tenant is not admin of another"); await step("t1's admin session is rejected by t2's site-sync admin routes", async () => { const t1Admin = { role_id: 1, tenant: T1, email: `admin@${T1}.local` }; let res = mkRes(); await inT(T1, () => routeOf(routes.routes, "get", "/admin/site-sync/")(mkAdminReq({ user: t1Admin }), res)); ok("t1 admin on t1 -> dashboard rendered", res.statusCode === 200 && !!res._wrap); res = mkRes(); await inT(T2, () => routeOf(routes.routes, "get", "/admin/site-sync/")(mkAdminReq({ user: t1Admin }), res)); ok("t1 admin on t2 -> 403", res.statusCode === 403 && !res._wrap, `status=${res.statusCode}`); }); section("HMAC host binding + per-tenant replay store"); await step("a request signed for t1 cannot be replayed at t2, even with a spoofed X-Forwarded-Host", async () => { // Worst case: the operator re-used ONE env_id + secret for both tenants. await inT(T2, () => peers.addPeer({ envId: sharedEnv, label: "shared", baseUrl: "http://src.example.com", existingSecret: sharedSecret })); const nonce = protocol.randomNonce().toString("hex"); const ts = String(Date.now()); const signed = () => mkPeerReq({ method: "GET", path: API_PATHS.HEALTH, host: HOST(T1), envId: sharedEnv, secret: sharedSecret, override: { nonce, timestamp: ts } }); let res = mkRes(); ok("accepted at t1 (its own host)", !!(await inT(T1, () => requirePeerAuth(signed(), res))), `status=${res.statusCode}`); const atT2 = signed(); atT2.headers.host = HOST(T2); atT2.host = HOST(T2); res = mkRes(); ok("replayed at t2 (Host t2) -> 401", (await inT(T2, () => requirePeerAuth(atT2, res))) === null && res.statusCode === 401); const spoof = signed(); spoof.headers.host = HOST(T2); spoof.host = HOST(T2); spoof.headers["x-forwarded-host"] = HOST(T1); res = mkRes(); ok("replayed at t2 with XFH=t1 -> 401", (await inT(T2, () => requirePeerAuth(spoof, res))) === null && res.statusCode === 401); const seenIn = async (t) => (await db.query(`SELECT 1 FROM "${t}"._ss_seen_nonces WHERE nonce = $1`, [nonce])).rows.length; ok("the nonce is recorded in t1's store only", (await seenIn(T1)) === 1 && (await seenIn(T2)) === 0); }); section("file index + file store are per tenant"); await step("t1's synced file is not resolvable, nor reachable by path, from t2", async () => { const bytes = Buffer.from(`t1 private file ${randomUuid()}`); const hash = sha256Hex(bytes); const fileValue = await inT(T1, async () => { const t = await Table.create("t1docs", { min_role_read: 1, min_role_write: 1 }); await Field.create({ table: t, name: "f", label: "f", type: "File" }); const f = await File.from_contents("secret.txt", "text/plain", bytes, 1, 1, "/"); await Table.findOne({ name: "t1docs" }).insertRow({ f: f.field_value }); await rowsLib.setDataMode("t1docs", "managed"); return f.field_value; }); ok("t1 resolves its own file by hash", (await inT(T1, () => rowsLib.resolveFileByHash(hash))) === fileValue); ok("t2 cannot resolve t1's hash", (await inT(T2, () => rowsLib.resolveFileByHash(hash))) === null); let refused = false; await inT(T2, async () => { try { files.toAbsolutePath(db, `../${T1}/${fileValue}`); } catch (e) { refused = true; } }); ok("t2 refuses a path into t1's file store", refused); }); process.exit(summary()); }; main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });