Source: database/repositories/workspaceSiemConfigRepo.js

/**
 * @module database/repositories/workspaceSiemConfigRepo
 * @description SEC-007 Part C — per-workspace SIEM forwarder configuration.
 *
 * Stores the admin-configured webhook target (URL, HMAC secret, optional
 * custom headers) the audit-log forwarder uses to push every event to a
 * customer's SIEM. Schema defined by migration 032.
 *
 * ### Encryption-at-rest
 * The `hmacSecret` column is encrypted via `credentialEncryption.js`
 * (AES-256-GCM) before persist and decrypted lazily on read. The
 * encryption key is the same one used for AI provider keys and TOTP
 * secrets — derived from `JWT_SECRET` (or `CREDENTIAL_ENCRYPTION_KEY`
 * if explicitly set).
 *
 * ### Read API conventions
 * - `getDecrypted(workspaceId)` — full row with `hmacSecret` decrypted.
 *   ONLY for the forwarder to dispatch events. NEVER return this to a
 *   client.
 * - `getMasked(workspaceId)` — same shape but `hmacSecret` replaced with
 *   `"••••••••<last4>"`. Use for `GET /siem-config` admin responses.
 */

import { getDatabase } from "../sqlite.js";
import { encryptString, decryptString } from "../../utils/credentialEncryption.js";

/**
 * Mask an HMAC secret for client display — keeps the last 4 characters
 * visible so admins can confirm which secret is configured without
 * exposing the value.
 *
 * @param {string|null} secret
 * @returns {string|null}
 * @private
 */
function maskSecret(secret) {
  if (!secret) return null;
  if (secret.length <= 4) return "••••";
  return "••••••••" + secret.slice(-4);
}

/**
 * Upsert the SIEM config for a workspace. The HMAC secret is encrypted
 * before persist so even a DB-dump leak doesn't yield usable secrets.
 *
 * ### `hmacSecret` semantics
 * - **Insert path** (no existing row): `hmacSecret` is required — the
 *   caller MUST pass a non-empty string. Callers should validate length
 *   upstream (PUT route enforces ≥ 32 chars per NIST SP 800-107).
 * - **Update path** (row exists): `hmacSecret` is OPTIONAL. Pass it to
 *   rotate the secret; omit (`undefined`) or pass the empty string to
 *   keep the existing encrypted secret unchanged. Admins editing just
 *   the `targetUrl` / `enabled` / `headers` should not be forced to
 *   re-enter the masked secret they can't see.
 *
 * @param {string} workspaceId
 * @param {Object} cfg
 * @param {string} cfg.targetUrl - Validated by the route handler via SSRF guard.
 * @param {string} [cfg.hmacSecret] - Plaintext. Required on insert, optional on update.
 * @param {Object|null} [cfg.headers] - Optional headers object.
 * @param {boolean} [cfg.enabled=true]
 * @returns {Object} The persisted row (without the plaintext secret).
 * @throws {Error} When inserting a new row and `hmacSecret` is missing/empty.
 */
export function upsert(workspaceId, { targetUrl, hmacSecret, headers = null, enabled = true }) {
  const db = getDatabase();
  const now = new Date().toISOString();
  const headersJson = headers ? JSON.stringify(headers) : null;

  // INSERT OR REPLACE is the cross-dialect way to upsert with this
  // module's translator (postgres-adapter rewrites to ON CONFLICT
  // DO UPDATE — see backend/src/database/adapters/postgres-adapter.js).
  // Preserves `createdAt` AND the existing encrypted secret on update
  // (when the caller omits `hmacSecret`) by reading the existing row first.
  const existing = db.prepare(
    "SELECT createdAt, hmacSecret FROM workspace_siem_config WHERE workspaceId = ?"
  ).get(workspaceId);
  const createdAt = existing?.createdAt || now;

  // Decide which encrypted blob to write:
  //   1. Caller provided a non-empty hmacSecret → rotate (encrypt fresh).
  //   2. Caller omitted on update → reuse the existing encrypted column.
  //   3. Caller omitted on insert → reject (no secret to reuse).
  let encryptedSecret;
  if (typeof hmacSecret === "string" && hmacSecret.length > 0) {
    encryptedSecret = encryptString(hmacSecret);
  } else if (existing) {
    encryptedSecret = existing.hmacSecret;
  } else {
    throw new Error("hmacSecret is required when creating a new SIEM config");
  }

  db.prepare(`
    INSERT OR REPLACE INTO workspace_siem_config
      (workspaceId, targetUrl, hmacSecret, headersJson, enabled, createdAt, updatedAt)
    VALUES (?, ?, ?, ?, ?, ?, ?)
  `).run(workspaceId, targetUrl, encryptedSecret, headersJson, enabled ? 1 : 0, createdAt, now);

  // Return the masked form of whatever secret is now persisted — either the
  // newly rotated plaintext or the prior encrypted blob's decrypted preview.
  const maskedSource = (typeof hmacSecret === "string" && hmacSecret.length > 0)
    ? hmacSecret
    : decryptString(encryptedSecret);

  return {
    workspaceId, targetUrl, headers, enabled, createdAt, updatedAt: now,
    hmacSecret: maskSecret(maskedSource),
  };
}

/**
 * Return the masked config for client display (admin Settings panel).
 * `hmacSecret` is masked; the raw value is NEVER exposed.
 *
 * @param {string} workspaceId
 * @returns {Object|null}
 */
export function getMasked(workspaceId) {
  const db = getDatabase();
  const row = db.prepare("SELECT * FROM workspace_siem_config WHERE workspaceId = ?").get(workspaceId);
  if (!row) return null;
  return {
    workspaceId: row.workspaceId,
    targetUrl: row.targetUrl,
    hmacSecret: maskSecret(decryptString(row.hmacSecret)),
    headers: row.headersJson ? safeParseJson(row.headersJson) : null,
    enabled: row.enabled === 1,
    createdAt: row.createdAt,
    updatedAt: row.updatedAt,
  };
}

/**
 * Return the decrypted config for the forwarder.
 *
 * SECURITY: the returned object contains the PLAINTEXT `hmacSecret`.
 * Callers MUST NOT log it, return it in HTTP responses, or persist it
 * back to disk. Only the in-process forwarder should ever call this.
 *
 * @param {string} workspaceId
 * @returns {Object|null}
 */
export function getDecrypted(workspaceId) {
  const db = getDatabase();
  const row = db.prepare("SELECT * FROM workspace_siem_config WHERE workspaceId = ?").get(workspaceId);
  if (!row) return null;
  return {
    workspaceId: row.workspaceId,
    targetUrl: row.targetUrl,
    hmacSecret: decryptString(row.hmacSecret),
    headers: row.headersJson ? safeParseJson(row.headersJson) : null,
    enabled: row.enabled === 1,
    createdAt: row.createdAt,
    updatedAt: row.updatedAt,
  };
}

/**
 * Delete the SIEM config for a workspace. Returns true if a row was
 * removed, false when none existed (idempotent).
 *
 * @param {string} workspaceId
 * @returns {boolean}
 */
export function remove(workspaceId) {
  const db = getDatabase();
  const info = db.prepare("DELETE FROM workspace_siem_config WHERE workspaceId = ?").run(workspaceId);
  return info.changes > 0;
}

/**
 * Tolerant JSON parser — corrupted `headersJson` shouldn't break the
 * forwarder or admin UI. Returns null on parse failure.
 *
 * @param {string} s
 * @returns {Object|null}
 * @private
 */
function safeParseJson(s) {
  try { return JSON.parse(s); } catch { return null; }
}