This commit is contained in:
Scott Duensing 2026-06-17 17:37:45 -05:00
parent ad31f61fab
commit b4f8daf421
8 changed files with 392 additions and 289 deletions

View file

@ -181,9 +181,9 @@ const groupsPage = async (req, res) => {
const members = await groups.membersOf(g.id); const members = await groups.membersOf(g.id);
let memberHtml = ""; let memberHtml = "";
for (const member of members) { for (const member of members) {
const u = await User.findOne({ id: member.user_id }); // memberships are keyed by email now; the row already carries it.
const label = u ? u.email : ("user#" + member.user_id); const label = member.user_email;
memberHtml += `<form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/removemember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="hidden" name="user_id" value="${escapeHtml(member.user_id)}"><code>${escapeHtml(label)}</code> <button>x</button></form><br>`; memberHtml += `<form class="inline" method="post" action="${escapeHtml(constants.ADMIN_BASE_PATH)}/groups/removemember">${csrfField(req)}<input type="hidden" name="group_id" value="${escapeHtml(g.id)}"><input type="hidden" name="user_email" value="${escapeHtml(member.user_email)}"><code>${escapeHtml(label)}</code> <button>x</button></form><br>`;
} }
rows += `<tr> rows += `<tr>
<td><code>${escapeHtml(g.name)}</code></td> <td><code>${escapeHtml(g.name)}</code></td>
@ -259,9 +259,11 @@ const addMemberHandler = async (req, res) => {
const groupId = parseInt(req.body && req.body.group_id, 10); const groupId = parseInt(req.body && req.body.group_id, 10);
const email = String((req.body && req.body.email) || "").trim(); const email = String((req.body && req.body.email) || "").trim();
if (Number.isFinite(groupId) && email) { 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 }); const u = await User.findOne({ email: email });
if (u) { if (u) {
await groups.addMember(groupId, u.id); await groups.addMember(groupId, u.email);
} }
} }
res.redirect(constants.ADMIN_BASE_PATH + "/groups"); res.redirect(constants.ADMIN_BASE_PATH + "/groups");
@ -273,9 +275,9 @@ const removeMemberHandler = async (req, res) => {
return; return;
} }
const groupId = parseInt(req.body && req.body.group_id, 10); const groupId = parseInt(req.body && req.body.group_id, 10);
const userId = parseInt(req.body && req.body.user_id, 10); const email = String((req.body && req.body.user_email) || "").trim();
if (Number.isFinite(groupId) && Number.isFinite(userId)) { if (Number.isFinite(groupId) && email) {
await groups.removeMember(groupId, userId); await groups.removeMember(groupId, email);
} }
res.redirect(constants.ADMIN_BASE_PATH + "/groups"); res.redirect(constants.ADMIN_BASE_PATH + "/groups");
}; };

51
lib/configStore.js Normal file
View file

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

View file

@ -33,7 +33,11 @@ const USERINFO_PATH = IDP_BASE_PATH + "/me";
const INTERACTION_PATH = IDP_BASE_PATH + "/interaction/:uid"; const INTERACTION_PATH = IDP_BASE_PATH + "/interaction/:uid";
const INTERACTION_CONFIRM_PATH = IDP_BASE_PATH + "/interaction/:uid/confirm"; 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_ENV = "_idp_env";
const TABLE_KEYS = "_idp_keys"; const TABLE_KEYS = "_idp_keys";
const TABLE_OIDC_STORE = "_idp_oidc_store"; 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_GROUP_MEMBERS = "_idp_group_members";
const TABLE_CLIENTS = "_idp_clients"; 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. // Signing.
const SIGNING_ALG = "RS256"; const SIGNING_ALG = "RS256";
const RSA_MODULUS_BITS = 2048; const RSA_MODULUS_BITS = 2048;
@ -138,6 +149,9 @@ module.exports = {
TABLE_GROUPS, TABLE_GROUPS,
TABLE_GROUP_MEMBERS, TABLE_GROUP_MEMBERS,
TABLE_CLIENTS, TABLE_CLIENTS,
CFG_ENV,
CFG_KEYS,
CFG_SAML,
SIGNING_ALG, SIGNING_ALG,
RSA_MODULUS_BITS, RSA_MODULUS_BITS,
KEY_STATUS, KEY_STATUS,

View file

@ -1,19 +1,18 @@
// Singleton per-instance (per-tenant) environment row for saltcorn-idp. Tracks // Singleton per-instance (per-tenant) environment row for saltcorn-idp. Tracks
// first-run bootstrap state and an instance label for the admin UI. // 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 // Stored in _sc_config (key CFG_ENV) rather than a _idp_env table: _sc_config
// serve one tenant's row to another. Reads are infrequent (onLoad + dashboard), // survives backup and is restored BEFORE onLoad, so a restored instance keeps
// so we query each time within the caller's tenant context. // its env_id and onLoad does not create a duplicate (see lib/configStore.js).
const crypto = require("crypto"); 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 getEnv = async () => {
const rows = await db.select(TABLE_ENV, {}); return (await readKey(CFG_ENV)) || null;
return rows.length > 0 ? rows[0] : null;
}; };
@ -22,21 +21,23 @@ const initEnvIfMissing = async () => {
if (existing) { if (existing) {
return existing; return existing;
} }
const now = new Date().toISOString(); const env = {
const row = {
env_id: crypto.randomUUID(), env_id: crypto.randomUUID(),
env_label: null, env_label: null,
created_at: now, created_at: new Date().toISOString(),
bootstrapped_at: null bootstrapped_at: null
}; };
await db.insert(TABLE_ENV, row, { noid: true }); await writeKey(CFG_ENV, env);
return row; return env;
}; };
const markBootstrapped = async (envId) => { const markBootstrapped = async (envId) => {
const now = new Date().toISOString(); const env = await getEnv();
await db.updateWhere(TABLE_ENV, { bootstrapped_at: now }, { env_id: envId }); if (env && env.env_id === envId) {
env.bootstrapped_at = new Date().toISOString();
await writeKey(CFG_ENV, env);
}
}; };

View file

@ -37,17 +37,20 @@ const membersOf = async (groupId) => {
}; };
const addMember = async (groupId, userId) => { // Memberships are keyed by the user's stable EMAIL (not the raw users.id), so a
const existing = await db.selectMaybeOne(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId }); // 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) { if (existing) {
return; 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) => { const removeMember = async (groupId, email) => {
await db.deleteWhere(TABLE_GROUP_MEMBERS, { group_id: groupId, user_id: userId }); await db.deleteWhere(TABLE_GROUP_MEMBERS, { group_id: groupId, user_email: email });
}; };
@ -57,7 +60,8 @@ const effectiveGroups = async (user) => {
if (role && role.role) { if (role && role.role) {
out.push(ROLE_PREFIX + 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) { for (const member of members) {
const group = await db.selectMaybeOne(TABLE_GROUPS, { id: member.group_id }); const group = await db.selectMaybeOne(TABLE_GROUPS, { id: member.group_id });
if (group && group.name) { if (group && group.name) {

View file

@ -1,91 +1,103 @@
// Signing-key lifecycle for the saltcorn-idp OIDC provider. // Signing-key lifecycle for the saltcorn-idp OIDC provider.
// //
// Keys are per-tenant (stored in this tenant's _idp_keys). The private key is // Keys are per-tenant, stored in _sc_config (key CFG_KEYS) as an array of key
// sealed at rest (AES-256-GCM under the plugin KEK); only the PUBLIC JWK is // objects rather than a _idp_keys table. _sc_config survives backup AND is
// ever exposed, via JWKS. ensureActiveKey() is idempotent: it creates an active // restored BEFORE onLoad, so a restored instance keeps its signing keys and
// key only if none exists for the current tenant. getActiveKeyMeta() never // ensureActiveKey() does not generate a duplicate ACTIVE key (a registered
// touches private material so the dashboard can render without decrypting. // table, imported AFTER onLoad, would leave two ACTIVE keys -> broken JWKS).
//
const db = require("@saltcorn/data/db"); // 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 crypto = require("./crypto");
const constants = require("./constants"); const constants = require("./constants");
const { readKey, writeKey } = require("./configStore");
// Generate + seal a fresh RSA signing keypair and insert it as the new ACTIVE const loadKeys = async () => {
// key. Shared by ensureActiveKey (first key for a tenant) and rotateActiveKey const arr = await readKey(constants.CFG_KEYS);
// (replacement key). Returns only safe metadata; the sealed private material is return Array.isArray(arr) ? arr : [];
// never handed back. };
const insertActiveKey = async () => {
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 kid = crypto.newKid();
const pair = crypto.generateSigningKeyPair(constants.RSA_MODULUS_BITS); const pair = crypto.generateSigningKeyPair(constants.RSA_MODULUS_BITS);
const jwk = crypto.publicKeyToJwk(pair.publicKey, kid, constants.SIGNING_ALG); const jwk = crypto.publicKeyToJwk(pair.publicKey, kid, constants.SIGNING_ALG);
const pem = crypto.exportPrivatePem(pair.privateKey); const pem = crypto.exportPrivatePem(pair.privateKey);
const sealed = crypto.sealText(pem); const sealed = crypto.sealText(pem);
const now = new Date().toISOString(); const now = new Date().toISOString();
const row = { return {
kid: kid, kid: kid,
alg: constants.SIGNING_ALG, alg: constants.SIGNING_ALG,
public_jwk: JSON.stringify(jwk), public_jwk: jwk,
private_ciphertext: sealed.ciphertext, private: { ciphertext: sealed.ciphertext, iv: sealed.iv, tag: sealed.tag },
private_iv: sealed.iv, status: constants.KEY_STATUS.ACTIVE,
private_tag: sealed.tag, created_at: now,
status: constants.KEY_STATUS.ACTIVE, retire_after: null
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 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) { if (existing) {
return { kid: existing.kid, alg: existing.alg, status: existing.status, created_at: existing.created_at }; 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 getJwks = async () => {
const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); const keys = await loadKeys();
const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING }); const pub = byStatus(keys, constants.KEY_STATUS.ACTIVE)
const keys = active.concat(retiring).map((r) => JSON.parse(r.public_jwk)); .concat(byStatus(keys, constants.KEY_STATUS.RETIRING))
return { keys: keys }; .map((k) => k.public_jwk);
return { keys: pub };
}; };
const getActiveKeyMeta = async () => { const getActiveKeyMeta = async () => {
const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); const k = byStatus(await loadKeys(), constants.KEY_STATUS.ACTIVE)[0];
if (!row) { if (!k) {
return null; 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 getActiveSigningKey = async () => {
const row = await db.selectMaybeOne(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); const k = byStatus(await loadKeys(), constants.KEY_STATUS.ACTIVE)[0];
if (!row) { if (!k) {
return null; return null;
} }
const pem = crypto.openText({ return { kid: k.kid, alg: k.alg, privateKey: crypto.importPrivatePem(openPrivate(k)) };
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)
};
}; };
// 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 getActivePrivateJwk = async () => {
const key = await getActiveSigningKey(); const key = await getActiveSigningKey();
if (!key) { if (!key) {
@ -96,63 +108,58 @@ const getActivePrivateJwk = async () => {
// All non-retired private signing keys as JWKs for oidc-provider's jwks config: // 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 // the ACTIVE key FIRST, then any RETIRING keys (so id_tokens issued before a
// matches the requested alg) followed by any RETIRING keys, so id_tokens issued // rotation still verify against JWKS during the grace window). RETIRED excluded.
// 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()).
const getSigningPrivateJwks = async () => { const getSigningPrivateJwks = async () => {
const active = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.ACTIVE }); const keys = await loadKeys();
const retiring = await db.select(constants.TABLE_KEYS, { status: constants.KEY_STATUS.RETIRING }); const ordered = byStatus(keys, constants.KEY_STATUS.ACTIVE).concat(byStatus(keys, constants.KEY_STATUS.RETIRING));
const jwks = []; return ordered.map((k) => crypto.privateKeyToJwk(crypto.importPrivatePem(openPrivate(k)), k.kid, k.alg));
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;
}; };
// Flip any RETIRING keys whose grace window has elapsed to RETIRED, dropping // Flip any RETIRING keys whose grace window has elapsed to RETIRED, dropping
// them from JWKS. Idempotent and cheap; called opportunistically on the admin // them from JWKS. Idempotent. A null retire_after never expires.
// 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.
const retireExpiredKeys = async () => { const retireExpiredKeys = async () => {
const now = new Date().toISOString(); const now = new Date().toISOString();
await db.updateWhere( const keys = await loadKeys();
constants.TABLE_KEYS, let changed = false;
{ status: constants.KEY_STATUS.RETIRED }, for (const k of keys) {
{ status: constants.KEY_STATUS.RETIRING, retire_after: { lt: now, equal: true } } 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 // Admin-triggered rotation. Generates a NEW ACTIVE keypair (new kid) and demotes
// demotes the prior ACTIVE key to RETIRING with retire_after = now + the grace // the prior ACTIVE key to RETIRING with retire_after = now + the grace window, so
// window, so id_tokens signed by the old key keep verifying (it stays in JWKS) // id_tokens signed by the old key keep verifying until the window elapses. Also
// until the window elapses. The OIDC Provider is built per-issuer with the // sweeps already-expired RETIRING keys to RETIRED and clears the provider cache.
// 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.
const rotateActiveKey = async () => { 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) { if (prior) {
const retireAfter = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString(); prior.status = constants.KEY_STATUS.RETIRING;
await db.updateWhere( prior.retire_after = new Date(Date.now() + constants.KEY_RETIRE_GRACE_MS).toISOString();
constants.TABLE_KEYS,
{ status: constants.KEY_STATUS.RETIRING, retire_after: retireAfter },
{ kid: prior.kid }
);
} }
const created = await insertActiveKey(); const k = buildActiveKey();
await retireExpiredKeys(); 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). // Lazy-require to avoid a require cycle (provider.js requires this module).
const { clearProviderCache } = require("./oidc/provider"); const { clearProviderCache } = require("./oidc/provider");
clearProviderCache(); clearProviderCache();
return created; return { kid: k.kid, alg: k.alg, status: k.status, created_at: k.created_at };
}; };

View file

@ -16,6 +16,7 @@ const selfsigned = require("selfsigned");
const idpCrypto = require("../crypto"); const idpCrypto = require("../crypto");
const constants = require("../constants"); const constants = require("../constants");
const { readKey, writeKey } = require("../configStore");
// Fail closed at load if xml-crypto is below the patched floor (2025 SAML // 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 ensureSamlCert = async () => {
const existing = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" }); const existing = await readKey(constants.CFG_SAML);
if (existing) { if (existing) {
return; return;
} }
@ -73,28 +77,21 @@ const ensureSamlCert = async () => {
{ keyType: "rsa", keySize: 2048, algorithm: "sha256" } { keyType: "rsa", keySize: 2048, algorithm: "sha256" }
); );
const sealed = idpCrypto.sealText(pems.private); const sealed = idpCrypto.sealText(pems.private);
await db.insert(constants.TABLE_SAML, { await writeKey(constants.CFG_SAML, {
id: "default", cert: pems.cert,
cert: pems.cert, private: { ciphertext: sealed.ciphertext, iv: sealed.iv, tag: sealed.tag },
private_ciphertext: sealed.ciphertext, created_at: new Date().toISOString()
private_iv: sealed.iv, });
private_tag: sealed.tag,
created_at: new Date().toISOString()
}, { noid: true });
}; };
const getSamlCert = async () => { const getSamlCert = async () => {
const row = await db.selectMaybeOne(constants.TABLE_SAML, { id: "default" }); const stored = await readKey(constants.CFG_SAML);
if (!row) { if (!stored) {
return null; return null;
} }
const key = idpCrypto.openText({ const key = idpCrypto.openText(stored.private).toString("utf8");
ciphertext: row.private_ciphertext, return { cert: stored.cert, key: key };
iv: row.private_iv,
tag: row.private_tag
}).toString("utf8");
return { cert: row.cert, key: key };
}; };

View file

@ -1,56 +1,167 @@
// Idempotent DDL for the saltcorn-idp plugin tables. Portable subset: TEXT for // Schema setup for the saltcorn-idp plugin tables.
// 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.
// //
// MULTI-TENANCY: raw CREATE statements must be schema-qualified with // Two storage strategies, chosen by WHEN the state is written, so each survives
// db.getTenantSchemaPrefix() (e.g. "t1".) so tables land in the current tenant's // Saltcorn backup/restore without fighting the plugin lifecycle:
// Postgres schema -- otherwise unqualified DDL hits the wrong schema while //
// Saltcorn's db.select reads the tenant-qualified one. On SQLite the prefix is // * onLoad-GENERATED state (env identity, signing keys, SAML cert) -> _sc_config
// "" (single schema), so the dev MAIN/TEST instances are unaffected. // (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 GROUPS_FIELDS = [
const pfx = db.getTenantSchemaPrefix(); { name: "name", type: "String", is_unique: true, required: true },
await db.query(` { name: "description", type: "String" },
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_ENV} ( { name: "created_at", type: "String", required: true }
env_id TEXT PRIMARY KEY, ];
env_label TEXT,
created_at TEXT NOT NULL, const GROUP_MEMBERS_FIELDS = [
bootstrapped_at TEXT { 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 () => { // Make `name` a registered Saltcorn table (auto id PK + fieldDefs). Idempotent.
const pfx = db.getTenantSchemaPrefix(); // If a legacy raw table exists, migrate its rows (optionally transformed) then
await db.query(` // drop it. idp installs no CRUD wraps, so no suppression is needed here; if
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_KEYS} ( // dev-deploy is co-installed, its wraps skip _idp_* infra tables.
kid TEXT PRIMARY KEY, const registerTable = async (name, fieldDefs, rowTransform) => {
alg TEXT NOT NULL, if (Table.findOne({ name })) {
public_jwk TEXT NOT NULL, return;
private_ciphertext TEXT NOT NULL, }
private_iv TEXT NOT NULL, const schema = db.getTenantSchemaPrefix();
private_tag TEXT NOT NULL, let rows = [];
status TEXT NOT NULL DEFAULT 'active', if (await rawTableExists(name)) {
created_at TEXT NOT NULL, rows = (await db.query(`SELECT * FROM ${schema}"${name}"`)).rows;
retire_after TEXT await db.query(`DROP TABLE ${schema}"${name}"`);
) }
`); const t = await Table.create(name, { min_role_read: 1, min_role_write: 1 });
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_KEYS}_status ON ${pfx}${TABLE_KEYS} (status)`); 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 createIdpOidcStore = async () => {
const pfx = db.getTenantSchemaPrefix(); const pfx = db.getTenantSchemaPrefix();
await db.query(` 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, model TEXT NOT NULL,
id TEXT NOT NULL, id TEXT NOT NULL,
payload TEXT NOT NULL, payload TEXT NOT NULL,
@ -61,127 +172,43 @@ const createIdpOidcStore = async () => {
PRIMARY KEY (model, id) 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 ${c.TABLE_OIDC_STORE}_uid ON ${pfx}${c.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 ${c.TABLE_OIDC_STORE}_grant ON ${pfx}${c.TABLE_OIDC_STORE} (grant_id)`);
await db.query(`CREATE INDEX IF NOT EXISTS ${TABLE_OIDC_STORE}_usercode ON ${pfx}${TABLE_OIDC_STORE} (user_code)`); await db.query(`CREATE INDEX IF NOT EXISTS ${c.TABLE_OIDC_STORE}_usercode ON ${pfx}${c.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
)
`);
}; };
const createAllTables = async () => { const createAllTables = async () => {
await createIdpEnv(); // onLoad-generated state -> _sc_config (migrate legacy tables first).
await createIdpKeys(); 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 createIdpOidcStore();
await createIdpGroups();
await createIdpGroupMembers();
await createIdpClients();
await createIdpSaml();
await createIdpSamlSps();
await createIdpLdapService();
}; };