177 lines
9.1 KiB
JavaScript
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);
|
|
});
|