sc-idp/lib/schema.js
2026-06-01 16:40:54 -05:00

190 lines
6.9 KiB
JavaScript

// 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.
//
// 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.
const db = require("@saltcorn/data/db");
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 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 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)`);
};
// Single table backing the oidc-provider storage adapter (all model types).
const createIdpOidcStore = async () => {
const pfx = db.getTenantSchemaPrefix();
await db.query(`
CREATE TABLE IF NOT EXISTS ${pfx}${TABLE_OIDC_STORE} (
model TEXT NOT NULL,
id TEXT NOT NULL,
payload TEXT NOT NULL,
uid TEXT,
grant_id TEXT,
user_code TEXT,
expires_at INTEGER,
PRIMARY KEY (model, id)
)
`);
await db.query(`CREATE INDEX IF NOT EXISTS ${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
)
`);
};
const createAllTables = async () => {
await createIdpEnv();
await createIdpKeys();
await createIdpOidcStore();
await createIdpGroups();
await createIdpGroupMembers();
await createIdpClients();
await createIdpSaml();
await createIdpSamlSps();
await createIdpLdapService();
};
module.exports = {
createAllTables
};