184 lines
8.4 KiB
JavaScript
184 lines
8.4 KiB
JavaScript
// Desired-state capture.
|
|
//
|
|
// A site's DESIRED STATE is Saltcorn's own snapshot-mode pack: metadata (tables
|
|
// schema, views, pages, page_groups, triggers, roles, library, tags, models,
|
|
// code_pages) plus a config map. We delegate the whole serialization to
|
|
// backup.create_pack_json(false, true) -- the exact object Saltcorn's Snapshot
|
|
// feature hashes -- then apply site-sync's security sanitization so the captured
|
|
// state is safe to hand to a peer AND safe to feed into install_pack on apply:
|
|
//
|
|
// * PLUGINS are reduced to PARITY info only: {name, source, location, version}.
|
|
// plugin_pack embeds `configuration` and the secret `deploy_private_key`;
|
|
// shipping those would leak secrets and could reconfigure a sibling plugin
|
|
// (e.g. idp redirect_uris). site-sync never pushes plugin config.
|
|
// * CONFIG is intersected with SYNCABLE_CONFIG_KEYS. install_pack's setConfig
|
|
// clobbers unconditionally, so this allow-list is the only guard against a
|
|
// compromised source overwriting security config (disable_csrf_routes, ...).
|
|
// * EVENT_LOGS and MODEL_INSTANCES are forced to [] (snapshot mode already drops
|
|
// them, and install_pack's EventLog.create is append-only / non-idempotent).
|
|
// * CODE_PAGES (server-side JavaScript) stay in the pack as a first-class kind:
|
|
// diff.js previews/prunes them like any other entity, so install_pack's
|
|
// code-page write is never invisible.
|
|
//
|
|
// The capture is hashed (sha256 of the sanitized-pack JSON) so a sync of an
|
|
// unchanged source short-circuits to an empty plan: both sides sanitize
|
|
// identically and create_pack_json emits a fixed key order, so equal states hash
|
|
// equal.
|
|
|
|
const { sha256Hex } = require("./common/hash");
|
|
const { SYNCABLE_CONFIG_KEYS, isInfraName, isLockedTable } = require("./constants");
|
|
|
|
|
|
// site-sync syncs APPLICATION design state, not other plugins' private
|
|
// bookkeeping (see isInfraName): a desired-state MIRROR must never propagate or
|
|
// (worse) prune infra tables or the per-row identity columns, or a source that
|
|
// lacks dev-deploy would drop the target's _dd_* tables and their peer secrets. The
|
|
// filter is applied symmetrically on the source's captured state and the target's
|
|
// current state, so infra entities never enter the diff at all.
|
|
//
|
|
// The users table (and any ROW_SYNC_LOCKED_TABLES) must NEVER be synced as a TABLE
|
|
// spec: install_pack would apply the pack's min_role_read/min_role_write/
|
|
// ownership_formula and create arbitrary fields on it, so a hostile peer could open
|
|
// the auth table's write ACL or add fields. We drop the table entry entirely so it
|
|
// never reaches install_pack. Views/pages/triggers that merely REFERENCE users are
|
|
// kept (login/signup views are legitimate design; the users table always exists on
|
|
// the target for them to bind to).
|
|
const isExcludedTable = (name) => isInfraName(name) || isLockedTable(name);
|
|
|
|
|
|
// Drop plugin/system-infra + locked tables, infra fields of the remaining tables,
|
|
// and views/triggers/models (and tag entries) bound to infra.
|
|
const excludeInfra = (pack) => {
|
|
pack.tables = pack.tables
|
|
.filter((t) => !isExcludedTable(t.name))
|
|
.map((t) => Object.assign({}, t, { fields: (t.fields || []).filter((f) => !isInfraName(f.name)) }));
|
|
pack.views = pack.views.filter((v) => !isInfraName(v.name) && !isInfraName(v.table));
|
|
const infraTriggers = new Set(pack.triggers.filter((t) => isInfraName(t.table_name)).map((t) => t.name));
|
|
pack.triggers = pack.triggers.filter((t) => !infraTriggers.has(t.name));
|
|
pack.models = pack.models.filter((m) => !isInfraName(m.table_name));
|
|
pack.tags = pack.tags.map((tag) => {
|
|
const entries = (tag.entries || []).filter((e) =>
|
|
!isInfraName(e.table_name) && !isInfraName(e.view_name) && !isInfraName(e.page_name) && !infraTriggers.has(e.trigger_name));
|
|
return Object.assign({}, tag, { entries });
|
|
});
|
|
return pack;
|
|
};
|
|
|
|
|
|
// Keep only parity-relevant, non-secret plugin fields.
|
|
const sanitizePluginEntry = (pl) => {
|
|
return {
|
|
name: pl.name,
|
|
source: pl.source,
|
|
location: pl.location,
|
|
version: pl.version
|
|
};
|
|
};
|
|
|
|
|
|
// Restrict a config map to the syncable allow-list. Missing keys are simply
|
|
// absent (so the diff treats them as "not managed", never as a delete).
|
|
const pickSyncableConfig = (config) => {
|
|
const out = {};
|
|
if (!config || typeof config !== "object") {
|
|
return out;
|
|
}
|
|
for (const key of SYNCABLE_CONFIG_KEYS) {
|
|
if (Object.prototype.hasOwnProperty.call(config, key)) {
|
|
out[key] = config[key];
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
|
|
// Every array-valued entity kind of a sanitized pack (always present, maybe empty).
|
|
const PACK_KINDS = ["tables", "views", "pages", "page_groups", "triggers", "roles", "library", "tags", "models", "code_pages"];
|
|
|
|
|
|
// Canonicalize a pack's array order before hashing so the hash is order-INSENSITIVE.
|
|
// create_pack_json builds several kinds via find() with no ORDER BY (roles,
|
|
// library, tags, models) and getFields() (DB order); an unrelated apply can reorder
|
|
// rows on Postgres, which would otherwise flip the hash and cause a spurious
|
|
// precondition-drift refusal. Sorting by the stable key (roles by id, everything
|
|
// else by name; fields/constraints within a table) removes that flakiness. Returns
|
|
// a deep clone -- the original pack is untouched.
|
|
const canonicalizeForHash = (pack) => {
|
|
const clone = JSON.parse(JSON.stringify(pack));
|
|
const byName = (a, b) => String(a && a.name).localeCompare(String(b && b.name));
|
|
if (Array.isArray(clone.roles)) {
|
|
clone.roles.sort((a, b) => (a.id || 0) - (b.id || 0));
|
|
}
|
|
for (const k of PACK_KINDS.filter((kind) => kind !== "roles")) {
|
|
if (Array.isArray(clone[k])) {
|
|
clone[k].sort(byName);
|
|
}
|
|
}
|
|
for (const t of (clone.tables || [])) {
|
|
if (Array.isArray(t.fields)) {
|
|
t.fields.sort(byName);
|
|
}
|
|
if (Array.isArray(t.constraints)) {
|
|
t.constraints.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
}
|
|
}
|
|
return clone;
|
|
};
|
|
|
|
|
|
// Stable content hash of a sanitized pack (order-insensitive; see above).
|
|
const hashPack = (pack) => {
|
|
return sha256Hex(Buffer.from(JSON.stringify(canonicalizeForHash(pack)), "utf8"));
|
|
};
|
|
|
|
|
|
// Apply site-sync's security sanitization to a raw pack. Used BOTH to build this
|
|
// instance's desired state AND -- critically -- to sanitize a pack RECEIVED from a
|
|
// peer before diffing/applying it: the target must never trust the sender's
|
|
// sanitization, or a hostile peer could ship an infra table (_dd_peers) or a
|
|
// non-allow-listed config key and have it created/clobbered on apply.
|
|
const sanitizeDesired = (raw) => {
|
|
const pack = Object.assign({}, raw || {});
|
|
pack.plugins = (pack.plugins || []).map(sanitizePluginEntry);
|
|
pack.config = pickSyncableConfig(pack.config);
|
|
pack.event_logs = [];
|
|
pack.model_instances = [];
|
|
for (const k of PACK_KINDS) {
|
|
if (!Array.isArray(pack[k])) {
|
|
pack[k] = [];
|
|
}
|
|
}
|
|
excludeInfra(pack);
|
|
return pack;
|
|
};
|
|
|
|
|
|
// Produce this instance's sanitized desired-state pack. Returns { pack, hash }.
|
|
//
|
|
// The pack is normalized to PLAIN JSON (JSON round-trip): create_pack_json returns
|
|
// class instances for some kinds (e.g. Role objects for `roles`), but a peer's
|
|
// desired pack always arrives as plain objects over the JSON transport. lodash
|
|
// isEqual treats an instance and a plain object with identical properties as
|
|
// UNEQUAL, which would make every such entity falsely diff as "changed" on every
|
|
// real sync. Round-tripping the current pack the same way the transport does makes
|
|
// both sides structurally identical. (Hash is unaffected -- it is already computed
|
|
// over JSON.stringify.)
|
|
const buildDesiredState = async () => {
|
|
const backup = require("@saltcorn/admin-models/models/backup");
|
|
const raw = await backup.create_pack_json(false, true);
|
|
const pack = JSON.parse(JSON.stringify(sanitizeDesired(raw)));
|
|
// Replace files referenced inside page layouts / view configs / the site logo
|
|
// with content-addressed placeholders (symmetric on both sides, so the hash and
|
|
// diff are content-based). Their bytes are transferred on apply via /api/file.
|
|
await require("./layoutFiles").placeholderize(pack);
|
|
return { pack, hash: hashPack(pack) };
|
|
};
|
|
|
|
|
|
module.exports = {
|
|
buildDesiredState,
|
|
sanitizeDesired,
|
|
hashPack,
|
|
pickSyncableConfig,
|
|
PACK_KINDS
|
|
};
|