// Schema setup for the saltcorn-idp plugin tables. // // Two storage strategies, chosen by WHEN the state is written, so each survives // Saltcorn backup/restore without fighting the plugin lifecycle: // // * onLoad-GENERATED state (env identity, signing keys, SAML cert) -> _sc_config // (lib/configStore.js + env.js/keys.js/saml/idp.js). _sc_config is restored // BEFORE onLoad, so a restored instance keeps these and onLoad does not // regenerate a duplicate (a duplicate ACTIVE signing key would break JWKS). // // * RUNTIME-written state (groups, group_members, clients, saml_sps, // ldap_service) -> REGISTERED Saltcorn tables (auto `id` PK + logical keys // kept as fields). Written only by admin/UI actions (not onLoad/restore), so // the catalog-recreate-on-restore path is clean; being in _sc_tables they // ride backup. Existing db.select/insert code keeps working unchanged. // // * _idp_oidc_store stays RAW: transient OIDC sessions/grants/tokens, fine to // regenerate (re-auth) on a restored host. // // _idp_group_members is RE-KEYED off raw users.id onto the stable user EMAIL, so // reassigned user ids on restore do not orphan/misattribute memberships. const db = require("@saltcorn/data/db"); const Table = require("@saltcorn/data/models/table"); const Field = require("@saltcorn/data/models/field"); const User = require("@saltcorn/data/models/user"); const c = require("./constants"); const { readKey, writeKey } = require("./configStore"); const GROUPS_FIELDS = [ { name: "name", type: "String", is_unique: true, required: true }, { name: "description", type: "String" }, { name: "created_at", type: "String", required: true } ]; const GROUP_MEMBERS_FIELDS = [ { name: "group_id", type: "Integer", required: true }, { name: "user_email", type: "String", required: true } ]; const CLIENTS_FIELDS = [ { name: "client_id", type: "String", is_unique: true, required: true }, { name: "label", type: "String" }, { name: "token_auth_method", type: "String", required: true, attributes: { default: "none" } }, { name: "redirect_uris", type: "String", required: true }, { name: "grant_types", type: "String", required: true }, { name: "response_types", type: "String", required: true }, { name: "scope", type: "String" }, { name: "secret_ciphertext", type: "String" }, { name: "secret_iv", type: "String" }, { name: "secret_tag", type: "String" }, { name: "created_at", type: "String", required: true } ]; const SAML_SPS_FIELDS = [ { name: "entity_id", type: "String", is_unique: true, required: true }, { name: "label", type: "String" }, { name: "acs_urls", type: "String", required: true }, { name: "signing_cert", type: "String" }, { name: "want_authn_requests_signed", type: "Integer", required: true, attributes: { default: 0 } }, { name: "created_at", type: "String", required: true } ]; const LDAP_SERVICE_FIELDS = [ { name: "dn", type: "String" }, { name: "secret_ciphertext", type: "String" }, { name: "secret_iv", type: "String" }, { name: "secret_tag", type: "String" }, { name: "created_at", type: "String", required: true } ]; const rawTableExists = async (name) => { if (db.isSQLite) { const rs = await db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='${name}'`); return rs.rows.length > 0; } const rs = await db.query( `SELECT 1 FROM information_schema.tables WHERE table_schema='${db.getTenantSchema()}' AND table_name='${name}'` ); return rs.rows.length > 0; }; // Make `name` a registered Saltcorn table (auto id PK + fieldDefs). Idempotent. // If a legacy raw table exists, migrate its rows (optionally transformed) then // drop it. idp installs no CRUD wraps, so no suppression is needed here; if // dev-deploy is co-installed, its wraps skip _idp_* infra tables. const registerTable = async (name, fieldDefs, rowTransform) => { if (Table.findOne({ name })) { return; } const schema = db.getTenantSchemaPrefix(); let rows = []; if (await rawTableExists(name)) { rows = (await db.query(`SELECT * FROM ${schema}"${name}"`)).rows; await db.query(`DROP TABLE ${schema}"${name}"`); } const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 }); for (const f of fieldDefs) { await Field.create({ table: t, name: f.name, label: f.label || f.name, type: f.type, is_unique: !!f.is_unique, required: !!f.required, attributes: f.attributes || {} }); } for (const r of rows) { const row = rowTransform ? await rowTransform(r) : r; if (!row) { continue; } const clean = {}; for (const f of fieldDefs) { if (row[f.name] !== undefined && row[f.name] !== null) { clean[f.name] = row[f.name]; } } await db.insert(name, clean, { noid: true }); } }; // Move a legacy onLoad-generated table into a _sc_config key, then drop it. const migrateTableToConfig = async (legacyTable, cfgKey, toValue) => { if (await readKey(cfgKey)) { return; } if (!(await rawTableExists(legacyTable))) { return; } const schema = db.getTenantSchemaPrefix(); const rows = (await db.query(`SELECT * FROM ${schema}${legacyTable}`)).rows; const value = toValue(rows); if (value !== undefined && value !== null) { await writeKey(cfgKey, value); } await db.query(`DROP TABLE IF EXISTS ${schema}${legacyTable}`); }; // Resolve a legacy membership row's integer user_id to the stable canonical email. const memberRowToEmail = async (r) => { if (r.user_email) { return { group_id: r.group_id, user_email: r.user_email }; } const u = await User.findOne({ id: r.user_id }); if (!u || !u.email) { return null; // user gone -> drop the orphaned membership } return { group_id: r.group_id, user_email: u.email }; }; // Raw DDL for the transient OIDC adapter store (regenerable; stays unregistered). const createIdpOidcStore = async () => { const pfx = db.getTenantSchemaPrefix(); await db.query(` CREATE TABLE IF NOT EXISTS ${pfx}${c.TABLE_OIDC_STORE} ( model TEXT NOT NULL, id TEXT NOT NULL, payload TEXT NOT NULL, uid TEXT, grant_id TEXT, user_code TEXT, expires_at INTEGER, PRIMARY KEY (model, id) ) `); await db.query(`CREATE INDEX IF NOT EXISTS ${c.TABLE_OIDC_STORE}_uid ON ${pfx}${c.TABLE_OIDC_STORE} (uid)`); await db.query(`CREATE INDEX IF NOT EXISTS ${c.TABLE_OIDC_STORE}_grant ON ${pfx}${c.TABLE_OIDC_STORE} (grant_id)`); await db.query(`CREATE INDEX IF NOT EXISTS ${c.TABLE_OIDC_STORE}_usercode ON ${pfx}${c.TABLE_OIDC_STORE} (user_code)`); }; const createAllTables = async () => { // onLoad-generated state -> _sc_config (migrate legacy tables first). await migrateTableToConfig(c.TABLE_ENV, c.CFG_ENV, (rows) => { if (!rows.length) return null; const e = rows[0]; return { env_id: e.env_id, env_label: e.env_label, created_at: e.created_at, bootstrapped_at: e.bootstrapped_at }; }); await migrateTableToConfig(c.TABLE_KEYS, c.CFG_KEYS, (rows) => rows.map((r) => ({ kid: r.kid, alg: r.alg, public_jwk: (typeof r.public_jwk === "string" ? JSON.parse(r.public_jwk) : r.public_jwk), private: { ciphertext: r.private_ciphertext, iv: r.private_iv, tag: r.private_tag }, status: r.status, created_at: r.created_at, retire_after: r.retire_after }))); await migrateTableToConfig(c.TABLE_SAML, c.CFG_SAML, (rows) => { if (!rows.length) return null; const r = rows[0]; return { cert: r.cert, private: { ciphertext: r.private_ciphertext, iv: r.private_iv, tag: r.private_tag }, created_at: r.created_at }; }); // Runtime-written state -> registered Saltcorn tables (ride backup). await registerTable(c.TABLE_GROUPS, GROUPS_FIELDS); await registerTable(c.TABLE_GROUP_MEMBERS, GROUP_MEMBERS_FIELDS, memberRowToEmail); await registerTable(c.TABLE_CLIENTS, CLIENTS_FIELDS); await registerTable(c.TABLE_SAML_SPS, SAML_SPS_FIELDS); await registerTable(c.TABLE_LDAP_SERVICE, LDAP_SERVICE_FIELDS); // Transient -> raw. await createIdpOidcStore(); }; module.exports = { createAllTables };