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 += `
`;
+ // memberships are keyed by email now; the row already carries it.
+ const label = member.user_email;
+ memberHtml += `
`;
}
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();
};