openvibers/openvibe.shared config-pg.js
config-pg.js
| 1 | ; |
| 2 | // openvibe-shared/config on PostgreSQL (ADR-035): the configuration store on an openvibe-sdk/db handle. Every |
| 3 | // method that reads or writes the database is async; get() and revision() stay in memory. createConfigStore picks this |
| 4 | // implementation when it is handed an openvibe-sdk/db handle, and then returns a promise of the store (it has read or |
| 5 | // seeded its active revision when it resolves). The tables come from |
| 6 | // the service's migrations: configSchema() is their DDL. |
| 7 | // |
| 8 | // Secret-class values are sealed (D11) when the store has a KEK: a random AES-256-GCM data key seals the value, a |
| 9 | // versioned KEK seals that data key, and values_json only ever holds the envelope. get() opens it for the service; |
| 10 | // summaries, snapshots, history and errors carry a keyed fingerprint marker only. x-bind adds the value of another |
| 11 | // key to the AAD, so changing that value locks the secret until it is entered again. Without a KEK nothing is sealed |
| 12 | // and the store behaves exactly as before, but reports sealed: false. |
| 13 | const crypto = require('crypto'); |
| 14 | const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows, |
| 15 | isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema } = require('./config-core'); |
| 16 | |
| 17 | /** The DDL of config_snapshots and config_keys, for a service's migrations. */ |
| 18 | function configSchema() { |
| 19 | return `CREATE TABLE IF NOT EXISTS config_snapshots ( |
| 20 | namespace text COLLATE "C" NOT NULL, |
| 21 | revision bigint NOT NULL, |
| 22 | service text COLLATE "C" NOT NULL, |
| 23 | previous_revision bigint, |
| 24 | state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')), |
| 25 | values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext) |
| 26 | classification_json text COLLATE "C" NOT NULL, |
| 27 | values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers) |
| 28 | created_at text COLLATE "C" NOT NULL, |
| 29 | created_by text COLLATE "C" NOT NULL, |
| 30 | activated_at text COLLATE "C", |
| 31 | activated_by text COLLATE "C", |
| 32 | reason text COLLATE "C", |
| 33 | error text COLLATE "C", |
| 34 | copied_from bigint, |
| 35 | good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good) |
| 36 | PRIMARY KEY (namespace, revision) |
| 37 | ); |
| 38 | CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active'; |
| 39 | -- The fingerprint key of each namespace. Never served, never logged. |
| 40 | CREATE TABLE IF NOT EXISTS config_keys ( |
| 41 | namespace text COLLATE "C" PRIMARY KEY, |
| 42 | hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK |
| 43 | created_at text COLLATE "C" NOT NULL |
| 44 | );`; |
| 45 | } |
| 46 | |
| 47 | async function createPgConfigStore(opts = {}) { |
| 48 | const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null, onChange = null } = opts; |
| 49 | if (!db || typeof db.prepare !== 'function' || typeof db.tx !== 'function') throw new TypeError('createConfigStore: db must be an openvibe-sdk/db handle'); |
| 50 | if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)'); |
| 51 | if (!NAMESPACE_RE.test(String(namespace || '')) || !namespace.startsWith(`${service}.`) || namespace.length > 120) { |
| 52 | throw new TypeError(`createConfigStore: namespace must be <service>.<name>, e.g. ${service}.settings`); |
| 53 | } |
| 54 | const keks = keksOf(opts, opts.env || process.env); |
| 55 | if (schema !== null) compileSchema(schema); |
| 56 | if (validate !== null && typeof validate !== 'function') throw new TypeError('createConfigStore: validate must be a function'); |
| 57 | if (onActivate !== null && typeof onActivate !== 'function') throw new TypeError('createConfigStore: onActivate must be a function'); |
| 58 | if (onChange !== null && typeof onChange !== 'function') throw new TypeError('createConfigStore: onChange must be a function'); |
| 59 | if (classify !== null && typeof classify !== 'function' && !isPlainObject(classify)) throw new TypeError('createConfigStore: classify must be a map or a function'); |
| 60 | if (opts.defaults != null && !isPlainObject(opts.defaults)) throw new TypeError('createConfigStore: defaults must be an object'); |
| 61 | const keep = Math.max(1, Math.floor(opts.keep || 50)); |
| 62 | const maxBytes = opts.maxBytes || 1024 * 1024; |
| 63 | const log = makeLog(opts.log); |
| 64 | const now = opts.now || (() => new Date()); |
| 65 | const iso = () => { const d = now(); return (d instanceof Date ? d : new Date(d)).toISOString(); }; |
| 66 | const self = { type: 'service', id: service }; |
| 67 | const defaults = deepFreeze(clone(opts.defaults || {})); |
| 68 | const tag = `[config] ${namespace}`; |
| 69 | const env = opts.env || process.env; |
| 70 | |
| 71 | // ── the schema's owner annotations: env pins, binds and danger keys ── |
| 72 | const annotations = schemaAnnotations(schema); |
| 73 | const binds = annotations.bind; |
| 74 | const dangerKeys = new Set(); |
| 75 | if (schema && isPlainObject(schema.properties)) { |
| 76 | for (const [k, p] of Object.entries(schema.properties)) { |
| 77 | if (isPlainObject(p) && p['x-danger'] === true) dangerKeys.add(k); |
| 78 | if (isPlainObject(p) && typeof p['x-bind'] === 'string' && classOf(p['x-bind'], undefined) === 'secret') throw new TypeError(`config schema: x-bind of ${k} names the secret key ${p['x-bind']}`); |
| 79 | } |
| 80 | } |
| 81 | function envValue(prop, raw) { |
| 82 | const types = prop && prop.type !== undefined ? [].concat(prop.type) : []; |
| 83 | if (types.includes('boolean')) return raw === 'true' || raw === '1'; |
| 84 | if (types.includes('integer') || types.includes('number')) { |
| 85 | const n = Number(String(raw).trim()); |
| 86 | return Number.isFinite(n) ? n : raw; |
| 87 | } |
| 88 | if (types.includes('array') || types.includes('object')) { try { return JSON.parse(raw); } catch { return raw; } } |
| 89 | return raw; |
| 90 | } |
| 91 | const pins = {}; |
| 92 | for (const [k, name] of Object.entries(annotations.env)) { |
| 93 | const raw = env ? env[name] : undefined; |
| 94 | if (raw === undefined) continue; |
| 95 | pins[k] = { env: name, value: envValue(schema && schema.properties && schema.properties[k], raw) }; |
| 96 | } |
| 97 | const pinKeys = new Set(Object.keys(pins)); |
| 98 | const pinValues = () => Object.fromEntries(Object.entries(pins).map(([k, p]) => [k, clone(p.value)])); |
| 99 | const emptyEffective = deepFreeze({ ...clone(defaults), ...pinValues() }); |
| 100 | |
| 101 | // ── classification and redaction ── |
| 102 | const RANK = { public: 0, internal: 1, secret: 2 }; |
| 103 | function classOf(key, value) { |
| 104 | let c; |
| 105 | try { c = typeof classify === 'function' ? classify(key, value) : (classify && has(classify, key) ? classify[key] : undefined); } catch { c = 'secret'; } |
| 106 | if (c == null) return 'internal'; |
| 107 | if (!CLASSES.includes(c)) { log.warn(`${tag}: classify gave an invalid class for ${key}; treating it as secret`); return 'secret'; } |
| 108 | return c; |
| 109 | } |
| 110 | /** An envelope is always secret-class; everything else is the policy's call. */ |
| 111 | const classOfValue = (k, v) => (isEnvelope(v) ? 'secret' : classOf(k, v)); |
| 112 | const classifyAll = (values) => Object.fromEntries(Object.keys(values).map((k) => [k, classOfValue(k, values[k])])); |
| 113 | /** The stricter of what was stored and what the policy says now, so a key made secret later is redacted in old revisions too. */ |
| 114 | function classesFor(values, stored = {}) { |
| 115 | const out = classifyAll(values); |
| 116 | for (const k of Object.keys(out)) if (stored[k] && RANK[stored[k]] > RANK[out[k]]) out[k] = stored[k]; |
| 117 | return out; |
| 118 | } |
| 119 | /** The marker of a stored value: an envelope carries its fingerprint; plaintext is HMAC'd as before. */ |
| 120 | function markerOf(v) { |
| 121 | const p = isEnvelope(v) ? parseEnvelope(v) : null; |
| 122 | if (p) return { redacted: true, fingerprint: p.fingerprint }; |
| 123 | return { redacted: true, fingerprint: fingerprint(v) }; |
| 124 | } |
| 125 | const redact = (values, cls) => Object.fromEntries(Object.entries(values).map(([k, v]) => [k, cls[k] === 'secret' ? markerOf(v) : v])); |
| 126 | /** The fingerprint a marker of this value has, whether the value is stored sealed or plain. */ |
| 127 | const storedFingerprint = (v) => (isEnvelope(v) ? parseEnvelope(v).fingerprint : fingerprint(v)); |
| 128 | function secretStrings(...valueSets) { |
| 129 | const out = []; |
| 130 | for (const values of valueSets) { |
| 131 | if (!values) continue; |
| 132 | for (const [k, v] of Object.entries(values)) { |
| 133 | if (v == null || isEnvelope(v)) continue; |
| 134 | if (classOf(k, v) !== 'secret') continue; |
| 135 | out.push(typeof v === 'string' ? v : JSON.stringify(v)); |
| 136 | } |
| 137 | } |
| 138 | return out.sort((a, b) => b.length - a.length); |
| 139 | } |
| 140 | const plainStrings = (...valueSets) => { |
| 141 | const out = []; |
| 142 | for (const values of valueSets) { |
| 143 | if (!values) continue; |
| 144 | for (const v of Object.values(values)) if (v != null && !isEnvelope(v)) out.push(typeof v === 'string' ? v : JSON.stringify(v)); |
| 145 | } |
| 146 | return out.sort((a, b) => b.length - a.length); |
| 147 | }; |
| 148 | |
| 149 | // config_snapshots and config_keys come from the service's migrations (configSchema()). |
| 150 | const q = { |
| 151 | row: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?'), |
| 152 | active: db.prepare("SELECT * FROM config_snapshots WHERE namespace = ? AND state = 'active'"), |
| 153 | any: db.prepare('SELECT 1 AS x FROM config_snapshots WHERE namespace = ? LIMIT 1'), |
| 154 | max: db.prepare('SELECT COALESCE(MAX(revision), 0) AS n FROM config_snapshots WHERE namespace = ?'), |
| 155 | lastGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('active', 'superseded', 'rolled_back') ORDER BY revision DESC LIMIT 1"), |
| 156 | previousGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('superseded', 'rolled_back') AND revision < ? AND values_checksum != ? ORDER BY revision DESC LIMIT 1"), |
| 157 | history: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision < ? ORDER BY revision DESC LIMIT ?'), |
| 158 | insert: db.prepare(`INSERT INTO config_snapshots (namespace, revision, service, previous_revision, state, values_json, classification_json, |
| 159 | values_checksum, created_at, created_by, activated_at, activated_by, reason, copied_from, good) |
| 160 | VALUES (@namespace, @revision, @service, @previous_revision, @state, @values_json, @classification_json, |
| 161 | @values_checksum, @created_at, @created_by, @activated_at, @activated_by, @reason, @copied_from, @good)`), |
| 162 | setState: db.prepare('UPDATE config_snapshots SET state = ? WHERE namespace = ? AND revision = ?'), |
| 163 | activate: db.prepare("UPDATE config_snapshots SET state = 'active', activated_at = ?, activated_by = ?, good = ? WHERE namespace = ? AND revision = ?"), |
| 164 | good: db.prepare('UPDATE config_snapshots SET good = 1 WHERE namespace = ? AND revision = ?'), |
| 165 | reject: db.prepare("UPDATE config_snapshots SET state = 'rejected', error = ?, activated_at = NULL, activated_by = NULL, good = 0 WHERE namespace = ? AND revision = ?"), |
| 166 | prune: db.prepare("DELETE FROM config_snapshots WHERE namespace = ? AND revision <= ? AND state != 'active' AND revision != ?"), |
| 167 | reseal: db.prepare('UPDATE config_snapshots SET values_json = ?, classification_json = ?, values_checksum = ? WHERE namespace = ? AND revision = ?'), |
| 168 | all: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? ORDER BY revision'), |
| 169 | }; |
| 170 | const tx = async (fn) => await db.tx(fn); // ambient: the store's statements inside join it |
| 171 | |
| 172 | // ── the namespace's fingerprint key: made on first use, then read (a racing process keeps the first) ── |
| 173 | // With a KEK, config_keys holds that key wrapped under the current KEK (never the key); without one it is |
| 174 | // today's plain 32-byte key. The key itself never changes, so markers and checksums survive a KEK rotation. |
| 175 | let hmacKey = null; // loaded by init(): made on first use, then read (a racing process keeps the first) |
| 176 | async function loadKey() { |
| 177 | const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?'); |
| 178 | const insert = db.prepare('INSERT INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING'); |
| 179 | const conditional = db.prepare('UPDATE config_keys SET hmac_key = ? WHERE namespace = ? AND hmac_key = ?'); |
| 180 | const unconditional = db.prepare('UPDATE config_keys SET hmac_key = ? WHERE namespace = ?'); |
| 181 | const wrap = (key) => sealFingerprintKey(key, { service, namespace, kekId: keks.current, kek: keks.keys.get(keks.current) }); |
| 182 | const open = (stored) => openFingerprintKey(stored, { service, namespace, keys: keks.keys }); |
| 183 | let row = await read.get(namespace); |
| 184 | if (!row) { |
| 185 | const fresh = crypto.randomBytes(32); |
| 186 | await insert.run(namespace, keks ? wrap(fresh) : fresh, iso()); |
| 187 | row = await read.get(namespace); |
| 188 | } |
| 189 | let stored = row && row.hmac_key ? Buffer.from(row.hmac_key) : null; // Buffer (pg) or Uint8Array (PGlite) |
| 190 | if (!stored) throw new Error(`createConfigStore: config_keys has no fingerprint key for ${namespace}`); |
| 191 | if (isWrappedFingerprintKey(stored)) { |
| 192 | if (!keks) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is sealed under a KEK, but no KEK is configured`); |
| 193 | let opened = open(stored); |
| 194 | if (!opened) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a valid wrapper`); |
| 195 | if (opened.kekId !== keks.current) { |
| 196 | const replacement = wrap(opened.key); |
| 197 | await conditional.run(replacement, namespace, stored); |
| 198 | let after = Buffer.from((await read.get(namespace)).hmac_key); |
| 199 | let next = isWrappedFingerprintKey(after) ? open(after) : null; |
| 200 | if (!next || next.kekId !== keks.current) { |
| 201 | // A row this process did not read (or a driver that did not match bytea): rewrap it outright. |
| 202 | await unconditional.run(replacement, namespace); |
| 203 | after = Buffer.from((await read.get(namespace)).hmac_key); |
| 204 | next = isWrappedFingerprintKey(after) ? open(after) : null; |
| 205 | } |
| 206 | if (!next) throw new Error(`createConfigStore: could not rewrap the fingerprint key of ${namespace} under ${keks.current}`); |
| 207 | opened = next; |
| 208 | } |
| 209 | hmacKey = opened.key; |
| 210 | return; |
| 211 | } |
| 212 | if (stored.length !== 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is neither a 32-byte key nor a KEK-wrapped key`); |
| 213 | if (!keks) { hmacKey = stored; return; } |
| 214 | // A KEK was just configured on an existing namespace: wrap the same key, so no marker or checksum moves. |
| 215 | let replacement = wrap(stored); |
| 216 | await conditional.run(replacement, namespace, stored); |
| 217 | let after = Buffer.from((await read.get(namespace)).hmac_key); |
| 218 | if (!isWrappedFingerprintKey(after)) { |
| 219 | // The conditional did not match: another process may have wrapped it (read again) or replaced |
| 220 | // the key, or the driver did not compare bytea. Wrap whichever 32-byte key is there now, once |
| 221 | // without the old-value guard, then fail closed. |
| 222 | if (after.length === 32) { |
| 223 | if (!after.equals(stored)) replacement = wrap(after); |
| 224 | await unconditional.run(replacement, namespace); |
| 225 | after = Buffer.from((await read.get(namespace)).hmac_key); |
| 226 | } |
| 227 | if (!isWrappedFingerprintKey(after)) throw new Error(`createConfigStore: could not seal the fingerprint key of ${namespace} under ${keks.current}`); |
| 228 | } |
| 229 | const opened = open(after); |
| 230 | if (!opened) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a valid wrapper`); |
| 231 | hmacKey = opened.key; |
| 232 | } |
| 233 | const fingerprint = (v) => crypto.createHmac('sha256', hmacKey).update(canonical(v)).digest('hex'); |
| 234 | const markerFor = (v) => ({ redacted: true, fingerprint: fingerprint(v) }); |
| 235 | |
| 236 | /** |
| 237 | * Open every envelope of a stored values object with the bind each key's x-bind names. A secret that does not |
| 238 | * open (another bind, a missing KEK, a copied envelope) is locked: it stays a marker and never reaches get(). |
| 239 | */ |
| 240 | function materialize(stored) { |
| 241 | const base = {}; |
| 242 | for (const [k, v] of Object.entries(stored)) if (!isEnvelope(v)) base[k] = v; |
| 243 | const opened = {}; |
| 244 | const locked = new Set(); |
| 245 | const meta = {}; |
| 246 | const memo = new Map(); |
| 247 | function valueOf(key, seen) { |
| 248 | if (pinKeys.has(key)) return clone(pins[key].value); |
| 249 | if (memo.has(key)) return memo.get(key); |
| 250 | if (has(opened, key)) return opened[key]; |
| 251 | if (has(base, key)) return base[key]; |
| 252 | if (has(stored, key)) { |
| 253 | const r = openKey(key, seen); |
| 254 | memo.set(key, r.value); |
| 255 | return r.value; |
| 256 | } |
| 257 | if (has(defaults, key)) return defaults[key]; |
| 258 | return undefined; |
| 259 | } |
| 260 | function bindOf(key, seen) { |
| 261 | const target = binds[key]; |
| 262 | if (!target) return ''; |
| 263 | return bindText(valueOf(target, new Set(seen))); |
| 264 | } |
| 265 | function openKey(key, seen) { |
| 266 | if (!keks) throw Object.assign(new Error('no KEK is configured'), { code: 'config.secret_unreadable' }); |
| 267 | if (seen.has(key)) throw Object.assign(new Error('x-bind cycle'), { code: 'config.secret_unreadable' }); |
| 268 | const nextSeen = new Set(seen).add(key); |
| 269 | const bind = bindOf(key, nextSeen); |
| 270 | const { value, meta: m } = openEnvelope(stored[key], { service, namespace, key, bind, keys: keks.keys }); |
| 271 | return { value, meta: m }; |
| 272 | } |
| 273 | for (const [k, v] of Object.entries(stored)) { |
| 274 | if (!isEnvelope(v)) continue; |
| 275 | try { |
| 276 | const r = openKey(k, new Set()); |
| 277 | opened[k] = r.value; |
| 278 | meta[k] = r.meta; |
| 279 | } catch { locked.add(k); } |
| 280 | } |
| 281 | const effective = { ...clone(defaults), ...clone(base), ...clone(opened) }; |
| 282 | for (const [k, p] of Object.entries(pins)) effective[k] = clone(p.value); |
| 283 | for (const k of locked) if (!pinKeys.has(k)) delete effective[k]; |
| 284 | return { opened, locked, effective, meta, stored }; |
| 285 | } |
| 286 | |
| 287 | function toSnapshot(row) { |
| 288 | const values = JSON.parse(row.values_json); |
| 289 | const classification = classesFor(values, JSON.parse(row.classification_json)); |
| 290 | const shown = redact(values, classification); |
| 291 | return { |
| 292 | service: row.service, |
| 293 | namespace: row.namespace, |
| 294 | revision: row.revision, |
| 295 | previous_revision: row.previous_revision == null ? null : row.previous_revision, |
| 296 | state: row.state, |
| 297 | values: shown, |
| 298 | classification, |
| 299 | checksum: sha256(canonical(shown)), |
| 300 | created_at: row.created_at, |
| 301 | created_by: JSON.parse(row.created_by), |
| 302 | activated_at: row.activated_at || null, |
| 303 | activated_by: row.activated_by ? JSON.parse(row.activated_by) : null, |
| 304 | reason: row.reason == null ? null : row.reason, |
| 305 | error: row.error == null ? null : row.error, |
| 306 | copied_from: row.copied_from == null ? null : row.copied_from, |
| 307 | }; |
| 308 | } |
| 309 | |
| 310 | // ── in-memory state ── |
| 311 | let current = null; // { revision, checksum, stored, opened, locked, effective, classification } |
| 312 | function load(row) { |
| 313 | const stored = JSON.parse(row.values_json); |
| 314 | const classification = classesFor(stored, JSON.parse(row.classification_json)); |
| 315 | const mat = materialize(stored); |
| 316 | for (const k of mat.locked) log.warn(`${tag}: secret ${k} is set but does not open (it must be entered again for the current value)`); |
| 317 | return { |
| 318 | revision: row.revision, |
| 319 | checksum: row.values_checksum, |
| 320 | stored, |
| 321 | values: stored, |
| 322 | opened: mat.opened, |
| 323 | locked: mat.locked, |
| 324 | envelopeMeta: mat.meta, |
| 325 | effective: deepFreeze(mat.effective), |
| 326 | classification, |
| 327 | }; |
| 328 | } |
| 329 | const effectiveNow = () => (current ? current.effective : emptyEffective); |
| 330 | |
| 331 | // ── validation ── |
| 332 | function problems(values) { |
| 333 | const errors = []; |
| 334 | if (!isPlainObject(values)) return [{ path: '', message: 'must be an object' }]; |
| 335 | for (const k of Object.keys(values)) if (!validKey(k)) errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }); |
| 336 | for (const [k, v] of Object.entries(values)) if (looksRedacted(v)) errors.push({ path: pointer(k), message: 'is an object with a redacted member, which only a redaction marker may be' }); |
| 337 | jsonErrors(values, '', errors); |
| 338 | if (errors.length) return errors; |
| 339 | const size = Buffer.byteLength(canonical(values)); |
| 340 | if (size > maxBytes) return [{ path: '', message: `is ${size} bytes; at most ${maxBytes}` }]; |
| 341 | const mat = materialize(values); |
| 342 | const skip = new Set([...mat.locked].map((k) => pointer(k))); |
| 343 | if (schema !== null) checkSchema(schema, mat.effective, '', errors, skip); |
| 344 | if (validate) { |
| 345 | let r; |
| 346 | try { r = validate(mat.effective); } catch (err) { r = [{ path: '', message: errText(err) }]; } |
| 347 | if (r && typeof r.then === 'function') throw new TypeError('createConfigStore: validate must be synchronous (check asynchronously in onActivate)'); |
| 348 | errors.push(...validateResult(r)); |
| 349 | } |
| 350 | const secrets = [...secretStrings(values, current && current.opened, current ? current.effective : emptyEffective), ...plainStrings(mat.opened)].sort((a, b) => b.length - a.length); |
| 351 | return errors.map((e) => ({ path: e.path, message: scrub(e.message, secrets) })); |
| 352 | } |
| 353 | const invalid = (errors, revision) => new ConfigError('config.invalid', `invalid configuration: ${summarize(errors)}`.slice(0, ERROR_MAX), { status: 422, errors, revision }); |
| 354 | |
| 355 | /** values (+ merge/unset) as the complete stored form a new revision would hold. A marker keeps the value it stands for. */ |
| 356 | function resolve(input, { merge = false, unset = [] } = {}) { |
| 357 | if (!isPlainObject(input)) throw invalid([{ path: '', message: 'must be an object' }]); |
| 358 | if (!Array.isArray(unset) || !unset.every((k) => typeof k === 'string')) throw invalid([{ path: '', message: 'unset must be a list of keys' }]); |
| 359 | for (const k of [...Object.keys(input), ...unset]) { |
| 360 | if (pinKeys.has(k)) throw new ConfigError('config.pinned_in_env', `${k} is pinned by ${pins[k].env} and is read-only`, { status: 409 }); |
| 361 | } |
| 362 | const base = merge && current ? clone(current.stored) : {}; |
| 363 | const errors = []; |
| 364 | for (const [k, v] of Object.entries(input)) { |
| 365 | if (!validKey(k)) { errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }); continue; } |
| 366 | if (!isMarker(v)) { base[k] = v; continue; } |
| 367 | if (current && has(current.stored, k) && sameHex(storedFingerprint(current.stored[k]), v.fingerprint)) base[k] = clone(current.stored[k]); |
| 368 | else if (has(defaults, k) && sameHex(fingerprint(defaults[k]), v.fingerprint)) delete base[k]; |
| 369 | else errors.push({ path: pointer(k), message: 'is a redacted value that is not the current one; send the value itself' }); |
| 370 | } |
| 371 | if (errors.length) throw invalid(errors); |
| 372 | for (const k of unset) delete base[k]; |
| 373 | return base; |
| 374 | } |
| 375 | |
| 376 | /** Seal the new plaintext secrets of a complete values object; envelopes already sealed are carried unchanged. */ |
| 377 | function sealValues(values) { |
| 378 | const classification = classesFor(values, current ? current.classification : {}); |
| 379 | const mat = materialize(values); |
| 380 | const out = {}; |
| 381 | for (const [k, v] of Object.entries(values)) { |
| 382 | if (classification[k] !== 'secret' || !keks || isEnvelope(v)) { out[k] = v; continue; } |
| 383 | const target = binds[k]; |
| 384 | let bindVal; |
| 385 | if (target) { |
| 386 | if (has(values, target) && isEnvelope(values[target])) throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 }); |
| 387 | if (pinKeys.has(target)) bindVal = pins[target].value; |
| 388 | else if (has(mat.effective, target)) bindVal = mat.effective[target]; |
| 389 | else if (has(defaults, target)) bindVal = defaults[target]; |
| 390 | if (bindVal !== undefined && classOfValue(target, bindVal) === 'secret') throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 }); |
| 391 | } |
| 392 | const bind = bindText(bindVal); |
| 393 | out[k] = sealEnvelope({ |
| 394 | service, namespace, key: k, value: v, bind, |
| 395 | fingerprint: fingerprint(v), hint: hintOf(v), kekId: keks.current, kek: keks.keys.get(keks.current), |
| 396 | }); |
| 397 | } |
| 398 | return { values: out, classification }; |
| 399 | } |
| 400 | |
| 401 | function changedKeys(before, after) { |
| 402 | const keys = new Set([...Object.keys(before), ...Object.keys(after)]); |
| 403 | return [...keys].filter((k) => has(before, k) !== has(after, k) || !same(before[k], after[k])).sort(); |
| 404 | } |
| 405 | const listKeys = (keys) => (keys.length ? keys.slice(0, 20).join(', ') + (keys.length > 20 ? ` (+${keys.length - 20})` : '') : 'nothing'); |
| 406 | const who = (a) => `${a.type}:${a.id}`; |
| 407 | |
| 408 | function actorOf(actor) { |
| 409 | if (actor == null) return self; |
| 410 | const ref = subjectRef(actor); |
| 411 | if (!ref) throw new ConfigError('config.bad_actor', 'actor must be a subject reference ({ type, id })', { status: 400 }); |
| 412 | return ref; |
| 413 | } |
| 414 | function reasonOf(reason) { |
| 415 | if (reason == null || reason === '') return null; |
| 416 | if (typeof reason !== 'string') throw invalid([{ path: '/reason', message: 'must be a string' }]); |
| 417 | return reason.slice(0, REASON_MAX); |
| 418 | } |
| 419 | |
| 420 | async function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) { |
| 421 | const sealed = sealValues(values); |
| 422 | const checksum = sha256(canonical(redact(sealed.values, sealed.classification))); |
| 423 | let row; |
| 424 | await tx(async () => { |
| 425 | const revision = (await q.max.get(namespace)).n + 1; |
| 426 | await q.insert.run({ |
| 427 | namespace, revision, service, previous_revision: previous, state, |
| 428 | values_json: JSON.stringify(sealed.values), classification_json: JSON.stringify(sealed.classification), |
| 429 | values_checksum: checksum, created_at: iso(), created_by: JSON.stringify(actor), |
| 430 | activated_at: activatedAt, activated_by: activatedAt ? JSON.stringify(actor) : null, |
| 431 | reason, copied_from: copiedFrom, good, |
| 432 | }); |
| 433 | row = await q.row.get(namespace, revision); |
| 434 | }); |
| 435 | return row; |
| 436 | } |
| 437 | |
| 438 | async function prune() { |
| 439 | const cutoff = (await q.max.get(namespace)).n - keep; |
| 440 | if (cutoff < 1) return; |
| 441 | const good = await q.lastGood.get(namespace); |
| 442 | await q.prune.run(namespace, cutoff, good ? good.revision : -1); |
| 443 | } |
| 444 | |
| 445 | // ── boot: load the active revision, recover an interrupted activation, or seed revision 1 ── |
| 446 | async function seed(values, reason, { strict }) { |
| 447 | if (!strict) { |
| 448 | // A legacy key the contract cannot carry is left behind, by name, rather than imported. |
| 449 | const bad = Object.keys(values).filter((k) => !validKey(k) || looksRedacted(values[k])); |
| 450 | if (bad.length) { |
| 451 | log.warn(`${tag}: ${bad.length} legacy key(s) are not valid config keys and were not imported: ${bad.slice(0, 5).map((k) => JSON.stringify(k.slice(0, 64))).join(', ')}`); |
| 452 | values = Object.fromEntries(Object.entries(values).filter(([k]) => validKey(k))); |
| 453 | } |
| 454 | } |
| 455 | const errors = problems(values); |
| 456 | if (errors.length && strict) throw new ConfigError('config.invalid_defaults', `${namespace} defaults do not validate: ${summarize(errors)}`, { status: 500, errors }); |
| 457 | if (errors.length) log.warn(`${tag}: the imported values do not validate (${summarize(errors).slice(0, 300)}); imported as they are`); |
| 458 | const row = await insertRow(clone(values), { state: 'active', actor: self, reason, previous: null, activatedAt: iso(), good: 1 }); |
| 459 | current = load(row); |
| 460 | log.info(`${tag}: revision ${row.revision} ${reason} (${Object.keys(values).length} keys)`); |
| 461 | return toSnapshot(row); |
| 462 | } |
| 463 | |
| 464 | async function importLegacy(legacy, { reason = 'imported from the legacy source' } = {}) { |
| 465 | if (await q.any.get(namespace)) return null; |
| 466 | let v = typeof legacy === 'function' ? legacy() : legacy; |
| 467 | if (Array.isArray(v)) v = fromRows(v); |
| 468 | if (v == null) return null; |
| 469 | if (!isPlainObject(v)) throw new TypeError('import: legacy must give an object or key-value rows'); |
| 470 | return await seed(v, reasonOf(reason) || 'imported from the legacy source', { strict: false }); |
| 471 | } |
| 472 | |
| 473 | async function boot() { |
| 474 | await loadKey(); |
| 475 | let row = await q.active.get(namespace); |
| 476 | if (row && !row.good) { |
| 477 | // The process stopped between the switch and the end of onActivate: that revision never |
| 478 | // proved itself, so the last-known-good one is active again. |
| 479 | const good = await q.lastGood.get(namespace); |
| 480 | await tx(async () => { |
| 481 | await q.reject.run('activation interrupted: the service stopped before onActivate finished', namespace, row.revision); |
| 482 | if (good) await q.setState.run('active', namespace, good.revision); |
| 483 | }); |
| 484 | log.warn(`${tag}: revision ${row.revision} was interrupted while activating; ${good ? `revision ${good.revision} restored` : 'no earlier revision to restore'}`); |
| 485 | row = good ? await q.row.get(namespace, good.revision) : null; |
| 486 | } |
| 487 | if (row) { |
| 488 | current = load(row); |
| 489 | const errors = problems(current.values); |
| 490 | if (errors.length) log.warn(`${tag}: active revision ${row.revision} does not validate under this release (${summarize(errors).slice(0, 300)}); serving it as it is`); |
| 491 | return; |
| 492 | } |
| 493 | if (await q.any.get(namespace)) return; |
| 494 | if (opts.legacy !== undefined && await importLegacy(opts.legacy)) return; |
| 495 | if (opts.defaults != null) await seed(defaults, 'seeded from defaults', { strict: true }); |
| 496 | } |
| 497 | |
| 498 | // ── activation (serialized per store) ── |
| 499 | let queue = Promise.resolve(); |
| 500 | function serialized(fn) { |
| 501 | const run = queue.then(fn, fn); |
| 502 | queue = run.catch(() => {}); |
| 503 | return run; |
| 504 | } |
| 505 | |
| 506 | async function notifyChange(keys, actor, revision) { |
| 507 | if (!onChange) return; |
| 508 | try { await onChange({ namespace, keys, actor, revision }); } |
| 509 | catch (err) { log.warn(`${tag}: onChange failed: ${scrub(errText(err), [...secretStrings(current && current.values, current && current.effective), ...plainStrings(current && current.opened)])}`); } |
| 510 | } |
| 511 | |
| 512 | async function activateNow(revision, { actor, rollingBack = false } = {}) { |
| 513 | const by = actorOf(actor); |
| 514 | const row = await q.row.get(namespace, revision); |
| 515 | if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${revision}`, { status: 404, revision }); |
| 516 | if (row.state !== 'proposed') throw new ConfigError('config.not_proposed', `revision ${revision} is ${row.state}, not proposed`, { status: 409, revision }); |
| 517 | const values = JSON.parse(row.values_json); |
| 518 | const errors = problems(values); |
| 519 | if (errors.length) { |
| 520 | await q.reject.run(summarize(errors).slice(0, ERROR_MAX), namespace, revision); |
| 521 | log.warn(`${tag}: revision ${revision} rejected: ${summarize(errors).slice(0, 300)}`); |
| 522 | throw invalid(errors, revision); |
| 523 | } |
| 524 | const prev = current; |
| 525 | const at = iso(); |
| 526 | await tx(async () => { |
| 527 | const dbActive = await q.active.get(namespace); |
| 528 | const activeRev = dbActive ? dbActive.revision : null; |
| 529 | if ((row.previous_revision == null ? null : row.previous_revision) !== activeRev) { |
| 530 | throw new ConfigError('config.stale', `revision ${revision} was proposed on ${row.previous_revision ? `revision ${row.previous_revision}` : 'an empty namespace'}, but ${activeRev ? `revision ${activeRev}` : 'nothing'} is active now; propose it again`, { status: 409, revision }); |
| 531 | } |
| 532 | if (dbActive) await q.setState.run(rollingBack ? 'rolled_back' : 'superseded', namespace, dbActive.revision); |
| 533 | await q.activate.run(at, JSON.stringify(by), onActivate ? 0 : 1, namespace, revision); |
| 534 | }); |
| 535 | current = load(await q.row.get(namespace, revision)); |
| 536 | const changed = changedKeys(prev ? prev.effective : emptyEffective, current.effective); |
| 537 | if (onActivate) { |
| 538 | const next = current; |
| 539 | try { |
| 540 | await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false }); |
| 541 | } catch (err) { |
| 542 | const message = scrub(`onActivate: ${errText(err)}`, [...secretStrings(next.values, prev && prev.values, next.effective, prev && prev.effective), ...plainStrings(next.opened, prev && prev.opened)]); |
| 543 | await tx(async () => { |
| 544 | await q.reject.run(message, namespace, revision); |
| 545 | if (prev) await q.setState.run('active', namespace, prev.revision); |
| 546 | }); |
| 547 | current = prev; |
| 548 | log.warn(`${tag}: revision ${revision} rejected (${message}); ${prev ? `revision ${prev.revision} restored` : 'nothing was active before'}`); |
| 549 | if (prev) { |
| 550 | try { |
| 551 | await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true }); |
| 552 | } catch (again) { |
| 553 | log.error(`${tag}: re-applying revision ${prev.revision} failed too: ${scrub(errText(again), [...secretStrings(next.values, prev.values, next.effective, prev.effective), ...plainStrings(next.opened, prev.opened)])}`); |
| 554 | } |
| 555 | } |
| 556 | throw new ConfigError('config.activation_failed', message, { status: 422, revision, cause: err }); |
| 557 | } |
| 558 | await q.good.run(namespace, revision); |
| 559 | } |
| 560 | await notifyChange(changed, by, revision); |
| 561 | await prune(); |
| 562 | log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`); |
| 563 | return toSnapshot(await q.row.get(namespace, revision)); |
| 564 | } |
| 565 | |
| 566 | async function propose(values, o = {}) { |
| 567 | const actor = actorOf(o.actor); |
| 568 | const reason = reasonOf(o.reason); |
| 569 | const next = resolve(values, o); |
| 570 | const errors = problems(next); |
| 571 | if (errors.length) throw invalid(errors); |
| 572 | const row = await insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null }); |
| 573 | await prune(); |
| 574 | log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), materialize(next).effective))}`); |
| 575 | return toSnapshot(row); |
| 576 | } |
| 577 | |
| 578 | async function rollbackTarget(to) { |
| 579 | if (!current) throw new ConfigError('config.nothing_active', `${namespace} has no active revision to roll back`, { status: 409 }); |
| 580 | if (to == null) { |
| 581 | const r = await q.previousGood.get(namespace, current.revision, current.checksum); |
| 582 | if (!r) throw new ConfigError('config.no_previous', `${namespace} has no earlier good revision to roll back to`, { status: 409 }); |
| 583 | return r.revision; |
| 584 | } |
| 585 | const rev = Number(to); |
| 586 | const row = Number.isInteger(rev) ? await q.row.get(namespace, rev) : null; |
| 587 | if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${to}`, { status: 404, revision: Number.isInteger(rev) ? rev : null }); |
| 588 | if (row.revision === current.revision) throw new ConfigError('config.already_active', `revision ${rev} is the active one`, { status: 409, revision: rev }); |
| 589 | if (!row.good) throw new ConfigError('config.not_good', `revision ${rev} never activated successfully (${row.state})`, { status: 409, revision: rev }); |
| 590 | return rev; |
| 591 | } |
| 592 | |
| 593 | /** The marker of one secret-class key: the active revision's row, or an env pin / default that no row holds. */ |
| 594 | async function secretMarker(key) { |
| 595 | const row = await q.active.get(namespace); |
| 596 | const values = row ? JSON.parse(row.values_json) : {}; |
| 597 | let value; |
| 598 | let fromRow = false; |
| 599 | if (pinKeys.has(key)) value = pins[key].value; |
| 600 | else if (has(values, key)) { value = values[key]; fromRow = true; } |
| 601 | else if (has(defaults, key)) value = defaults[key]; |
| 602 | else return null; |
| 603 | const classification = fromRow ? classesFor(values, JSON.parse(row.classification_json)) : { [key]: classOfValue(key, value) }; |
| 604 | if (classification[key] !== 'secret') return null; |
| 605 | const envelope = isEnvelope(value) ? parseEnvelope(value) : null; |
| 606 | const readable = !envelope && current && has(current.effective, key) ? current.effective[key] : undefined; |
| 607 | const plain = readable !== undefined ? readable : value; |
| 608 | const fp = envelope ? envelope.fingerprint : fingerprint(plain); |
| 609 | let boundTo = envelope ? envelope.bind : null; |
| 610 | if (!envelope) { |
| 611 | const target = binds[key]; |
| 612 | const mat = materialize(values); |
| 613 | const v = target && has(mat.effective, target) ? mat.effective[target] : undefined; |
| 614 | boundTo = target && !(v !== undefined && classOfValue(target, v) === 'secret') ? bindText(v) : ''; |
| 615 | } |
| 616 | const activatedAt = fromRow ? (row.activated_at || row.created_at) : null; |
| 617 | const setBy = fromRow ? (row.activated_by ? JSON.parse(row.activated_by) : JSON.parse(row.created_by)) : null; |
| 618 | return { |
| 619 | redacted: true, |
| 620 | fingerprint: fp, |
| 621 | hint: envelope ? (envelope.hint || null) : hintOf(plain), |
| 622 | set_at: activatedAt || null, |
| 623 | set_by: setBy, |
| 624 | bound_to: boundTo || null, |
| 625 | }; |
| 626 | } |
| 627 | |
| 628 | async function resealNow() { |
| 629 | if (!keks) throw new ConfigError('config.no_kek', 'reseal needs a KEK (opts.keks or OV_CONFIG_KEKS)', { status: 409 }); |
| 630 | const rows = await q.all.all(namespace); |
| 631 | const report = { namespace, kek: keks.current, revisions: rows.length, resealed: 0, current: 0, plaintext: 0, locked: 0, errors: [] }; |
| 632 | const updates = []; |
| 633 | for (const row of rows) { |
| 634 | const values = JSON.parse(row.values_json); |
| 635 | const storedCls = JSON.parse(row.classification_json); |
| 636 | const cls = classesFor(values, storedCls); |
| 637 | const mat = materialize(values); |
| 638 | const next = {}; |
| 639 | let changed = false; |
| 640 | for (const [k, v] of Object.entries(values)) { |
| 641 | if (cls[k] !== 'secret') { next[k] = v; continue; } |
| 642 | try { |
| 643 | const target = binds[k]; |
| 644 | const bindVal = target ? (pinKeys.has(target) ? pins[target].value : (has(mat.effective, target) ? mat.effective[target] : undefined)) : undefined; |
| 645 | if (target && has(values, target) && isEnvelope(values[target])) throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 }); |
| 646 | if (target && bindVal !== undefined && classOfValue(target, bindVal) === 'secret') throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 }); |
| 647 | const bind = bindText(bindVal); |
| 648 | if (isEnvelope(v)) { |
| 649 | const p = parseEnvelope(v); |
| 650 | const { value } = openEnvelope(v, { service, namespace, key: k, bind, keys: keks.keys }); |
| 651 | const fpNow = fingerprint(value); |
| 652 | if (p.kekId === keks.current && fpNow === p.fingerprint) { next[k] = v; report.current++; continue; } |
| 653 | next[k] = sealEnvelope({ |
| 654 | service, namespace, key: k, value, bind, |
| 655 | fingerprint: fpNow, hint: p.hint || hintOf(value), |
| 656 | kekId: keks.current, kek: keks.keys.get(keks.current), |
| 657 | }); |
| 658 | changed = true; |
| 659 | report.resealed++; |
| 660 | } else { |
| 661 | next[k] = sealEnvelope({ |
| 662 | service, namespace, key: k, value: v, bind, |
| 663 | fingerprint: fingerprint(v), hint: hintOf(v), kekId: keks.current, kek: keks.keys.get(keks.current), |
| 664 | }); |
| 665 | changed = true; |
| 666 | report.plaintext++; |
| 667 | } |
| 668 | } catch (err) { |
| 669 | next[k] = v; |
| 670 | report.locked++; |
| 671 | report.errors.push({ revision: row.revision, key: k, reason: scrub(errText(err)) }); |
| 672 | } |
| 673 | } |
| 674 | const nextJson = JSON.stringify(next); |
| 675 | const clsJson = JSON.stringify(cls); |
| 676 | if (changed || nextJson !== row.values_json || clsJson !== row.classification_json) { |
| 677 | updates.push({ revision: row.revision, values_json: nextJson, classification_json: clsJson, values_checksum: sha256(canonical(redact(next, cls))) }); |
| 678 | } |
| 679 | } |
| 680 | if (updates.length) { |
| 681 | await tx(async () => { |
| 682 | for (const u of updates) await q.reseal.run(u.values_json, u.classification_json, u.values_checksum, namespace, u.revision); |
| 683 | }); |
| 684 | } |
| 685 | if (current) current = load(await q.row.get(namespace, current.revision)); |
| 686 | if (report.locked) log.warn(`${tag}: reseal left ${report.locked} secret(s) locked (${report.errors.slice(0, 3).map((e) => `${e.key}@${e.revision}`).join(', ')})`); |
| 687 | log.info(`${tag}: reseal to ${keks.current}: ${report.resealed} under an old KEK, ${report.plaintext} plaintext, ${report.current} current, ${report.locked} locked`); |
| 688 | return report; |
| 689 | } |
| 690 | |
| 691 | /** The summary's schema: a clone whose secret-class properties never carry a default, examples, enum or const. */ |
| 692 | function schemaForSummary() { |
| 693 | const isSecret = (k) => { |
| 694 | if (current && current.classification[k] === 'secret') return true; |
| 695 | if (pinKeys.has(k) && classOf(k, pins[k].value) === 'secret') return true; |
| 696 | if (classOf(k, undefined) === 'secret') return true; |
| 697 | const p = schema && schema.properties && schema.properties[k]; |
| 698 | return !!(isPlainObject(p) && has(p, 'default') && classOf(k, p.default) === 'secret'); |
| 699 | }; |
| 700 | return summarySchema(schema, isSecret); |
| 701 | } |
| 702 | |
| 703 | const store = { |
| 704 | service, |
| 705 | namespace, |
| 706 | /** Whether secret-class values are sealed with a KEK (D11). */ |
| 707 | get sealed() { return !!keks; }, |
| 708 | /** The current KEK id, or null when unsealed. */ |
| 709 | get kek() { return keks ? keks.current : null; }, |
| 710 | /** The active values over the defaults and env pins (frozen), or one of them. Never reads the database. */ |
| 711 | get(key) { const e = effectiveNow(); return key === undefined ? e : e[key]; }, |
| 712 | /** The active revision number, or null. */ |
| 713 | revision() { return current ? current.revision : null; }, |
| 714 | propose, |
| 715 | activate(revision, o = {}) { return serialized(async () => await activateNow(Number(revision), { actor: o.actor })); }, |
| 716 | /** propose + activate, in turn with every other activation of this store. */ |
| 717 | apply(values, o = {}) { return serialized(async () => await activateNow((await propose(values, o)).revision, { actor: o.actor })); }, |
| 718 | /** A new revision copying the previous good one (or `to`), activated; the one it replaces becomes rolled_back. */ |
| 719 | rollback(o = {}) { |
| 720 | return serialized(async () => { |
| 721 | const actor = actorOf(o.actor); |
| 722 | const target = await rollbackTarget(o.to); |
| 723 | const values = JSON.parse((await q.row.get(namespace, target)).values_json); |
| 724 | const row = await insertRow(values, { state: 'proposed', actor, reason: reasonOf(o.reason) || `rollback to revision ${target}`, previous: current.revision, copiedFrom: target }); |
| 725 | log.info(`${tag}: revision ${row.revision} rolls back to revision ${target} (by ${who(actor)})`); |
| 726 | return await activateNow(row.revision, { actor, rollingBack: true }); |
| 727 | }); |
| 728 | }, |
| 729 | rollbackTarget, |
| 730 | async lastKnownGood() { const r = await q.lastGood.get(namespace); return r ? toSnapshot(await q.row.get(namespace, r.revision)) : null; }, |
| 731 | async snapshot(revision) { const row = await q.row.get(namespace, Number(revision)); return row ? toSnapshot(row) : null; }, |
| 732 | async history({ limit = 20, before = null } = {}) { |
| 733 | const n = Math.min(200, Math.max(1, Math.floor(Number(limit)) || 20)); |
| 734 | const b = before == null || before === '' ? Number.MAX_SAFE_INTEGER : Math.floor(Number(before)); |
| 735 | return (await q.history.all(namespace, Number.isFinite(b) ? b : Number.MAX_SAFE_INTEGER, n)).map(toSnapshot); |
| 736 | }, |
| 737 | /** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */ |
| 738 | async changes(values, o = {}) { |
| 739 | if (o.revision != null) { |
| 740 | const row = await q.row.get(namespace, Number(o.revision)); |
| 741 | const stored = row ? JSON.parse(row.values_json) : {}; |
| 742 | return changedKeys(effectiveNow(), materialize(stored).effective); |
| 743 | } |
| 744 | const next = resolve(values, o); |
| 745 | return changedKeys(effectiveNow(), materialize(next).effective); |
| 746 | }, |
| 747 | /** The classification a change would give its keys (the active one for keys it removes). */ |
| 748 | async classes(values, o = {}) { |
| 749 | let next; |
| 750 | if (o.revision != null) { |
| 751 | const row = await q.row.get(namespace, Number(o.revision)); |
| 752 | next = row ? JSON.parse(row.values_json) : {}; |
| 753 | } else next = resolve(values, o); |
| 754 | const out = classesFor(next, current ? current.classification : {}); |
| 755 | if (current) for (const k of Object.keys(current.classification)) if (!has(out, k)) out[k] = current.classification[k]; |
| 756 | return out; |
| 757 | }, |
| 758 | isDanger(key) { return dangerKeys.has(key); }, |
| 759 | isPinned(key) { return pinKeys.has(key); }, |
| 760 | /** The classification policy for one value (an envelope is secret). */ |
| 761 | classify(key, value) { return classOfValue(key, value); }, |
| 762 | /** The marker of one secret-class key of the active revision, or null. */ |
| 763 | secretMarker, |
| 764 | /** Write one secret through the write-only path: a new revision holding its envelope. Returns the marker. */ |
| 765 | setSecret(key, value, o = {}) { |
| 766 | return serialized(async () => { |
| 767 | if (typeof key !== 'string' || !validKey(key)) throw invalid([{ path: pointer(key), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }]); |
| 768 | if (pinKeys.has(key)) throw new ConfigError('config.pinned_in_env', `${key} is pinned by ${pins[key].env} and is read-only`, { status: 409 }); |
| 769 | if (classOfValue(key, value) !== 'secret') throw new ConfigError('config.not_secret', `${key} is not a secret-class key`, { status: 422 }); |
| 770 | if (value === undefined) throw invalid([{ path: pointer(key), message: 'is required' }]); |
| 771 | if (looksRedacted(value)) throw invalid([{ path: pointer(key), message: 'a secret value cannot be a redaction marker' }]); |
| 772 | const actor = actorOf(o.actor); |
| 773 | const next = current ? clone(current.stored) : {}; |
| 774 | next[key] = value; |
| 775 | const errors = problems(next); |
| 776 | if (errors.length) throw invalid(errors); |
| 777 | const row = await insertRow(next, { state: 'proposed', actor, reason: reasonOf(o.reason) || 'secret set', previous: current ? current.revision : null }); |
| 778 | await activateNow(row.revision, { actor }); |
| 779 | return await secretMarker(key); |
| 780 | }); |
| 781 | }, |
| 782 | /** Remove one secret through the write-only path. Returns the marker of the removed (empty) state. */ |
| 783 | deleteSecret(key, o = {}) { |
| 784 | return serialized(async () => { |
| 785 | if (typeof key !== 'string' || !validKey(key)) throw invalid([{ path: pointer(key), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }]); |
| 786 | if (pinKeys.has(key)) throw new ConfigError('config.pinned_in_env', `${key} is pinned by ${pins[key].env} and is read-only`, { status: 409 }); |
| 787 | if (!current || !has(current.stored, key)) throw new ConfigError('config.secret_not_set', `${key} is not set`, { status: 404 }); |
| 788 | if (current.classification[key] !== 'secret') throw new ConfigError('config.not_secret', `${key} is not a secret-class key`, { status: 422 }); |
| 789 | const actor = actorOf(o.actor); |
| 790 | const next = clone(current.stored); |
| 791 | delete next[key]; |
| 792 | const errors = problems(next); |
| 793 | if (errors.length) throw invalid(errors); |
| 794 | const row = await insertRow(next, { state: 'proposed', actor, reason: reasonOf(o.reason) || 'secret deleted', previous: current.revision }); |
| 795 | await activateNow(row.revision, { actor }); |
| 796 | return { redacted: true, fingerprint: null, hint: null, set_at: null, set_by: actor, bound_to: null }; |
| 797 | }); |
| 798 | }, |
| 799 | /** Move every plaintext secret and every envelope under an older KEK to the current KEK, revision by revision. */ |
| 800 | reseal() { return serialized(resealNow); }, |
| 801 | /** What /api/admin/config shows: the active revision, its effective values redacted, the schema, pins and state. */ |
| 802 | async summary() { |
| 803 | const values = {}; |
| 804 | for (const [k, v] of Object.entries(current ? current.stored : {})) values[k] = clone(v); |
| 805 | for (const k of Object.keys(defaults)) if (!has(values, k)) values[k] = clone(defaults[k]); |
| 806 | for (const [k, p] of Object.entries(pins)) values[k] = clone(p.value); |
| 807 | const classification = classesFor(values, current ? current.classification : {}); |
| 808 | const shown = redact(values, classification); |
| 809 | // Every property the schema names is classified too, set or not (4.125.0): a form then knows a key is a |
| 810 | // secret before anyone has entered it, and offers the write-only route instead of a plain field. |
| 811 | if (schema && isPlainObject(schema.properties)) { |
| 812 | for (const k of Object.keys(schema.properties)) if (!has(classification, k)) classification[k] = classOf(k, undefined); |
| 813 | } |
| 814 | const secrets = {}; |
| 815 | for (const k of Object.keys(classification)) { |
| 816 | if (classification[k] !== 'secret' || !has(values, k)) continue; |
| 817 | const m = await store.secretMarker(k); |
| 818 | if (m) secrets[k] = m; |
| 819 | } |
| 820 | return { |
| 821 | service, namespace, |
| 822 | revision: current ? current.revision : null, |
| 823 | values: shown, |
| 824 | classification, |
| 825 | sealed: !!keks, |
| 826 | kek: keks ? keks.current : null, |
| 827 | pins: Object.fromEntries(Object.entries(pins).map(([k, p]) => [k, { env: p.env, pinned: true }])), |
| 828 | secrets, |
| 829 | schema: schemaForSummary(), |
| 830 | active: current ? await store.snapshot(current.revision) : null, |
| 831 | last_known_good: await store.lastKnownGood(), |
| 832 | }; |
| 833 | }, |
| 834 | /** Seed revision 1 from a legacy source when the namespace has no revisions yet (else null). */ |
| 835 | import: importLegacy, |
| 836 | /** Re-read the active revision (another process changed it). onActivate is not run. */ |
| 837 | async reload() { |
| 838 | const row = await q.active.get(namespace); |
| 839 | current = row ? load(row) : null; |
| 840 | return store.revision(); |
| 841 | }, |
| 842 | }; |
| 843 | await boot(); |
| 844 | return store; |
| 845 | } |
| 846 | |
| 847 | // ── /api/admin/config ─────────────────────────────────────────── |
| 848 | |
| 849 | const TITLES = { 400: 'Bad Request', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 413: 'Payload Too Large', 422: 'Unprocessable Content', 500: 'Internal Server Error' }; |
| 850 | |
| 851 | module.exports = { createPgConfigStore, configSchema }; |
| 852 |