sc-site-sync/test/pgGate.js
2026-10-19 01:25:16 -05:00

177 lines
9.1 KiB
JavaScript

// 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);
});