diff --git a/README.md b/README.md index 8d9605d..c9d420a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ The first account created becomes **admin**. Later accounts are created from Use | Variable | Default | Notes | |---|---|---| | `SCRUNNER_JWT_SECRET` | `scrunner-dev-secret` (dev only) | **Required in production** | +| `SCRUNNER_SECRETS_KEY` | `scrunner-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. | | `SCRUNNER_DB_PATH` | `packages/server/data/scrunner.db` | SQLite file | | `SCRUNNER_LOG_LEVEL` | `debug` | Pino level | | `SCRUNNER_RETENTION_DAYS` | `30` | Run history prune | @@ -47,7 +48,7 @@ The first account created becomes **admin**. Later accounts are created from Use ```bash pnpm install pnpm build -SCRUNNER_JWT_SECRET=... NODE_ENV=production pnpm start +SCRUNNER_JWT_SECRET=... SCRUNNER_SECRETS_KEY=... NODE_ENV=production pnpm start ``` The server serves `packages/web/dist` when that folder exists. diff --git a/packages/server/logger.js b/packages/server/logger.js index 01a4ee5..0c19b24 100644 --- a/packages/server/logger.js +++ b/packages/server/logger.js @@ -3,6 +3,18 @@ import path from "path"; import pino from "pino"; import * as store from "./store.js"; import { LOGS_DIR } from "./paths.js"; +import { redactString } from "./secret-value.js"; + +/** + * @param {{ write: (line: string) => unknown }} dest + */ +function redactStream(dest) { + return { + write(line) { + dest.write(redactString(typeof line === "string" ? line : String(line))); + }, + }; +} const LEVEL_TO_NUM = { trace: 10, @@ -27,7 +39,7 @@ let timer = null; function enqueueLine(line) { let record; try { - record = JSON.parse(line); + record = JSON.parse(redactString(line)); } catch { return; } @@ -103,9 +115,9 @@ const rollingFile = pino.transport({ export const log = pino( { level: process.env.SCRUNNER_LOG_LEVEL ?? "debug" }, pino.multistream([ - { level: "debug", stream: process.stdout }, - { level: "debug", stream: rollingFile }, - { level: "debug", stream: sqliteStream }, + { level: "debug", stream: redactStream(process.stdout) }, + { level: "debug", stream: redactStream(rollingFile) }, + { level: "debug", stream: redactStream(sqliteStream) }, ]), ); diff --git a/packages/server/migrations/20260814200000_secrets.js b/packages/server/migrations/20260814200000_secrets.js new file mode 100644 index 0000000..0bd57f5 --- /dev/null +++ b/packages/server/migrations/20260814200000_secrets.js @@ -0,0 +1,27 @@ +/** + * @param {import("knex").Knex} knex + */ +export async function up(knex) { + await knex.schema.createTable("secrets", (t) => { + t.text("id").primary(); + t.text("owner").notNullable(); + t.text("name").notNullable(); + t.text("ciphertext").notNullable(); + t.text("iv").notNullable(); + t.text("auth_tag").notNullable(); + t.text("created_at").notNullable(); + t.text("updated_at").notNullable(); + t.unique(["owner", "name"]); + }); + + await knex.schema.raw( + "CREATE INDEX secrets_owner_name_idx ON secrets (owner, name)", + ); +} + +/** + * @param {import("knex").Knex} knex + */ +export async function down(knex) { + await knex.schema.dropTableIfExists("secrets"); +} diff --git a/packages/server/registry.js b/packages/server/registry.js index 8f9cc5b..a5d89ec 100644 --- a/packages/server/registry.js +++ b/packages/server/registry.js @@ -207,12 +207,13 @@ export function createRegistry(server) { * @param {string} runId * @param {import("pino").Logger} runLog * @param {string} key + * @param {string} owner */ - async function runLinearSteps(compiled, ctx, runId, runLog, key) { + async function runLinearSteps(compiled, ctx, runId, runLog, key, owner) { let next = ctx; for (const index of compiled.order) { const parsed = compiled.steps[index]; - next = await runCompiledStep(parsed, next, index, runId, runLog, key); + next = await runCompiledStep(parsed, next, index, runId, runLog, key, owner); } return next; } @@ -223,8 +224,9 @@ export function createRegistry(server) { * @param {string} runId * @param {import("pino").Logger} runLog * @param {string} key + * @param {string} owner */ - async function runDagSteps(compiled, ctx, runId, runLog, key) { + async function runDagSteps(compiled, ctx, runId, runLog, key, owner) { const triggerData = ctx.data; /** @type {Map} */ const outputsById = new Map(); @@ -240,6 +242,7 @@ export function createRegistry(server) { runId, runLog, key, + owner, ); if (parsed.id) { outputsById.set(parsed.id, last); @@ -255,8 +258,9 @@ export function createRegistry(server) { * @param {string} runId * @param {import("pino").Logger} runLog * @param {string} key + * @param {string} owner */ - async function runCompiledStep(parsed, ctx, index, runId, runLog, key) { + async function runCompiledStep(parsed, ctx, index, runId, runLog, key, owner) { const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script; const config = parsed.config; const step = await store.startStep({ @@ -287,6 +291,7 @@ export function createRegistry(server) { const result = await runScript(script, { ...ctx, config }, { log: stepLog, workflowName: key, + owner, }); await store.finishStep(step.id, "success", result); return result; @@ -327,9 +332,9 @@ export function createRegistry(server) { try { const compiled = compileWorkflowScripts(workflow.scripts); if (compiled.dagMode) { - ctx = await runDagSteps(compiled, ctx, run.id, runLog, key); + ctx = await runDagSteps(compiled, ctx, run.id, runLog, key, owner); } else { - ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key); + ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key, owner); } await store.finishRun(run.id, "success", ctx); return { runId: run.id, status: "success", result: ctx }; diff --git a/packages/server/runner.js b/packages/server/runner.js index 541d89e..77fb5cc 100644 --- a/packages/server/runner.js +++ b/packages/server/runner.js @@ -15,7 +15,9 @@ import scriptsPluginFactory from "./src/api/scripts.js"; import workflowsPluginFactory from "./src/api/workflows.js"; import runsPlugin from "./src/api/runs.js"; import dashboardPluginFactory from "./src/api/dashboard.js"; +import secretsPlugin from "./src/api/secrets.js"; import { WEB_DIST } from "./paths.js"; +import { resolveSecretsKeyMaterial } from "./secrets.js"; await migrate(); enableLogPersistence(); @@ -29,6 +31,13 @@ if (!jwtSecret) { process.exit(1); } +try { + resolveSecretsKeyMaterial(); +} catch (err) { + log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +} + const server = fastify({ loggerInstance: log }); await server.register(cookie); @@ -78,6 +87,7 @@ await server.register( }); await api.register(authPlugin); await api.register(usersPlugin); + await api.register(secretsPlugin); await api.register(scriptsPluginFactory(registry)); await api.register(workflowsPluginFactory(registry)); await api.register(runsPlugin); diff --git a/packages/server/script-sandbox.js b/packages/server/script-sandbox.js index f9535c3..557943e 100644 --- a/packages/server/script-sandbox.js +++ b/packages/server/script-sandbox.js @@ -6,6 +6,8 @@ import axios from "axios"; import pino from "pino"; import { createKvApi } from "./kv-store.js"; import { SCRIPTS_DIR } from "./paths.js"; +import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js"; +import { getSecretPlaintext } from "./secrets-store.js"; const hostRequire = createRequire(import.meta.url); @@ -256,7 +258,26 @@ function createScreenedAxios(log) { screenRequestUrl(url, log); return config; }); - return instance; + + for (const method of [ + "request", + "get", + "delete", + "head", + "options", + "post", + "put", + "patch", + ]) { + const orig = instance[method].bind(instance); + instance[method] = (...args) => orig(...unwrapSecretsDeep(args)); + } + + return new Proxy(instance, { + apply(_target, _thisArg, args) { + return instance.request(...args); + }, + }); } function createRestrictedRequire(screenedAxios) { @@ -272,18 +293,45 @@ function createRestrictedRequire(screenedAxios) { } /** - * @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts + * @param {string} owner */ -function createScriptSandbox({ log, script, workflowName }) { +function createSecretsApi(owner) { + return { + /** + * @param {string} name + */ + async get(name) { + const value = await getSecretPlaintext(owner, name); + if (value == null) { + throw new Error(`secret "${name}" not found`); + } + return new Secret(value); + }, + /** + * @param {unknown} value + */ + reveal(value) { + if (isSecret(value)) return value.reveal(); + throw new Error("reveal() expects a Secret from $secrets.get()"); + }, + }; +} + +/** + * @param {{ log: import("pino").Logger, script: string, workflowName: string, owner?: string }} opts + */ +function createScriptSandbox({ log, script, workflowName, owner = "default" }) { const scriptLog = log.child({ workflow: workflowName, script }); const $axios = createScreenedAxios(scriptLog); const $kv = createKvApi(workflowName); + const $secrets = createSecretsApi(owner); const sandbox = { ...pickBuiltins(), log: scriptLog, console: createConsole(scriptLog), $axios, $kv, + $secrets, require: createRestrictedRequire($axios), }; @@ -328,10 +376,10 @@ export function extractScriptMeta(fn) { /** * @param {import("node:vm").Script} compiled - * @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts + * @param {{ log: import("pino").Logger, script: string, workflowName: string, owner?: string }} opts */ -function instantiateCompiled(compiled, { log, script, workflowName }) { - const sandbox = createScriptSandbox({ log, script, workflowName }); +function instantiateCompiled(compiled, { log, script, workflowName, owner }) { + const sandbox = createScriptSandbox({ log, script, workflowName, owner }); return compiled.runInContext(sandbox); } @@ -341,7 +389,7 @@ function instantiateCompiled(compiled, { log, script, workflowName }) { * * @param {string} script * @param {string} source - * @param {{ log?: import("pino").Logger, workflowName?: string }} [opts] + * @param {{ log?: import("pino").Logger, workflowName?: string, owner?: string }} [opts] */ export function instantiateScriptSource(script, source, opts = {}) { const compiled = compileScriptSource(source, script); @@ -349,6 +397,7 @@ export function instantiateScriptSource(script, source, opts = {}) { log: opts.log ?? inspectLog, script, workflowName: opts.workflowName ?? "inspect", + owner: opts.owner ?? "default", }); return { fn, ...extractScriptMeta(fn) }; } @@ -390,11 +439,11 @@ function loadCompiledScript(script) { * * @param {string} script * @param {unknown} ctx - * @param {{ log: import("pino").Logger, workflowName: string }} opts + * @param {{ log: import("pino").Logger, workflowName: string, owner?: string }} opts */ -export async function runScript(script, ctx, { log, workflowName }) { +export async function runScript(script, ctx, { log, workflowName, owner }) { const compiled = loadCompiledScript(script); - const fn = instantiateCompiled(compiled, { log, script, workflowName }); + const fn = instantiateCompiled(compiled, { log, script, workflowName, owner }); return await fn(ctx); } @@ -404,9 +453,9 @@ export async function runScript(script, ctx, { log, workflowName }) { * @param {string} script * @param {string} source * @param {unknown} ctx - * @param {{ log: import("pino").Logger, workflowName: string }} opts + * @param {{ log: import("pino").Logger, workflowName: string, owner?: string }} opts */ -export async function runScriptSource(script, source, ctx, { log, workflowName }) { - const { fn } = instantiateScriptSource(script, source, { log, workflowName }); +export async function runScriptSource(script, source, ctx, { log, workflowName, owner }) { + const { fn } = instantiateScriptSource(script, source, { log, workflowName, owner }); return await fn(ctx); } diff --git a/packages/server/scripts/get-secret.js b/packages/server/scripts/get-secret.js new file mode 100644 index 0000000..6091c2f --- /dev/null +++ b/packages/server/scripts/get-secret.js @@ -0,0 +1,50 @@ +async function getSecret(ctx) { + const name = ctx.config?.name; + if (typeof name !== "string" || name.length === 0) { + throw new Error("config.name is required"); + } + const as = + typeof ctx.config?.as === "string" && ctx.config.as.length > 0 + ? ctx.config.as + : name; + + const value = await $secrets.get(name); + const base = + ctx != null && typeof ctx === "object" && !Array.isArray(ctx) ? { ...ctx } : {}; + const data = + base.data != null && typeof base.data === "object" && !Array.isArray(base.data) + ? { ...base.data } + : {}; + data[as] = value; + return { ...base, data }; +} + +getSecret.meta = { + description: + "Load a named secret for this workflow owner into ctx.data. The value is wrapped and redacted in logs.", + config: { + name: { + type: "string", + required: true, + description: "Secret name (per owner)", + }, + as: { + type: "string", + required: false, + description: "ctx.data field to write (defaults to name)", + }, + }, + input: {}, + output: { + data: { + type: "object", + description: "Previous ctx.data plus the retrieved Secret at [as]", + }, + }, + example: { + data: {}, + config: { name: "ntfy_token", as: "ntfyToken" }, + }, +}; + +export default getSecret; diff --git a/packages/server/secret-value.js b/packages/server/secret-value.js new file mode 100644 index 0000000..149eda0 --- /dev/null +++ b/packages/server/secret-value.js @@ -0,0 +1,101 @@ +import { inspect } from "node:util"; + +export const REDACTED = "[secret]"; +export const MIN_SECRET_LENGTH = 8; + +/** @type {Set} */ +const plaintextValues = new Set(); + +/** + * @param {string} value + */ +export function registerPlaintext(value) { + if (typeof value === "string" && value.length >= MIN_SECRET_LENGTH) { + plaintextValues.add(value); + } +} + +/** + * Replace registered secret strings in text (raw and JSON-escaped forms). + * @param {string} text + */ +export function redactString(text) { + if (typeof text !== "string" || plaintextValues.size === 0) return text; + let out = text; + for (const secret of plaintextValues) { + if (out.includes(secret)) { + out = out.split(secret).join("***"); + } + const escaped = JSON.stringify(secret).slice(1, -1); + if (escaped !== secret && out.includes(escaped)) { + out = out.split(escaped).join("***"); + } + } + return out; +} + +export class Secret { + /** @type {string} */ + #value; + + /** + * @param {string} value + */ + constructor(value) { + if (typeof value !== "string") { + throw new Error("secret value must be a string"); + } + this.#value = value; + registerPlaintext(value); + } + + reveal() { + return this.#value; + } + + toString() { + return REDACTED; + } + + toJSON() { + return REDACTED; + } + + [inspect.custom]() { + return REDACTED; + } + + [Symbol.toPrimitive]() { + return REDACTED; + } +} + +/** + * @param {unknown} value + */ +export function isSecret(value) { + return value instanceof Secret; +} + +/** + * @param {unknown} value + * @param {WeakSet} [seen] + */ +export function unwrapSecretsDeep(value, seen = new WeakSet()) { + if (isSecret(value)) return value.reveal(); + if (value == null || typeof value !== "object") return value; + if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) return value; + if (seen.has(value)) return value; + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => unwrapSecretsDeep(item, seen)); + } + + /** @type {Record} */ + const out = {}; + for (const [key, child] of Object.entries(value)) { + out[key] = unwrapSecretsDeep(child, seen); + } + return out; +} diff --git a/packages/server/secrets-store.js b/packages/server/secrets-store.js new file mode 100644 index 0000000..777f743 --- /dev/null +++ b/packages/server/secrets-store.js @@ -0,0 +1,144 @@ +import { randomUUID } from "node:crypto"; +import { db } from "./db.js"; +import { assertOwner } from "./fs-store.js"; +import { decryptSecret, encryptSecret } from "./secrets.js"; +import { MIN_SECRET_LENGTH, registerPlaintext } from "./secret-value.js"; + +const MAX_NAME_LENGTH = 128; +const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/; + +function nowIso() { + return new Date().toISOString(); +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertSecretName(name) { + if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) { + const err = new Error("invalid secret name"); + err.statusCode = 400; + throw err; + } + if (name.length > MAX_NAME_LENGTH) { + const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`); + err.statusCode = 400; + throw err; + } + return name; +} + +function publicSecret(row) { + return { + id: row.id, + owner: row.owner, + name: row.name, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * @param {{ owner?: string }} [filters] + */ +export async function listSecrets(filters = {}) { + let q = db("secrets") + .select("id", "owner", "name", "created_at", "updated_at") + .orderBy("owner", "asc") + .orderBy("name", "asc"); + if (filters.owner) { + q = q.where("owner", assertOwner(filters.owner)); + } + return q; +} + +/** + * @param {string} id + */ +export async function getSecretById(id) { + const row = await db("secrets") + .select("id", "owner", "name", "created_at", "updated_at") + .where({ id }) + .first(); + return row ?? null; +} + +/** + * @param {{ owner: string, name: string, value: string }} opts + */ +export async function upsertSecret({ owner, name, value }) { + if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) { + const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`); + err.statusCode = 400; + throw err; + } + const ownerName = assertOwner(owner); + const secretName = assertSecretName(name); + registerPlaintext(value); + const { ciphertext, iv, authTag } = encryptSecret(value); + const now = nowIso(); + const existing = await db("secrets") + .where({ owner: ownerName, name: secretName }) + .first(); + + if (existing) { + await db("secrets") + .where({ id: existing.id }) + .update({ + ciphertext, + iv, + auth_tag: authTag, + updated_at: now, + }); + return getSecretById(existing.id); + } + + const id = randomUUID(); + await db("secrets").insert({ + id, + owner: ownerName, + name: secretName, + ciphertext, + iv, + auth_tag: authTag, + created_at: now, + updated_at: now, + }); + return getSecretById(id); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteSecret(id) { + const n = await db("secrets").where({ id }).del(); + return n > 0; +} + +/** + * Decrypt a named secret for an owner. Returns null if missing. + * @param {string} owner + * @param {string} name + * @returns {Promise} + */ +export async function getSecretPlaintext(owner, name) { + const ownerName = assertOwner(owner); + const secretName = assertSecretName(name); + const row = await db("secrets") + .where({ owner: ownerName, name: secretName }) + .first(); + if (!row) return null; + try { + const plaintext = decryptSecret({ + ciphertext: row.ciphertext, + iv: row.iv, + authTag: row.auth_tag, + }); + registerPlaintext(plaintext); + return plaintext; + } catch { + throw new Error(`failed to decrypt secret "${secretName}"`); + } +} diff --git a/packages/server/secrets.js b/packages/server/secrets.js new file mode 100644 index 0000000..86f0865 --- /dev/null +++ b/packages/server/secrets.js @@ -0,0 +1,73 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +const DEV_DEFAULT = "scrunner-dev-secrets-key"; +const SCRYPT_SALT = Buffer.from("scrunner-secrets-v1"); +const KEY_LEN = 32; +const IV_LEN = 12; +const AUTH_TAG_LEN = 16; + +/** + * @returns {string} + */ +export function resolveSecretsKeyMaterial() { + const raw = + process.env.SCRUNNER_SECRETS_KEY ?? + (process.env.NODE_ENV === "production" ? "" : DEV_DEFAULT); + if (!raw) { + throw new Error("SCRUNNER_SECRETS_KEY is required in production"); + } + return raw; +} + +/** @type {Buffer | null} */ +let cachedKey = null; + +/** + * @returns {Buffer} + */ +export function getMasterKey() { + if (cachedKey) return cachedKey; + const raw = resolveSecretsKeyMaterial(); + cachedKey = /^[0-9a-fA-F]{64}$/.test(raw) + ? Buffer.from(raw, "hex") + : scryptSync(raw, SCRYPT_SALT, KEY_LEN); + return cachedKey; +} + +/** + * @param {string} plaintext + * @returns {{ ciphertext: string, iv: string, authTag: string }} + */ +export function encryptSecret(plaintext) { + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv("aes-256-gcm", getMasterKey(), iv, { + authTagLength: AUTH_TAG_LEN, + }); + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + return { + ciphertext: encrypted.toString("base64"), + iv: iv.toString("base64"), + authTag: cipher.getAuthTag().toString("base64"), + }; +} + +/** + * @param {{ ciphertext: string, iv: string, authTag: string }} row + * @returns {string} + */ +export function decryptSecret(row) { + const decipher = createDecipheriv( + "aes-256-gcm", + getMasterKey(), + Buffer.from(row.iv, "base64"), + { authTagLength: AUTH_TAG_LEN }, + ); + decipher.setAuthTag(Buffer.from(row.authTag, "base64")); + return Buffer.concat([ + decipher.update(Buffer.from(row.ciphertext, "base64")), + decipher.final(), + ]).toString("utf8"); +} diff --git a/packages/server/src/api/dry-run-logger.js b/packages/server/src/api/dry-run-logger.js index 939ee34..7f347c0 100644 --- a/packages/server/src/api/dry-run-logger.js +++ b/packages/server/src/api/dry-run-logger.js @@ -1,4 +1,5 @@ import pino from "pino"; +import { redactString } from "../../secret-value.js"; const LEVEL_TO_NUM = { trace: 10, @@ -22,7 +23,9 @@ export function createDryRunLogger() { write(line) { let record; try { - record = JSON.parse(typeof line === "string" ? line : String(line)); + record = JSON.parse( + redactString(typeof line === "string" ? line : String(line)), + ); } catch { return; } @@ -43,7 +46,7 @@ export function createDryRunLogger() { logs.push({ ts, level, - msg, + msg: typeof msg === "string" ? redactString(msg) : msg, payload: Object.keys(rest).length ? rest : null, }); }, @@ -60,14 +63,16 @@ export function safeSerialize(value) { const seen = new WeakSet(); try { return JSON.parse( - JSON.stringify(value, (_key, v) => { - if (typeof v === "bigint") return v.toString(); - if (typeof v === "object" && v !== null) { - if (seen.has(v)) return "[Circular]"; - seen.add(v); - } - return v; - }), + redactString( + JSON.stringify(value, (_key, v) => { + if (typeof v === "bigint") return v.toString(); + if (typeof v === "object" && v !== null) { + if (seen.has(v)) return "[Circular]"; + seen.add(v); + } + return v; + }), + ), ); } catch (err) { return { diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index 41eeb5e..939ade7 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -85,13 +85,22 @@ export default function scriptsPluginFactory(registry) { return reply.code(err.statusCode ?? 400).send({ error: err.message }); } - const body = /** @type {{ content?: string, data?: unknown, config?: unknown }} */ ( + const body = /** @type {{ content?: string, data?: unknown, config?: unknown, owner?: string }} */ ( req.body ?? {} ); if (typeof body.content !== "string") { return reply.code(400).send({ error: "content is required" }); } + let owner = "default"; + if (body.owner != null && body.owner !== "") { + try { + owner = fsStore.assertOwner(String(body.owner)); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + } + const ctx = { data: body.data ?? null, config: body.config ?? null, @@ -104,6 +113,7 @@ export default function scriptsPluginFactory(registry) { const { fn, meta, metaError } = instantiateScriptSource(name, body.content, { log, workflowName: "dry-run", + owner, }); const output = await fn(ctx); return { diff --git a/packages/server/src/api/secrets.js b/packages/server/src/api/secrets.js new file mode 100644 index 0000000..d3f75e3 --- /dev/null +++ b/packages/server/src/api/secrets.js @@ -0,0 +1,67 @@ +import * as fsStore from "../../fs-store.js"; +import { + assertSecretName, + deleteSecret, + getSecretById, + listSecrets, + upsertSecret, +} from "../../secrets-store.js"; +import { MIN_SECRET_LENGTH } from "../../secret-value.js"; + +/** + * @param {import("fastify").FastifyInstance} fastify + */ +export default async function secretsPlugin(fastify) { + fastify.addHook("onRequest", fastify.requireAdmin); + + fastify.get("/secrets", async (req, reply) => { + const q = /** @type {{ owner?: string }} */ (req.query ?? {}); + try { + const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined; + const secrets = await listSecrets({ owner }); + return { secrets }; + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + fastify.put("/secrets", async (req, reply) => { + const body = /** @type {{ owner?: string, name?: string, value?: string }} */ ( + req.body ?? {} + ); + try { + fsStore.assertOwner(String(body.owner ?? "")); + assertSecretName(String(body.name ?? "")); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + + const value = String(body.value ?? ""); + if (value.length < MIN_SECRET_LENGTH) { + return reply + .code(400) + .send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` }); + } + + try { + const secret = await upsertSecret({ + owner: String(body.owner), + name: String(body.name), + value, + }); + return reply.send({ secret }); + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + fastify.delete("/secrets/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + const existing = await getSecretById(id); + if (!existing) { + return reply.code(404).send({ error: "secret not found" }); + } + await deleteSecret(id); + return { ok: true }; + }); +} diff --git a/packages/server/store.js b/packages/server/store.js index c50c584..4f337f9 100644 --- a/packages/server/store.js +++ b/packages/server/store.js @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { db } from "./db.js"; +import { redactString } from "./secret-value.js"; const MAX_JSON_BYTES = 64 * 1024; @@ -15,6 +16,7 @@ export function serialize(value) { } catch { json = JSON.stringify({ truncated: true, reason: "unserializable" }); } + json = redactString(json); if (Buffer.byteLength(json, "utf8") <= MAX_JSON_BYTES) return json; return JSON.stringify({ truncated: true, diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index 2e169c7..9ac2e5e 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -13,6 +13,7 @@ import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx" import { EventsPage } from "./pages/EventsPage.jsx"; import { EventDetailPage } from "./pages/EventDetailPage.jsx"; import { UsersPage } from "./pages/UsersPage.jsx"; +import { SecretsPage } from "./pages/SecretsPage.jsx"; export function App() { const qc = useQueryClient(); @@ -57,9 +58,15 @@ export function App() { } /> } /> {user.role === "admin" ? ( - } /> + <> + } /> + } /> + ) : ( - } /> + <> + } /> + } /> + )} } /> diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index c0d7d32..f7c1720 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -107,12 +107,13 @@ export function useDeleteScript() { export function useDryRunScript() { return useMutation({ - mutationFn: async ({ name, content, data, config }) => + mutationFn: async ({ name, content, data, config, owner }) => ( await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, { content, data, config, + owner, }) ).data, }); @@ -267,3 +268,30 @@ export function useDeleteUser() { onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), }); } + +export function useSecrets(owner) { + return useQuery({ + queryKey: ["secrets", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/secrets", { params })).data.secrets; + }, + }); +} + +export function useUpsertSecret() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/secrets", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), + }); +} + +export function useDeleteSecret() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id) => + (await api.delete(`/secrets/${encodeURIComponent(id)}`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), + }); +} diff --git a/packages/web/src/components/Layout.jsx b/packages/web/src/components/Layout.jsx index b77e334..1d0564d 100644 --- a/packages/web/src/components/Layout.jsx +++ b/packages/web/src/components/Layout.jsx @@ -4,6 +4,7 @@ import { LuCode, LuGitBranch, LuHouse, + LuKey, LuLogOut, LuMenu, LuMoon, @@ -28,7 +29,10 @@ export function Layout({ user, children }) { const navItems = [ ...links, ...(user.role === "admin" - ? [{ to: "/users", label: "Users", icon: LuUsers }] + ? [ + { to: "/secrets", label: "Secrets", icon: LuKey }, + { to: "/users", label: "Users", icon: LuUsers }, + ] : []), ]; diff --git a/packages/web/src/pages/ScriptDryRunPage.jsx b/packages/web/src/pages/ScriptDryRunPage.jsx index 78ebc7d..7dfc4e6 100644 --- a/packages/web/src/pages/ScriptDryRunPage.jsx +++ b/packages/web/src/pages/ScriptDryRunPage.jsx @@ -4,6 +4,7 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu"; import { errorMessage } from "../api/client.js"; import { useDryRunScript, + useOwners, useSaveScript, useScript, } from "../api/hooks.js"; @@ -27,6 +28,8 @@ export function ScriptDryRunPage() { const existing = useScript(name, stateContent == null); const dryRun = useDryRunScript(); const save = useSaveScript(); + const { data: owners = [] } = useOwners(); + const [owner, setOwner] = useState("default"); const [content, setContent] = useState(""); const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT); @@ -116,6 +119,7 @@ export function ScriptDryRunPage() { content, data: ctx.data, config: ctx.config, + owner, }); } @@ -136,6 +140,21 @@ export function ScriptDryRunPage() { {lastRun.durationMs}ms ) : null}
+ +
+ + + {isLoading ? ( + + ) : secrets.length === 0 ? ( +

No secrets yet.

+ ) : ( +
+ + + + + + + + + + {secrets.map((s) => ( + + + + + + + ))} + +
OwnerNameUpdated +
{s.owner}{s.name}{formatTime(s.updated_at)} + + +
+
+ )} + + {mode ? ( +
+
+ + {mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`} + + +
+ {mode === "add" ? ( + <> + + {owners.length > 0 ? ( + + ) : ( + setForm({ ...form, owner: e.target.value })} + required + /> + )} + + setForm({ ...form, name: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + title="Letters, numbers, dots, underscores, hyphens" + /> + + ) : null} + + setForm({ ...form, value: e.target.value })} + required + minLength={8} + autoComplete="new-password" + /> +

+ Values are encrypted at rest and never shown again after save. +

+ {upsert.isError ? ( +

{errorMessage(upsert.error)}

+ ) : null} + +
+ ) : null} + + {confirmDelete ? ( + +
+

+ Delete {confirmDelete.owner}/{confirmDelete.name}? +

+

This cannot be undone. Workflows that retrieve this name will fail.

+ {del.isError ? ( +

{errorMessage(del.error)}

+ ) : null} +
+ + +
+
+
+ +
+
+ ) : null} + + ); +}