diff --git a/lib/adminUi.js b/lib/adminUi.js index 7bc4432..c8ab771 100644 --- a/lib/adminUi.js +++ b/lib/adminUi.js @@ -181,9 +181,9 @@ const groupsPage = async (req, res) => { const members = await groups.membersOf(g.id); let memberHtml = ""; for (const member of members) { - const u = await User.findOne({ id: member.user_id }); - const label = u ? u.email : ("user#" + member.user_id); - memberHtml += `
${csrfField(req)}${escapeHtml(label)}

`; + // memberships are keyed by email now; the row already carries it. + const label = member.user_email; + memberHtml += `
${csrfField(req)}${escapeHtml(label)}

`; } rows += ` ${escapeHtml(g.name)} @@ -259,9 +259,11 @@ const addMemberHandler = async (req, res) => { const groupId = parseInt(req.body && req.body.group_id, 10); const email = String((req.body && req.body.email) || "").trim(); if (Number.isFinite(groupId) && email) { + // Validate the user exists and store its CANONICAL email (matches how + // users.email is stored), so the membership key stays consistent. const u = await User.findOne({ email: email }); if (u) { - await groups.addMember(groupId, u.id); + await groups.addMember(groupId, u.email); } } res.redirect(constants.ADMIN_BASE_PATH + "/groups"); @@ -273,9 +275,9 @@ const removeMemberHandler = async (req, res) => { return; } const groupId = parseInt(req.body && req.body.group_id, 10); - const userId = parseInt(req.body && req.body.user_id, 10); - if (Number.isFinite(groupId) && Number.isFinite(userId)) { - await groups.removeMember(groupId, userId); + const email = String((req.body && req.body.user_email) || "").trim(); + if (Number.isFinite(groupId) && email) { + await groups.removeMember(groupId, email); } res.redirect(constants.ADMIN_BASE_PATH + "/groups"); }; diff --git a/lib/configStore.js b/lib/configStore.js new file mode 100644 index 0000000..6aed41b --- /dev/null +++ b/lib/configStore.js @@ -0,0 +1,51 @@ +// Durable per-tenant key/value storage in Saltcorn's _sc_config, used for +// saltcorn-idp state that is GENERATED during onLoad (env identity, signing +// keys, SAML cert). +// +// Why _sc_config and not a registered table or the plugin config blob: +// - It survives backup: backup_config dumps every non-fixed _sc_config key, +// and restore_config restores them BEFORE install_pack runs the plugin's +// onLoad -- so the restored value is already present when onLoad reads it +// (no onLoad-generates-then-restore-imports duplicate, which a registered +// table suffers because restore_tables runs AFTER onLoad). This matters +// because onLoad creates the env row, the active signing key, and the SAML +// cert if missing; a duplicate active key would break JWKS. +// - It does NOT write the plugin's own _sc_plugins row (Plugin.upsert during +// onLoad cascades plugin re-loading + duplicates rows). setConfig writes +// _sc_config only. +// +// Reads go DIRECT to _sc_config: getState().getConfig only surfaces keys in +// Saltcorn's known configTypes schema, so a custom plugin key is invisible +// through it even though it is stored and backed up. Writes go through setConfig +// (correct jsonb/text encoding on both backends; value wrapped as {v: ...}). + +const db = require("@saltcorn/data/db"); + + +const readKey = async (key) => { + const row = await db.selectMaybeOne("_sc_config", { key: key }); + if (!row || row.value === null || row.value === undefined) { + return undefined; + } + let v = row.value; + if (typeof v === "string") { + try { + v = JSON.parse(v); + } catch (e) { + return undefined; + } + } + return v && typeof v === "object" && "v" in v ? v.v : v; +}; + + +const writeKey = async (key, value) => { + const { getState } = require("@saltcorn/data/db/state"); + await getState().setConfig(key, value); +}; + + +module.exports = { + readKey, + writeKey +}; diff --git a/lib/constants.js b/lib/constants.js index 5eb9d79..398abe9 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -33,7 +33,11 @@ const USERINFO_PATH = IDP_BASE_PATH + "/me"; const INTERACTION_PATH = IDP_BASE_PATH + "/interaction/:uid"; const INTERACTION_CONFIRM_PATH = IDP_BASE_PATH + "/interaction/:uid/confirm"; -// Plugin tables (all prefixed _idp_, created idempotently in onLoad). +// Plugin tables (prefixed _idp_). Runtime-written tables (groups, group_members, +// clients, saml_sps, ldap_service) are REGISTERED Saltcorn tables so they ride +// backup; _idp_oidc_store stays raw (transient sessions/tokens, regenerable). +// TABLE_ENV/TABLE_KEYS/TABLE_SAML remain only as LEGACY table names so the +// onLoad migration can read + drop them after moving their content to _sc_config. const TABLE_ENV = "_idp_env"; const TABLE_KEYS = "_idp_keys"; const TABLE_OIDC_STORE = "_idp_oidc_store"; @@ -41,6 +45,13 @@ const TABLE_GROUPS = "_idp_groups"; const TABLE_GROUP_MEMBERS = "_idp_group_members"; const TABLE_CLIENTS = "_idp_clients"; +// onLoad-GENERATED state lives in _sc_config (see lib/configStore.js) so it is +// restored BEFORE onLoad and never duplicated: env identity, signing keys, SAML +// cert. (Legacy _idp_env/_idp_keys/_idp_saml tables are migrated + dropped.) +const CFG_ENV = "idp_env"; +const CFG_KEYS = "idp_signing_keys"; +const CFG_SAML = "idp_saml_cert"; + // Signing. const SIGNING_ALG = "RS256"; const RSA_MODULUS_BITS = 2048; @@ -138,6 +149,9 @@ module.exports = { TABLE_GROUPS, TABLE_GROUP_MEMBERS, TABLE_CLIENTS, + CFG_ENV, + CFG_KEYS, + CFG_SAML, SIGNING_ALG, RSA_MODULUS_BITS, KEY_STATUS, diff --git a/lib/env.js b/lib/env.js index 0ae3b3a..2d46534 100644 --- a/lib/env.js +++ b/lib/env.js @@ -1,19 +1,18 @@ // Singleton per-instance (per-tenant) environment row for saltcorn-idp. Tracks // first-run bootstrap state and an instance label for the admin UI. // -// No module-level cache: in multi-tenant mode a single un-keyed cache would -// serve one tenant's row to another. Reads are infrequent (onLoad + dashboard), -// so we query each time within the caller's tenant context. +// Stored in _sc_config (key CFG_ENV) rather than a _idp_env table: _sc_config +// survives backup and is restored BEFORE onLoad, so a restored instance keeps +// its env_id and onLoad does not create a duplicate (see lib/configStore.js). const crypto = require("crypto"); -const db = require("@saltcorn/data/db"); -const { TABLE_ENV } = require("./constants"); +const { CFG_ENV } = require("./constants"); +const { readKey, writeKey } = require("./configStore"); const getEnv = async () => { - const rows = await db.select(TABLE_ENV, {}); - return rows.length > 0 ? rows[0] : null; + return (await readKey(CFG_ENV)) || null; }; @@ -22,21 +21,23 @@ const initEnvIfMissing = async () => { if (existing) { return existing; } - const now = new Date().toISOString(); - const row = { + const env = { env_id: crypto.randomUUID(), env_label: null, - created_at: now, + created_at: new Date().toISOString(), bootstrapped_at: null }; - await db.insert(TABLE_ENV, row, { noid: true }); - return row; + await writeKey(CFG_ENV, env); + return env; }; const markBootstrapped = async (envId) => { - const now = new Date().toISOString(); - await db.updateWhere(TABLE_ENV, { bootstrapped_at: now }, { env_id: envId }); + const env = await getEnv(); + if (env && env.env_id === envId) { + env.bootstrapped_at = new Date().toISOString(); + await writeKey(CFG_ENV, env); + } }; diff --git a/lib/groups.js b/lib/groups.js index ab167c1..7784d0b 100644 --- a/lib/groups.js +++ b/lib/groups.js @@ -37,17 +37,20 @@ const membersOf = async (groupId) => { }; -const addMember = async (groupId, userId) => { - const existing = await db.selectMaybeOne(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId }); +// Memberships are keyed by the user's stable EMAIL (not the raw users.id), so a +// restore that reassigns user ids does not orphan/misattribute memberships. +// Callers pass the canonical email (resolved from the User row). +const addMember = async (groupId, email) => { + const existing = await db.selectMaybeOne(TABLE_GROUP_MEMBERS, { group_id: groupId, user_email: email }); if (existing) { return; } - await db.insert(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId }, { noid: true }); + await db.insert(TABLE_GROUP_MEMBERS, { group_id: groupId, user_email: email }, { noid: true }); }; -const removeMember = async (groupId, userId) => { - await db.deleteWhere(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId }); +const removeMember = async (groupId, email) => { + await db.deleteWhere(TABLE_GROUP_MEMBERS, { group_id: groupId, user_email: email }); }; @@ -57,7 +60,8 @@ const effectiveGroups = async (user) => { if (role && role.role) { out.push(ROLE_PREFIX + role.role); } - const members = await db.select(TABLE_GROUP_MEMBERS, { user_id: user.id }); + // user object already carries the canonical email; resolve groups by it. + const members = await db.select(TABLE_GROUP_MEMBERS, { user_email: user.email }); for (const member of members) { const group = await db.selectMaybeOne(TABLE_GROUPS, { id: member.group_id }); if (group && group.name) { diff --git a/lib/keys.js b/lib/keys.js index b0fb4c1..2587cc8 100644 --- a/lib/keys.js +++ b/lib/keys.js @@ -1,91 +1,103 @@ // Signing-key lifecycle for the saltcorn-idp OIDC provider. // -// Keys are per-tenant (stored in this tenant's _idp_keys). The private key is -// sealed at rest (AES-256-GCM under the plugin KEK); only the PUBLIC JWK is -// ever exposed, via JWKS. ensureActiveKey() is idempotent: it creates an active -// key only if none exists for the current tenant. getActiveKeyMeta() never -// touches private material so the dashboard can render without decrypting. - -const db = require("@saltcorn/data/db"); +// Keys are per-tenant, stored in _sc_config (key CFG_KEYS) as an array of key +// objects rather than a _idp_keys table. _sc_config survives backup AND is +// restored BEFORE onLoad, so a restored instance keeps its signing keys and +// ensureActiveKey() does not generate a duplicate ACTIVE key (a registered +// table, imported AFTER onLoad, would leave two ACTIVE keys -> broken JWKS). +// +// Each stored key object: { kid, alg, public_jwk (object), private ({ciphertext, +// iv, tag} hex from crypto.sealText), status, created_at, retire_after }. The +// private key is sealed at rest (AES-256-GCM under the plugin KEK); only the +// PUBLIC JWK is ever exposed, via JWKS. const crypto = require("./crypto"); const constants = require("./constants"); +const { readKey, writeKey } = require("./configStore"); -// Generate + seal a fresh RSA signing keypair and insert it as the new ACTIVE -// key. Shared by ensureActiveKey (first key for a tenant) and rotateActiveKey -// (replacement key). Returns only safe metadata; the sealed private material is -// never handed back. -const insertActiveKey = async () => { +const loadKeys = async () => { + const arr = await readKey(constants.CFG_KEYS); + return Array.isArray(arr) ? arr : []; +}; + + +const saveKeys = async (keys) => { + await writeKey(constants.CFG_KEYS, keys); +}; + + +const byStatus = (keys, status) => keys.filter((k) => k.status === status); + + +// Generate + seal a fresh RSA signing keypair and append it as the new ACTIVE +// key. Returns only safe metadata; the sealed private material is never handed +// back. Caller persists the mutated array. +const buildActiveKey = () => { const kid = crypto.newKid(); const pair = crypto.generateSigningKeyPair(constants.RSA_MODULUS_BITS); const jwk = crypto.publicKeyToJwk(pair.publicKey, kid, constants.SIGNING_ALG); const pem = crypto.exportPrivatePem(pair.privateKey); const sealed = crypto.sealText(pem); const now = new Date().toISOString(); - const row = { - kid: kid, - alg: constants.SIGNING_ALG, - public_jwk: JSON.stringify(jwk), - private_ciphertext: sealed.ciphertext, - private_iv: sealed.iv, - private_tag: sealed.tag, - status: constants.KEY_STATUS.ACTIVE, - created_at: now, - retire_after: null + return { + kid: kid, + alg: constants.SIGNING_ALG, + public_jwk: jwk, + private: { ciphertext: sealed.ciphertext, iv: sealed.iv, tag: sealed.tag }, + status: constants.KEY_STATUS.ACTIVE, + created_at: now, + retire_after: null }; - await db.insert(constants.TABLE_KEYS, row, { noid: true }); - return { kid: kid, alg: constants.SIGNING_ALG, status: constants.KEY_STATUS.ACTIVE, created_at: now }; }; const ensureActiveKey = async () => { - const existing = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); + const keys = await loadKeys(); + const existing = byStatus(keys, constants.KEY_STATUS.ACTIVE)[0]; if (existing) { return { kid: existing.kid, alg: existing.alg, status: existing.status, created_at: existing.created_at }; } - return await insertActiveKey(); + const k = buildActiveKey(); + keys.push(k); + await saveKeys(keys); + return { kid: k.kid, alg: k.alg, status: k.status, created_at: k.created_at }; }; const getJwks = async () => { - const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); - const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING }); - const keys = active.concat(retiring).map((r) => JSON.parse(r.public_jwk)); - return { keys: keys }; + const keys = await loadKeys(); + const pub = byStatus(keys, constants.KEY_STATUS.ACTIVE) + .concat(byStatus(keys, constants.KEY_STATUS.RETIRING)) + .map((k) => k.public_jwk); + return { keys: pub }; }; const getActiveKeyMeta = async () => { - const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); - if (!row) { + const k = byStatus(await loadKeys(), constants.KEY_STATUS.ACTIVE)[0]; + if (!k) { return null; } - return { kid: row.kid, alg: row.alg, created_at: row.created_at }; + return { kid: k.kid, alg: k.alg, created_at: k.created_at }; }; -// Phase 1: decrypts and returns the private signing key for id_token signing. +const openPrivate = (k) => { + return crypto.openText(k.private).toString("utf8"); +}; + + +// Decrypts and returns the private signing key for id_token signing. const getActiveSigningKey = async () => { - const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); - if (!row) { + const k = byStatus(await loadKeys(), constants.KEY_STATUS.ACTIVE)[0]; + if (!k) { return null; } - const pem = crypto.openText({ - ciphertext: row.private_ciphertext, - iv: row.private_iv, - tag: row.private_tag - }).toString("utf8"); - return { - kid: row.kid, - alg: row.alg, - privateKey: crypto.importPrivatePem(pem) - }; + return { kid: k.kid, alg: k.alg, privateKey: crypto.importPrivatePem(openPrivate(k)) }; }; -// Phase 1: the active private signing key as a JWK, for oidc-provider's jwks -// config (it signs id_tokens with it and serves the public half via JWKS). const getActivePrivateJwk = async () => { const key = await getActiveSigningKey(); if (!key) { @@ -96,63 +108,58 @@ const getActivePrivateJwk = async () => { // All non-retired private signing keys as JWKs for oidc-provider's jwks config: -// the ACTIVE key FIRST (oidc-provider signs new tokens with the first key that -// matches the requested alg) followed by any RETIRING keys, so id_tokens issued -// BEFORE a rotation still verify against JWKS during the grace window. RETIRED -// keys are excluded. This is what actually keeps a rotated-out key in /idp/jwks -// (the provider builds JWKS from this set, not from keys.getJwks()). +// the ACTIVE key FIRST, then any RETIRING keys (so id_tokens issued before a +// rotation still verify against JWKS during the grace window). RETIRED excluded. const getSigningPrivateJwks = async () => { - const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); - const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING }); - const jwks = []; - for (const row of active.concat(retiring)) { - const pem = crypto.openText({ - ciphertext: row.private_ciphertext, - iv: row.private_iv, - tag: row.private_tag - }).toString("utf8"); - jwks.push(crypto.privateKeyToJwk(crypto.importPrivatePem(pem), row.kid, row.alg)); - } - return jwks; + const keys = await loadKeys(); + const ordered = byStatus(keys, constants.KEY_STATUS.ACTIVE).concat(byStatus(keys, constants.KEY_STATUS.RETIRING)); + return ordered.map((k) => crypto.privateKeyToJwk(crypto.importPrivatePem(openPrivate(k)), k.kid, k.alg)); }; // Flip any RETIRING keys whose grace window has elapsed to RETIRED, dropping -// them from JWKS. Idempotent and cheap; called opportunistically on the admin -// dashboard GET and at the end of a rotation. NULL retire_after never matches -// (SQL "<=" of NULL is NULL, not true), so a key without a deadline is safe. +// them from JWKS. Idempotent. A null retire_after never expires. const retireExpiredKeys = async () => { const now = new Date().toISOString(); - await db.updateWhere( - constants.TABLE_KEYS, - { status: constants.KEY_STATUS.RETIRED }, - { status: constants.KEY_STATUS.RETIRING, retire_after: { lt: now, equal: true } } - ); + const keys = await loadKeys(); + let changed = false; + for (const k of keys) { + if (k.status === constants.KEY_STATUS.RETIRING && k.retire_after && k.retire_after <= now) { + k.status = constants.KEY_STATUS.RETIRED; + changed = true; + } + } + if (changed) { + await saveKeys(keys); + } }; -// Admin-triggered rotation. Generates+seals a NEW ACTIVE keypair (new kid) and -// demotes the prior ACTIVE key to RETIRING with retire_after = now + the grace -// window, so id_tokens signed by the old key keep verifying (it stays in JWKS) -// until the window elapses. The OIDC Provider is built per-issuer with the -// active private JWK baked in, so its cache is cleared here to force a rebuild -// against the new key. Also sweeps any already-expired RETIRING keys to RETIRED. +// Admin-triggered rotation. Generates a NEW ACTIVE keypair (new kid) and demotes +// the prior ACTIVE key to RETIRING with retire_after = now + the grace window, so +// id_tokens signed by the old key keep verifying until the window elapses. Also +// sweeps already-expired RETIRING keys to RETIRED and clears the provider cache. const rotateActiveKey = async () => { - const prior = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); + const keys = await loadKeys(); + const prior = byStatus(keys, constants.KEY_STATUS.ACTIVE)[0]; if (prior) { - const retireAfter = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString(); - await db.updateWhere( - constants.TABLE_KEYS, - { status: constants.KEY_STATUS.RETIRING, retire_after: retireAfter }, - { kid: prior.kid } - ); + prior.status = constants.KEY_STATUS.RETIRING; + prior.retire_after = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString(); } - const created = await insertActiveKey(); - await retireExpiredKeys(); + const k = buildActiveKey(); + keys.push(k); + // sweep expired retiring keys in the same array before persisting + const now = new Date().toISOString(); + for (const e of keys) { + if (e.status === constants.KEY_STATUS.RETIRING && e.retire_after && e.retire_after <= now) { + e.status = constants.KEY_STATUS.RETIRED; + } + } + await saveKeys(keys); // Lazy-require to avoid a require cycle (provider.js requires this module). const { clearProviderCache } = require("./oidc/provider"); clearProviderCache(); - return created; + return { kid: k.kid, alg: k.alg, status: k.status, created_at: k.created_at }; }; diff --git a/lib/saml/idp.js b/lib/saml/idp.js index ec79aad..d6b2975 100644 --- a/lib/saml/idp.js +++ b/lib/saml/idp.js @@ -16,6 +16,7 @@ const selfsigned = require("selfsigned"); const idpCrypto = require("../crypto"); const constants = require("../constants"); +const { readKey, writeKey } = require("../configStore"); // Fail closed at load if xml-crypto is below the patched floor (2025 SAML @@ -63,8 +64,11 @@ const LOGIN_RESPONSE_TEMPLATE = { }; +// The SAML signing cert + sealed private key live in _sc_config (key CFG_SAML), +// not a _idp_saml table: _sc_config is restored BEFORE onLoad, so a restored +// instance keeps its cert and ensureSamlCert() does not generate a duplicate. const ensureSamlCert = async () => { - const existing = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" }); + const existing = await readKey(constants.CFG_SAML); if (existing) { return; } @@ -73,28 +77,21 @@ const ensureSamlCert = async () => { { keyType: "rsa", keySize: 2048, algorithm: "sha256" } ); const sealed = idpCrypto.sealText(pems.private); - await db.insert(constants.TABLE_SAML, { - id: "default", - cert: pems.cert, - private_ciphertext: sealed.ciphertext, - private_iv: sealed.iv, - private_tag: sealed.tag, - created_at: new Date().toISOString() - }, { noid: true }); + await writeKey(constants.CFG_SAML, { + cert: pems.cert, + private: { ciphertext: sealed.ciphertext, iv: sealed.iv, tag: sealed.tag }, + created_at: new Date().toISOString() + }); }; const getSamlCert = async () => { - const row = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" }); - if (!row) { + const stored = await readKey(constants.CFG_SAML); + if (!stored) { return null; } - const key = idpCrypto.openText({ - ciphertext: row.private_ciphertext, - iv: row.private_iv, - tag: row.private_tag - }).toString("utf8"); - return { cert: row.cert, key: key }; + const key = idpCrypto.openText(stored.private).toString("utf8"); + return { cert: stored.cert, key: key }; }; diff --git a/lib/schema.js b/lib/schema.js index e9c4a24..989f7fb 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -1,56 +1,167 @@ -// Idempotent DDL for the saltcorn-idp plugin tables. Portable subset: TEXT for -// strings/JSON/timestamps, INTEGER for booleans; no JSONB. Sealed key/secret -// material is stored as hex TEXT (see crypto.sealText). Table names come from -// constants so there is one source of truth. +// Schema setup for the saltcorn-idp plugin tables. // -// MULTI-TENANCY: raw CREATE statements must be schema-qualified with -// db.getTenantSchemaPrefix() (e.g. "t1".) so tables land in the current tenant's -// Postgres schema -- otherwise unqualified DDL hits the wrong schema while -// Saltcorn's db.select reads the tenant-qualified one. On SQLite the prefix is -// "" (single schema), so the dev MAIN/TEST instances are unaffected. +// 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 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 { TABLE_ENV, TABLE_KEYS, TABLE_OIDC_STORE, TABLE_GROUPS, TABLE_GROUP_MEMBERS, TABLE_CLIENTS, TABLE_SAML, TABLE_SAML_SPS, TABLE_LDAP_SERVICE } = require("./constants"); +const c = require("./constants"); +const { readKey, writeKey } = require("./configStore"); -const createIdpEnv = async () => { - const pfx = db.getTenantSchemaPrefix(); - await db.query(` - CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_ENV} ( - env_id TEXT PRIMARY KEY, - env_label TEXT, - created_at TEXT NOT NULL, - bootstrapped_at TEXT - ) - `); +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; }; -const createIdpKeys = async () => { - const pfx = db.getTenantSchemaPrefix(); - await db.query(` - CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_KEYS} ( - kid TEXT PRIMARY KEY, - alg TEXT NOT NULL, - public_jwk TEXT NOT NULL, - private_ciphertext TEXT NOT NULL, - private_iv TEXT NOT NULL, - private_tag TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - retire_after TEXT - ) - `); - await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_KEYS}_status ON ${pfx}${TABLE_KEYS} (status)`); +// 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 }); + } }; -// Single table backing the oidc-provider storage adapter (all model types). +// 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}${TABLE_OIDC_STORE} ( + CREATE TABLE IF NOT EXISTS ${pfx}${c.TABLE_OIDC_STORE} ( model TEXT NOT NULL, id TEXT NOT NULL, payload TEXT NOT NULL, @@ -61,127 +172,43 @@ const createIdpOidcStore = async () => { PRIMARY KEY (model, id) ) `); - await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_uid ON ${pfx}${TABLE_OIDC_STORE} (uid)`); - await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_grant ON ${pfx}${TABLE_OIDC_STORE} (grant_id)`); - await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_usercode ON ${pfx}${TABLE_OIDC_STORE} (user_code)`); -}; - - -// Custom groups + the user<->group junction ("meet me" table). -const createIdpGroups = async () => { - const pfx = db.getTenantSchemaPrefix(); - // Portable auto-increment PK: sqlite "integer primary key" auto-assigns - // rowids; postgres uses "serial". (AUTOINCREMENT is sqlite-only syntax.) - const serial = db.isSQLite ? "integer" : "serial"; - await db.query(` - CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_GROUPS} ( - id ${serial} PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - description TEXT, - created_at TEXT NOT NULL - ) - `); -}; - - -const createIdpGroupMembers = async () => { - const pfx = db.getTenantSchemaPrefix(); - await db.query(` - CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_GROUP_MEMBERS} ( - group_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - PRIMARY KEY (group_id, user_id) - ) - `); - await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_GROUP_MEMBERS}_user ON ${pfx}${TABLE_GROUP_MEMBERS} (user_id)`); -}; - - -// Registered relying parties. Confidential clients' secrets are sealed at rest -// (hex columns); public clients (token_auth_method='none') have none. -const createIdpClients = async () => { - const pfx = db.getTenantSchemaPrefix(); - await db.query(` - CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_CLIENTS} ( - client_id TEXT PRIMARY KEY, - label TEXT, - token_auth_method TEXT NOT NULL DEFAULT 'none', - redirect_uris TEXT NOT NULL, - grant_types TEXT NOT NULL, - response_types TEXT NOT NULL, - scope TEXT, - secret_ciphertext TEXT, - secret_iv TEXT, - secret_tag TEXT, - created_at TEXT NOT NULL - ) - `); -}; - - -// Per-tenant SAML signing material: a self-signed X.509 cert (advertised in IdP -// metadata) + its sealed private key (used to sign assertions). Singleton row. -const createIdpSaml = async () => { - const pfx = db.getTenantSchemaPrefix(); - await db.query(` - CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_SAML} ( - id TEXT PRIMARY KEY, - cert TEXT NOT NULL, - private_ciphertext TEXT NOT NULL, - private_iv TEXT NOT NULL, - private_tag TEXT NOT NULL, - created_at TEXT NOT NULL - ) - `); -}; - - -// Registered SAML relying parties (service providers). The IdP only issues an -// assertion to a registered SP and only to one of its allow-listed ACS URLs, so -// a forged AuthnRequest cannot redirect a signed assertion to an attacker. The -// optional signing_cert (public, not sealed) enables AuthnRequest signature -// verification; want_authn_requests_signed (INTEGER 0/1) gates enforcement. -const createIdpSamlSps = async () => { - const pfx = db.getTenantSchemaPrefix(); - await db.query(` - CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_SAML_SPS} ( - entity_id TEXT PRIMARY KEY, - label TEXT, - acs_urls TEXT NOT NULL, - signing_cert TEXT, - want_authn_requests_signed INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - ) - `); -}; - - -// LDAP service-account credentials (search-then-bind binder). Single row; the -// password is sealed at rest (hex columns), like client secrets. -const createIdpLdapService = async () => { - const pfx = db.getTenantSchemaPrefix(); - await db.query(` - CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_LDAP_SERVICE} ( - dn TEXT, - secret_ciphertext TEXT, - secret_iv TEXT, - secret_tag TEXT, - created_at TEXT NOT NULL - ) - `); + 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 () => { - await createIdpEnv(); - await createIdpKeys(); + // 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(); - await createIdpGroups(); - await createIdpGroupMembers(); - await createIdpClients(); - await createIdpSaml(); - await createIdpSamlSps(); - await createIdpLdapService(); };