sc-idp/lib/schema.js
2026-06-17 17:37:45 -05:00

217 lines
8.7 KiB
JavaScript

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