217 lines
8.7 KiB
JavaScript
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
|
|
};
|