diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts new file mode 100644 index 00000000..21a1a8b9 --- /dev/null +++ b/server/src/__tests__/redaction.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { REDACTED_EVENT_VALUE, redactEventPayload, sanitizeRecord } from "../redaction.js"; + +describe("redaction", () => { + it("redacts sensitive keys and nested secret values", () => { + const input = { + apiKey: "abc123", + nested: { + AUTH_TOKEN: "token-value", + safe: "ok", + }, + env: { + OPENAI_API_KEY: "sk-openai", + OPENAI_API_KEY_REF: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + }, + OPENAI_API_KEY_PLAIN: { + type: "plain", + value: "sk-plain", + }, + PAPERCLIP_API_URL: "http://localhost:3100", + }, + }; + + const result = sanitizeRecord(input); + + expect(result.apiKey).toBe(REDACTED_EVENT_VALUE); + expect(result.nested).toEqual({ + AUTH_TOKEN: REDACTED_EVENT_VALUE, + safe: "ok", + }); + expect(result.env).toEqual({ + OPENAI_API_KEY: REDACTED_EVENT_VALUE, + OPENAI_API_KEY_REF: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + }, + OPENAI_API_KEY_PLAIN: { + type: "plain", + value: REDACTED_EVENT_VALUE, + }, + PAPERCLIP_API_URL: "http://localhost:3100", + }); + }); + + it("redacts jwt-looking values even when key name is not sensitive", () => { + const input = { + session: "aaa.bbb.ccc", + normal: "plain", + }; + + const result = sanitizeRecord(input); + + expect(result.session).toBe(REDACTED_EVENT_VALUE); + expect(result.normal).toBe("plain"); + }); + + it("redacts payload objects while preserving null", () => { + expect(redactEventPayload(null)).toBeNull(); + expect(redactEventPayload({ password: "hunter2", safe: "value" })).toEqual({ + password: REDACTED_EVENT_VALUE, + safe: "value", + }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index eefeae32..6f91c397 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -12,6 +12,7 @@ import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { goalRoutes } from "./routes/goals.js"; import { approvalRoutes } from "./routes/approvals.js"; +import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; @@ -37,6 +38,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode }) { api.use(issueRoutes(db)); api.use(goalRoutes(db)); api.use(approvalRoutes(db)); + api.use(secretRoutes(db)); api.use(costRoutes(db)); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); diff --git a/server/src/config.ts b/server/src/config.ts index 797ce390..e022aeac 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,6 +2,7 @@ import { readConfigFile } from "./config-file.js"; import { existsSync } from "node:fs"; import { config as loadDotenv } from "dotenv"; import { resolvePaperclipEnvPath } from "./paths.js"; +import { SECRET_PROVIDERS, type SecretProvider } from "@paperclip/shared"; const PAPERCLIP_ENV_FILE_PATH = resolvePaperclipEnvPath(); if (existsSync(PAPERCLIP_ENV_FILE_PATH)) { @@ -18,6 +19,9 @@ export interface Config { embeddedPostgresPort: number; serveUi: boolean; uiDevMiddleware: boolean; + secretsProvider: SecretProvider; + secretsStrictMode: boolean; + secretsMasterKeyFilePath: string; heartbeatSchedulerEnabled: boolean; heartbeatSchedulerIntervalMs: number; } @@ -31,6 +35,20 @@ export function loadConfig(): Config { fileDatabaseMode === "postgres" ? fileConfig?.database.connectionString : undefined; + const fileSecrets = fileConfig?.secrets; + const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE; + const secretsStrictMode = + strictModeFromEnv !== undefined + ? strictModeFromEnv === "true" + : (fileSecrets?.strictMode ?? false); + + const providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER; + const providerFromEnv = + providerFromEnvRaw && SECRET_PROVIDERS.includes(providerFromEnvRaw as SecretProvider) + ? (providerFromEnvRaw as SecretProvider) + : null; + const providerFromFile = fileSecrets?.provider; + const secretsProvider: SecretProvider = providerFromEnv ?? providerFromFile ?? "local_encrypted"; return { port: Number(process.env.PORT) || fileConfig?.server.port || 3100, @@ -43,6 +61,12 @@ export function loadConfig(): Config { ? process.env.SERVE_UI === "true" : fileConfig?.server.serveUi ?? true, uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true", + secretsProvider, + secretsStrictMode, + secretsMasterKeyFilePath: + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ?? + fileSecrets?.localEncrypted.keyFilePath ?? + "./data/secrets/master.key", heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false", heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000), }; diff --git a/server/src/index.ts b/server/src/index.ts index cad3640c..49bc5537 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -33,6 +33,15 @@ type EmbeddedPostgresCtor = new (opts: { }) => EmbeddedPostgresInstance; const config = loadConfig(); +if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { + process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; +} +if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) { + process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false"; +} +if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath; +} type MigrationSummary = | "skipped" diff --git a/server/src/redaction.ts b/server/src/redaction.ts new file mode 100644 index 00000000..2c562ae5 --- /dev/null +++ b/server/src/redaction.ts @@ -0,0 +1,59 @@ +const SECRET_PAYLOAD_KEY_RE = + /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; +export const REDACTED_EVENT_VALUE = "***REDACTED***"; + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function sanitizeValue(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(sanitizeValue); + if (isSecretRefBinding(value)) return value; + if (isPlainBinding(value)) return { type: "plain", value: sanitizeValue(value.value) }; + if (!isPlainObject(value)) return value; + return sanitizeRecord(value); +} + +function isSecretRefBinding(value: unknown): value is { type: "secret_ref"; secretId: string; version?: unknown } { + if (!isPlainObject(value)) return false; + return value.type === "secret_ref" && typeof value.secretId === "string"; +} + +function isPlainBinding(value: unknown): value is { type: "plain"; value: unknown } { + if (!isPlainObject(value)) return false; + return value.type === "plain" && "value" in value; +} + +export function sanitizeRecord(record: Record): Record { + const redacted: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (SECRET_PAYLOAD_KEY_RE.test(key)) { + if (isSecretRefBinding(value)) { + redacted[key] = sanitizeValue(value); + continue; + } + if (isPlainBinding(value)) { + redacted[key] = { type: "plain", value: REDACTED_EVENT_VALUE }; + continue; + } + redacted[key] = REDACTED_EVENT_VALUE; + continue; + } + if (typeof value === "string" && JWT_VALUE_RE.test(value)) { + redacted[key] = REDACTED_EVENT_VALUE; + continue; + } + redacted[key] = sanitizeValue(value); + } + return redacted; +} + +export function redactEventPayload(payload: Record | null): Record | null { + if (!payload) return null; + if (!isPlainObject(payload)) return payload; + return sanitizeRecord(payload); +} diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 2c421367..6933a446 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -5,6 +5,7 @@ import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; import { issueService } from "../services/index.js"; +import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({ actorType: z.enum(["agent", "user", "system"]).optional().default("system"), @@ -41,6 +42,7 @@ export function activityRoutes(db: Db) { const event = await svc.create({ companyId, ...req.body, + details: req.body.details ? sanitizeRecord(req.body.details) : null, }); res.status(201).json(event); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 50b51c62..2f083290 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -19,49 +19,12 @@ import { issueApprovalService, issueService, logActivity, + secretService, } from "../services/index.js"; import { forbidden } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { listAdapterModels } from "../adapters/index.js"; - -const SECRET_PAYLOAD_KEY_RE = - /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; -const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; -const REDACTED_EVENT_VALUE = "***REDACTED***"; - -function sanitizeValue(value: unknown): unknown { - if (value === null || value === undefined) return value; - if (Array.isArray(value)) return value.map(sanitizeValue); - if (typeof value !== "object") return value; - if (value instanceof Date) return value; - if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value; - return sanitizeRecord(value as Record); -} - -function sanitizeRecord(record: Record): Record { - const redacted: Record = {}; - for (const [key, value] of Object.entries(record)) { - const isSensitiveKey = SECRET_PAYLOAD_KEY_RE.test(key); - if (isSensitiveKey) { - redacted[key] = REDACTED_EVENT_VALUE; - continue; - } - if (typeof value === "string" && JWT_VALUE_RE.test(value)) { - redacted[key] = REDACTED_EVENT_VALUE; - continue; - } - redacted[key] = sanitizeValue(value); - } - return redacted; -} - -function redactEventPayload(payload: Record | null): Record | null { - if (!payload) return null; - if (Array.isArray(payload) || typeof payload !== "object") { - return payload as Record; - } - return sanitizeRecord(payload); -} +import { redactEventPayload } from "../redaction.js"; export function agentRoutes(db: Db) { const router = Router(); @@ -69,6 +32,8 @@ export function agentRoutes(db: Db) { const approvalsSvc = approvalService(db); const heartbeat = heartbeatService(db); const issueApprovalsSvc = issueApprovalService(db); + const secretsSvc = secretService(db); + const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; function canCreateAgents(agent: { role: string; permissions: Record | null | undefined }) { if (!agent.permissions || typeof agent.permissions !== "object") return false; @@ -130,6 +95,28 @@ export function agentRoutes(db: Db) { return Array.from(new Set(values)); } + function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; + } + + function summarizeAgentUpdateDetails(patch: Record) { + const changedTopLevelKeys = Object.keys(patch).sort(); + const details: Record = { changedTopLevelKeys }; + + const adapterConfigPatch = asRecord(patch.adapterConfig); + if (adapterConfigPatch) { + details.changedAdapterConfigKeys = Object.keys(adapterConfigPatch).sort(); + } + + const runtimeConfigPatch = asRecord(patch.runtimeConfig); + if (runtimeConfigPatch) { + details.changedRuntimeConfigKeys = Object.keys(runtimeConfigPatch).sort(); + } + + return details; + } + function redactForRestrictedAgentView(agent: Awaited>) { if (!agent) return null; return { @@ -411,6 +398,15 @@ export function agentRoutes(db: Db) { await assertCanCreateAgentsForCompany(req, companyId); const sourceIssueIds = parseSourceIssueIds(req.body); const { sourceIssueId: _sourceIssueId, sourceIssueIds: _sourceIssueIds, ...hireInput } = req.body; + const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + companyId, + ((hireInput.adapterConfig ?? {}) as Record), + { strictMode: strictSecretsMode }, + ); + const normalizedHireInput = { + ...hireInput, + adapterConfig: normalizedAdapterConfig, + }; const company = await db .select() @@ -425,7 +421,7 @@ export function agentRoutes(db: Db) { const requiresApproval = company.requireBoardApprovalForNewAgents; const status = requiresApproval ? "pending_approval" : "idle"; const agent = await svc.create(companyId, { - ...hireInput, + ...normalizedHireInput, status, spentMonthlyCents: 0, lastHeartbeatAt: null, @@ -435,19 +431,44 @@ export function agentRoutes(db: Db) { const actor = getActorInfo(req); if (requiresApproval) { + const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType; + const requestedAdapterConfig = + redactEventPayload( + (normalizedHireInput.adapterConfig ?? agent.adapterConfig) as Record, + ) ?? {}; + const requestedRuntimeConfig = + redactEventPayload( + (normalizedHireInput.runtimeConfig ?? agent.runtimeConfig) as Record, + ) ?? {}; + const requestedMetadata = + redactEventPayload( + ((normalizedHireInput.metadata ?? agent.metadata ?? {}) as Record), + ) ?? {}; approval = await approvalsSvc.create(companyId, { type: "hire_agent", requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, requestedByUserId: actor.actorType === "user" ? actor.actorId : null, status: "pending", payload: { - ...hireInput, + name: normalizedHireInput.name, + role: normalizedHireInput.role, + title: normalizedHireInput.title ?? null, + reportsTo: normalizedHireInput.reportsTo ?? null, + capabilities: normalizedHireInput.capabilities ?? null, + adapterType: requestedAdapterType, + adapterConfig: requestedAdapterConfig, + runtimeConfig: requestedRuntimeConfig, + budgetMonthlyCents: + typeof normalizedHireInput.budgetMonthlyCents === "number" + ? normalizedHireInput.budgetMonthlyCents + : agent.budgetMonthlyCents, + metadata: requestedMetadata, agentId: agent.id, requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, requestedConfigurationSnapshot: { - adapterType: hireInput.adapterType ?? agent.adapterType, - adapterConfig: hireInput.adapterConfig ?? agent.adapterConfig, - runtimeConfig: hireInput.runtimeConfig ?? agent.runtimeConfig, + adapterType: requestedAdapterType, + adapterConfig: requestedAdapterConfig, + runtimeConfig: requestedRuntimeConfig, }, }, decisionNote: null, @@ -507,8 +528,15 @@ export function agentRoutes(db: Db) { assertBoard(req); } + const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + companyId, + ((req.body.adapterConfig ?? {}) as Record), + { strictMode: strictSecretsMode }, + ); + const agent = await svc.create(companyId, { ...req.body, + adapterConfig: normalizedAdapterConfig, status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, @@ -587,8 +615,22 @@ export function agentRoutes(db: Db) { return; } + const patchData = { ...(req.body as Record) }; + if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) { + const adapterConfig = asRecord(patchData.adapterConfig); + if (!adapterConfig) { + res.status(422).json({ error: "adapterConfig must be an object" }); + return; + } + patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + adapterConfig, + { strictMode: strictSecretsMode }, + ); + } + const actor = getActorInfo(req); - const agent = await svc.update(id, req.body, { + const agent = await svc.update(id, patchData, { recordRevision: { createdByAgentId: actor.agentId, createdByUserId: actor.actorType === "user" ? actor.actorId : null, @@ -609,7 +651,7 @@ export function agentRoutes(db: Db) { action: "agent.updated", entityType: "agent", entityId: agent.id, - details: req.body, + details: summarizeAgentUpdateDetails(patchData), }); res.json(agent); diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index ace1c1e9..3de2fb40 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -14,21 +14,32 @@ import { heartbeatService, issueApprovalService, logActivity, + secretService, } from "../services/index.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { redactEventPayload } from "../redaction.js"; + +function redactApprovalPayload }>(approval: T): T { + return { + ...approval, + payload: redactEventPayload(approval.payload) ?? {}, + }; +} export function approvalRoutes(db: Db) { const router = Router(); const svc = approvalService(db); const heartbeat = heartbeatService(db); const issueApprovalsSvc = issueApprovalService(db); + const secretsSvc = secretService(db); + const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; router.get("/companies/:companyId/approvals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const status = req.query.status as string | undefined; const result = await svc.list(companyId, status); - res.json(result); + res.json(result.map((approval) => redactApprovalPayload(approval))); }); router.get("/approvals/:id", async (req, res) => { @@ -39,7 +50,7 @@ export function approvalRoutes(db: Db) { return; } assertCompanyAccess(req, approval.companyId); - res.json(approval); + res.json(redactApprovalPayload(approval)); }); router.post("/companies/:companyId/approvals", validate(createApprovalSchema), async (req, res) => { @@ -51,10 +62,19 @@ export function approvalRoutes(db: Db) { : []; const uniqueIssueIds = Array.from(new Set(issueIds)); const { issueIds: _issueIds, ...approvalInput } = req.body; + const normalizedPayload = + approvalInput.type === "hire_agent" + ? await secretsSvc.normalizeHireApprovalPayloadForPersistence( + companyId, + approvalInput.payload, + { strictMode: strictSecretsMode }, + ) + : approvalInput.payload; const actor = getActorInfo(req); const approval = await svc.create(companyId, { ...approvalInput, + payload: normalizedPayload, requestedByUserId: actor.actorType === "user" ? actor.actorId : null, requestedByAgentId: approvalInput.requestedByAgentId ?? (actor.actorType === "agent" ? actor.actorId : null), @@ -83,7 +103,7 @@ export function approvalRoutes(db: Db) { details: { type: approval.type, issueIds: uniqueIssueIds }, }); - res.status(201).json(approval); + res.status(201).json(redactApprovalPayload(approval)); }); router.get("/approvals/:id/issues", async (req, res) => { @@ -183,7 +203,7 @@ export function approvalRoutes(db: Db) { } } - res.json(approval); + res.json(redactApprovalPayload(approval)); }); router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => { @@ -201,7 +221,7 @@ export function approvalRoutes(db: Db) { details: { type: approval.type }, }); - res.json(approval); + res.json(redactApprovalPayload(approval)); }); router.post( @@ -226,7 +246,7 @@ export function approvalRoutes(db: Db) { details: { type: approval.type }, }); - res.json(approval); + res.json(redactApprovalPayload(approval)); }, ); @@ -244,7 +264,16 @@ export function approvalRoutes(db: Db) { return; } - const approval = await svc.resubmit(id, req.body.payload); + const normalizedPayload = req.body.payload + ? existing.type === "hire_agent" + ? await secretsSvc.normalizeHireApprovalPayloadForPersistence( + existing.companyId, + req.body.payload, + { strictMode: strictSecretsMode }, + ) + : req.body.payload + : undefined; + const approval = await svc.resubmit(id, normalizedPayload); const actor = getActorInfo(req); await logActivity(db, { companyId: approval.companyId, @@ -256,7 +285,7 @@ export function approvalRoutes(db: Db) { entityId: approval.id, details: { type: approval.type }, }); - res.json(approval); + res.json(redactApprovalPayload(approval)); }); router.get("/approvals/:id/comments", async (req, res) => { diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index f1f57e7e..fbd5a515 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -5,6 +5,7 @@ export { projectRoutes } from "./projects.js"; export { issueRoutes } from "./issues.js"; export { goalRoutes } from "./goals.js"; export { approvalRoutes } from "./approvals.js"; +export { secretRoutes } from "./secrets.js"; export { costRoutes } from "./costs.js"; export { activityRoutes } from "./activity.js"; export { dashboardRoutes } from "./dashboard.js"; diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts new file mode 100644 index 00000000..eff2c37a --- /dev/null +++ b/server/src/routes/secrets.ts @@ -0,0 +1,165 @@ +import { Router } from "express"; +import type { Db } from "@paperclip/db"; +import { + SECRET_PROVIDERS, + type SecretProvider, + createSecretSchema, + rotateSecretSchema, + updateSecretSchema, +} from "@paperclip/shared"; +import { validate } from "../middleware/validate.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; +import { logActivity, secretService } from "../services/index.js"; + +export function secretRoutes(db: Db) { + const router = Router(); + const svc = secretService(db); + const configuredDefaultProvider = process.env.PAPERCLIP_SECRETS_PROVIDER; + const defaultProvider = ( + configuredDefaultProvider && SECRET_PROVIDERS.includes(configuredDefaultProvider as SecretProvider) + ? configuredDefaultProvider + : "local_encrypted" + ) as SecretProvider; + + router.get("/companies/:companyId/secret-providers", (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(svc.listProviders()); + }); + + router.get("/companies/:companyId/secrets", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const secrets = await svc.list(companyId); + res.json(secrets); + }); + + router.post("/companies/:companyId/secrets", validate(createSecretSchema), async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + + const created = await svc.create( + companyId, + { + name: req.body.name, + provider: req.body.provider ?? defaultProvider, + value: req.body.value, + description: req.body.description, + externalRef: req.body.externalRef, + }, + { userId: req.actor.userId ?? "board", agentId: null }, + ); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret.created", + entityType: "secret", + entityId: created.id, + details: { name: created.name, provider: created.provider }, + }); + + res.status(201).json(created); + }); + + router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Secret not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const rotated = await svc.rotate( + id, + { + value: req.body.value, + externalRef: req.body.externalRef, + }, + { userId: req.actor.userId ?? "board", agentId: null }, + ); + + await logActivity(db, { + companyId: rotated.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret.rotated", + entityType: "secret", + entityId: rotated.id, + details: { version: rotated.latestVersion }, + }); + + res.json(rotated); + }); + + router.patch("/secrets/:id", validate(updateSecretSchema), async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Secret not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const updated = await svc.update(id, { + name: req.body.name, + description: req.body.description, + externalRef: req.body.externalRef, + }); + + if (!updated) { + res.status(404).json({ error: "Secret not found" }); + return; + } + + await logActivity(db, { + companyId: updated.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret.updated", + entityType: "secret", + entityId: updated.id, + details: { name: updated.name }, + }); + + res.json(updated); + }); + + router.delete("/secrets/:id", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Secret not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const removed = await svc.remove(id); + if (!removed) { + res.status(404).json({ error: "Secret not found" }); + return; + } + + await logActivity(db, { + companyId: removed.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "secret.deleted", + entityType: "secret", + entityId: removed.id, + details: { name: removed.name }, + }); + + res.json({ ok: true }); + }); + + return router; +} diff --git a/server/src/secrets/external-stub-providers.ts b/server/src/secrets/external-stub-providers.ts new file mode 100644 index 00000000..3e808abf --- /dev/null +++ b/server/src/secrets/external-stub-providers.ts @@ -0,0 +1,32 @@ +import { unprocessable } from "../errors.js"; +import type { SecretProviderModule } from "./types.js"; + +function unavailableProvider( + id: "aws_secrets_manager" | "gcp_secret_manager" | "vault", + label: string, +): SecretProviderModule { + return { + id, + descriptor: { + id, + label, + requiresExternalRef: true, + }, + async createVersion() { + throw unprocessable(`${id} provider is not configured in this deployment`); + }, + async resolveVersion() { + throw unprocessable(`${id} provider is not configured in this deployment`); + }, + }; +} + +export const awsSecretsManagerProvider = unavailableProvider( + "aws_secrets_manager", + "AWS Secrets Manager", +); +export const gcpSecretManagerProvider = unavailableProvider( + "gcp_secret_manager", + "GCP Secret Manager", +); +export const vaultProvider = unavailableProvider("vault", "HashiCorp Vault"); diff --git a/server/src/secrets/local-encrypted-provider.ts b/server/src/secrets/local-encrypted-provider.ts new file mode 100644 index 00000000..a92ded20 --- /dev/null +++ b/server/src/secrets/local-encrypted-provider.ts @@ -0,0 +1,135 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs"; +import path from "node:path"; +import type { SecretProviderModule, StoredSecretVersionMaterial } from "./types.js"; +import { badRequest } from "../errors.js"; + +interface LocalEncryptedMaterial extends StoredSecretVersionMaterial { + scheme: "local_encrypted_v1"; + iv: string; + tag: string; + ciphertext: string; +} + +function resolveMasterKeyFilePath() { + const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim()); + return path.resolve(process.cwd(), "data/secrets/master.key"); +} + +function decodeMasterKey(raw: string): Buffer | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) { + return Buffer.from(trimmed, "hex"); + } + + try { + const decoded = Buffer.from(trimmed, "base64"); + if (decoded.length === 32) return decoded; + } catch { + // ignored + } + + if (Buffer.byteLength(trimmed, "utf8") === 32) { + return Buffer.from(trimmed, "utf8"); + } + return null; +} + +function loadOrCreateMasterKey(): Buffer { + const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + if (envKeyRaw && envKeyRaw.trim().length > 0) { + const fromEnv = decodeMasterKey(envKeyRaw); + if (!fromEnv) { + throw badRequest( + "Invalid PAPERCLIP_SECRETS_MASTER_KEY (expected 32-byte base64, 64-char hex, or raw 32-char string)", + ); + } + return fromEnv; + } + + const keyPath = resolveMasterKeyFilePath(); + if (existsSync(keyPath)) { + const raw = readFileSync(keyPath, "utf8"); + const decoded = decodeMasterKey(raw); + if (!decoded) { + throw badRequest(`Invalid secrets master key at ${keyPath}`); + } + return decoded; + } + + const dir = path.dirname(keyPath); + mkdirSync(dir, { recursive: true }); + const generated = randomBytes(32); + writeFileSync(keyPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600 }); + try { + chmodSync(keyPath, 0o600); + } catch { + // best effort + } + return generated; +} + +function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function encryptValue(masterKey: Buffer, value: string): LocalEncryptedMaterial { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", masterKey, iv); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + scheme: "local_encrypted_v1", + iv: iv.toString("base64"), + tag: tag.toString("base64"), + ciphertext: ciphertext.toString("base64"), + }; +} + +function decryptValue(masterKey: Buffer, material: LocalEncryptedMaterial): string { + const iv = Buffer.from(material.iv, "base64"); + const tag = Buffer.from(material.tag, "base64"); + const ciphertext = Buffer.from(material.ciphertext, "base64"); + const decipher = createDecipheriv("aes-256-gcm", masterKey, iv); + decipher.setAuthTag(tag); + const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return plain.toString("utf8"); +} + +function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncryptedMaterial { + if ( + value && + typeof value === "object" && + value.scheme === "local_encrypted_v1" && + typeof value.iv === "string" && + typeof value.tag === "string" && + typeof value.ciphertext === "string" + ) { + return value as LocalEncryptedMaterial; + } + throw badRequest("Invalid local_encrypted secret material"); +} + +export const localEncryptedProvider: SecretProviderModule = { + id: "local_encrypted", + descriptor: { + id: "local_encrypted", + label: "Local encrypted (default)", + requiresExternalRef: false, + }, + async createVersion(input) { + const masterKey = loadOrCreateMasterKey(); + return { + material: encryptValue(masterKey, input.value), + valueSha256: sha256Hex(input.value), + externalRef: null, + }; + }, + async resolveVersion(input) { + const masterKey = loadOrCreateMasterKey(); + return decryptValue(masterKey, asLocalEncryptedMaterial(input.material)); + }, +}; diff --git a/server/src/secrets/provider-registry.ts b/server/src/secrets/provider-registry.ts new file mode 100644 index 00000000..d928b88a --- /dev/null +++ b/server/src/secrets/provider-registry.ts @@ -0,0 +1,30 @@ +import type { SecretProvider, SecretProviderDescriptor } from "@paperclip/shared"; +import { localEncryptedProvider } from "./local-encrypted-provider.js"; +import { + awsSecretsManagerProvider, + gcpSecretManagerProvider, + vaultProvider, +} from "./external-stub-providers.js"; +import type { SecretProviderModule } from "./types.js"; +import { unprocessable } from "../errors.js"; + +const providers: SecretProviderModule[] = [ + localEncryptedProvider, + awsSecretsManagerProvider, + gcpSecretManagerProvider, + vaultProvider, +]; + +const providerById = new Map( + providers.map((provider) => [provider.id, provider]), +); + +export function getSecretProvider(id: SecretProvider): SecretProviderModule { + const provider = providerById.get(id); + if (!provider) throw unprocessable(`Unsupported secret provider: ${id}`); + return provider; +} + +export function listSecretProviders(): SecretProviderDescriptor[] { + return providers.map((provider) => provider.descriptor); +} diff --git a/server/src/secrets/types.ts b/server/src/secrets/types.ts new file mode 100644 index 00000000..13a7270b --- /dev/null +++ b/server/src/secrets/types.ts @@ -0,0 +1,22 @@ +import type { SecretProvider, SecretProviderDescriptor } from "@paperclip/shared"; + +export interface StoredSecretVersionMaterial { + [key: string]: unknown; +} + +export interface SecretProviderModule { + id: SecretProvider; + descriptor: SecretProviderDescriptor; + createVersion(input: { + value: string; + externalRef: string | null; + }): Promise<{ + material: StoredSecretVersionMaterial; + valueSha256: string; + externalRef: string | null; + }>; + resolveVersion(input: { + material: StoredSecretVersionMaterial; + externalRef: string | null; + }): Promise; +} diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 3b37a5e6..971d9c62 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -1,6 +1,7 @@ import type { Db } from "@paperclip/db"; import { activityLog } from "@paperclip/db"; import { publishLiveEvent } from "./live-events.js"; +import { sanitizeRecord } from "../redaction.js"; export interface LogActivityInput { companyId: string; @@ -15,6 +16,7 @@ export interface LogActivityInput { } export async function logActivity(db: Db, input: LogActivityInput) { + const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null; await db.insert(activityLog).values({ companyId: input.companyId, actorType: input.actorType, @@ -24,7 +26,7 @@ export async function logActivity(db: Db, input: LogActivityInput) { entityId: input.entityId, agentId: input.agentId ?? null, runId: input.runId ?? null, - details: input.details ?? null, + details: sanitizedDetails, }); publishLiveEvent({ @@ -38,7 +40,7 @@ export async function logActivity(db: Db, input: LogActivityInput) { entityId: input.entityId, agentId: input.agentId ?? null, runId: input.runId ?? null, - details: input.details ?? null, + details: sanitizedDetails, }, }); } diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index ec32d3ed..2cb62d4f 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -13,6 +13,7 @@ import { } from "@paperclip/db"; import { conflict, notFound, unprocessable } from "../errors.js"; import { normalizeAgentPermissions } from "./agent-permissions.js"; +import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -60,6 +61,18 @@ function jsonEqual(left: unknown, right: unknown): boolean { function buildConfigSnapshot( row: Pick, ): AgentConfigSnapshot { + const adapterConfig = + typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig) + ? sanitizeRecord(row.adapterConfig as Record) + : {}; + const runtimeConfig = + typeof row.runtimeConfig === "object" && row.runtimeConfig !== null && !Array.isArray(row.runtimeConfig) + ? sanitizeRecord(row.runtimeConfig as Record) + : {}; + const metadata = + typeof row.metadata === "object" && row.metadata !== null && !Array.isArray(row.metadata) + ? sanitizeRecord(row.metadata as Record) + : row.metadata ?? null; return { name: row.name, role: row.role, @@ -67,13 +80,20 @@ function buildConfigSnapshot( reportsTo: row.reportsTo, capabilities: row.capabilities, adapterType: row.adapterType, - adapterConfig: row.adapterConfig ?? {}, - runtimeConfig: row.runtimeConfig ?? {}, + adapterConfig, + runtimeConfig, budgetMonthlyCents: row.budgetMonthlyCents, - metadata: row.metadata ?? null, + metadata, }; } +function containsRedactedMarker(value: unknown): boolean { + if (value === REDACTED_EVENT_VALUE) return true; + if (Array.isArray(value)) return value.some((item) => containsRedactedMarker(item)); + if (typeof value !== "object" || value === null) return false; + return Object.values(value as Record).some((entry) => containsRedactedMarker(entry)); +} + function hasConfigPatchFields(data: Partial) { return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field)); } @@ -374,6 +394,9 @@ export function agentService(db: Db) { .where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId))) .then((rows) => rows[0] ?? null); if (!revision) return null; + if (containsRedactedMarker(revision.afterConfig)) { + throw unprocessable("Cannot roll back a revision that contains redacted secret values"); + } const patch = configPatchFromSnapshot(revision.afterConfig); return updateAgent(id, patch, { diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 3e271aba..c6137be8 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -17,6 +17,7 @@ import { approvalComments, approvals, activityLog, + companySecrets, } from "@paperclip/db"; export function companyService(db: Db) { @@ -66,6 +67,7 @@ export function companyService(db: Db) { await tx.delete(costEvents).where(eq(costEvents.companyId, id)); await tx.delete(approvalComments).where(eq(approvalComments.companyId, id)); await tx.delete(approvals).where(eq(approvals.companyId, id)); + await tx.delete(companySecrets).where(eq(companySecrets.companyId, id)); await tx.delete(issues).where(eq(issues.companyId, id)); await tx.delete(goals).where(eq(goals.companyId, id)); await tx.delete(projects).where(eq(projects.companyId, id)); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2ded0a68..c8a120d1 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -17,6 +17,7 @@ import { getServerAdapter, runningProcesses } from "../adapters/index.js"; import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; +import { secretService } from "./secrets.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; @@ -153,6 +154,7 @@ function resolveNextSessionState(input: { export function heartbeatService(db: Db) { const runLogStore = getRunLogStore(); + const secretsSvc = secretService(db); async function getAgent(agentId: string) { return db @@ -689,6 +691,10 @@ export function heartbeatService(db: Db) { }; const config = parseObject(agent.adapterConfig); + const resolvedConfig = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + config, + ); const onAdapterMeta = async (meta: AdapterInvocationMeta) => { await appendRunEvent(currentRun, seq++, { eventType: "adapter.invoke", @@ -718,7 +724,7 @@ export function heartbeatService(db: Db) { runId: run.id, agent, runtime: runtimeForAdapter, - config, + config: resolvedConfig, context, onLog, onMeta: onAdapterMeta, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2384ae35..08ea8652 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -6,6 +6,7 @@ export { issueApprovalService } from "./issue-approvals.js"; export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; export { approvalService } from "./approvals.js"; +export { secretService } from "./secrets.js"; export { costService } from "./costs.js"; export { heartbeatService } from "./heartbeat.js"; export { dashboardService } from "./dashboard.js"; diff --git a/server/src/services/issue-approvals.ts b/server/src/services/issue-approvals.ts index 335a7ea4..c737644a 100644 --- a/server/src/services/issue-approvals.ts +++ b/server/src/services/issue-approvals.ts @@ -2,6 +2,7 @@ import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclip/db"; import { approvals, issueApprovals, issues } from "@paperclip/db"; import { notFound, unprocessable } from "../errors.js"; +import { redactEventPayload } from "../redaction.js"; interface LinkActor { agentId?: string | null; @@ -44,7 +45,7 @@ export function issueApprovalService(db: Db) { const issue = await getIssue(issueId); if (!issue) throw notFound("Issue not found"); - return db + const result = await db .select({ id: approvals.id, companyId: approvals.companyId, @@ -63,6 +64,10 @@ export function issueApprovalService(db: Db) { .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) .where(eq(issueApprovals.issueId, issueId)) .orderBy(desc(issueApprovals.createdAt)); + return result.map((approval) => ({ + ...approval, + payload: redactEventPayload(approval.payload) ?? {}, + })); }, listIssuesForApproval: async (approvalId: string) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ef902494..24bcfeb7 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -71,6 +71,7 @@ export function issueService(db: Db) { conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + conditions.push(isNull(issues.hiddenAt)); const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; return db.select().from(issues).where(and(...conditions)).orderBy(asc(priorityOrder), desc(issues.updatedAt)); diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts new file mode 100644 index 00000000..148e8b10 --- /dev/null +++ b/server/src/services/secrets.ts @@ -0,0 +1,364 @@ +import { and, desc, eq } from "drizzle-orm"; +import type { Db } from "@paperclip/db"; +import { companySecrets, companySecretVersions } from "@paperclip/db"; +import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclip/shared"; +import { envBindingSchema } from "@paperclip/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; +import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js"; + +const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SENSITIVE_ENV_KEY_RE = + /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; +const REDACTED_SENTINEL = "***REDACTED***"; + +type CanonicalEnvBinding = + | { type: "plain"; value: string } + | { type: "secret_ref"; secretId: string; version: number | "latest" }; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function isSensitiveEnvKey(key: string) { + return SENSITIVE_ENV_KEY_RE.test(key); +} + +function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding { + if (typeof binding === "string") { + return { type: "plain", value: binding }; + } + if (binding.type === "plain") { + return { type: "plain", value: String(binding.value) }; + } + return { + type: "secret_ref", + secretId: binding.secretId, + version: binding.version ?? "latest", + }; +} + +export function secretService(db: Db) { + async function getById(id: string) { + return db + .select() + .from(companySecrets) + .where(eq(companySecrets.id, id)) + .then((rows) => rows[0] ?? null); + } + + async function getByName(companyId: string, name: string) { + return db + .select() + .from(companySecrets) + .where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.name, name))) + .then((rows) => rows[0] ?? null); + } + + async function getSecretVersion(secretId: string, version: number) { + return db + .select() + .from(companySecretVersions) + .where( + and( + eq(companySecretVersions.secretId, secretId), + eq(companySecretVersions.version, version), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function assertSecretInCompany(companyId: string, secretId: string) { + const secret = await getById(secretId); + if (!secret) throw notFound("Secret not found"); + if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); + return secret; + } + + async function resolveSecretValue( + companyId: string, + secretId: string, + version: number | "latest", + ): Promise { + const secret = await assertSecretInCompany(companyId, secretId); + const resolvedVersion = version === "latest" ? secret.latestVersion : version; + const versionRow = await getSecretVersion(secret.id, resolvedVersion); + if (!versionRow) throw notFound("Secret version not found"); + const provider = getSecretProvider(secret.provider as SecretProvider); + return provider.resolveVersion({ + material: versionRow.material as Record, + externalRef: secret.externalRef, + }); + } + + async function normalizeEnvConfig( + companyId: string, + envValue: unknown, + opts?: { strictMode?: boolean }, + ): Promise { + const record = asRecord(envValue); + if (!record) throw unprocessable("adapterConfig.env must be an object"); + + const normalized: AgentEnvConfig = {}; + for (const [key, rawBinding] of Object.entries(record)) { + if (!ENV_KEY_RE.test(key)) { + throw unprocessable(`Invalid environment variable name: ${key}`); + } + + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) { + throw unprocessable(`Invalid environment binding for key: ${key}`); + } + + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "plain") { + if (opts?.strictMode && isSensitiveEnvKey(key) && binding.value.trim().length > 0) { + throw unprocessable( + `Strict secret mode requires secret references for sensitive key: ${key}`, + ); + } + if (binding.value === REDACTED_SENTINEL) { + throw unprocessable(`Refusing to persist redacted placeholder for key: ${key}`); + } + normalized[key] = binding; + continue; + } + + await assertSecretInCompany(companyId, binding.secretId); + normalized[key] = { + type: "secret_ref", + secretId: binding.secretId, + version: binding.version, + }; + } + return normalized; + } + + async function normalizeAdapterConfigForPersistenceInternal( + companyId: string, + adapterConfig: Record, + opts?: { strictMode?: boolean }, + ) { + const normalized = { ...adapterConfig }; + if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { + return normalized; + } + normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts); + return normalized; + } + + return { + listProviders: () => listSecretProviders(), + + list: (companyId: string) => + db + .select() + .from(companySecrets) + .where(eq(companySecrets.companyId, companyId)) + .orderBy(desc(companySecrets.createdAt)), + + getById, + getByName, + + create: async ( + companyId: string, + input: { + name: string; + provider: SecretProvider; + value: string; + description?: string | null; + externalRef?: string | null; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const existing = await getByName(companyId, input.name); + if (existing) throw conflict(`Secret already exists: ${input.name}`); + + const provider = getSecretProvider(input.provider); + const prepared = await provider.createVersion({ + value: input.value, + externalRef: input.externalRef ?? null, + }); + + return db.transaction(async (tx) => { + const secret = await tx + .insert(companySecrets) + .values({ + companyId, + name: input.name, + provider: input.provider, + externalRef: prepared.externalRef, + latestVersion: 1, + description: input.description ?? null, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + + await tx.insert(companySecretVersions).values({ + secretId: secret.id, + version: 1, + material: prepared.material, + valueSha256: prepared.valueSha256, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }); + + return secret; + }); + }, + + rotate: async ( + secretId: string, + input: { value: string; externalRef?: string | null }, + actor?: { userId?: string | null; agentId?: string | null }, + ) => { + const secret = await getById(secretId); + if (!secret) throw notFound("Secret not found"); + const provider = getSecretProvider(secret.provider as SecretProvider); + const nextVersion = secret.latestVersion + 1; + const prepared = await provider.createVersion({ + value: input.value, + externalRef: input.externalRef ?? secret.externalRef ?? null, + }); + + return db.transaction(async (tx) => { + await tx.insert(companySecretVersions).values({ + secretId: secret.id, + version: nextVersion, + material: prepared.material, + valueSha256: prepared.valueSha256, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }); + + const updated = await tx + .update(companySecrets) + .set({ + latestVersion: nextVersion, + externalRef: prepared.externalRef, + updatedAt: new Date(), + }) + .where(eq(companySecrets.id, secret.id)) + .returning() + .then((rows) => rows[0] ?? null); + + if (!updated) throw notFound("Secret not found"); + return updated; + }); + }, + + update: async ( + secretId: string, + patch: { name?: string; description?: string | null; externalRef?: string | null }, + ) => { + const secret = await getById(secretId); + if (!secret) throw notFound("Secret not found"); + + if (patch.name && patch.name !== secret.name) { + const duplicate = await getByName(secret.companyId, patch.name); + if (duplicate && duplicate.id !== secret.id) { + throw conflict(`Secret already exists: ${patch.name}`); + } + } + + return db + .update(companySecrets) + .set({ + name: patch.name ?? secret.name, + description: + patch.description === undefined ? secret.description : patch.description, + externalRef: + patch.externalRef === undefined ? secret.externalRef : patch.externalRef, + updatedAt: new Date(), + }) + .where(eq(companySecrets.id, secret.id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + remove: async (secretId: string) => { + const secret = await getById(secretId); + if (!secret) return null; + await db.delete(companySecrets).where(eq(companySecrets.id, secretId)); + return secret; + }, + + normalizeAdapterConfigForPersistence: async ( + companyId: string, + adapterConfig: Record, + opts?: { strictMode?: boolean }, + ) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts), + + normalizeHireApprovalPayloadForPersistence: async ( + companyId: string, + payload: Record, + opts?: { strictMode?: boolean }, + ) => { + const normalized = { ...payload }; + const adapterConfig = asRecord(payload.adapterConfig); + if (adapterConfig) { + normalized.adapterConfig = await normalizeAdapterConfigForPersistenceInternal( + companyId, + adapterConfig, + opts, + ); + } + return normalized; + }, + + resolveEnvBindings: async (companyId: string, envValue: unknown) => { + const record = asRecord(envValue); + if (!record) return {} as Record; + const resolved: Record = {}; + + for (const [key, rawBinding] of Object.entries(record)) { + if (!ENV_KEY_RE.test(key)) { + throw unprocessable(`Invalid environment variable name: ${key}`); + } + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) { + throw unprocessable(`Invalid environment binding for key: ${key}`); + } + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "plain") { + resolved[key] = binding.value; + } else { + resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version); + } + } + return resolved; + }, + + resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record) => { + const resolved = { ...adapterConfig }; + if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { + return resolved; + } + const record = asRecord(adapterConfig.env); + if (!record) { + resolved.env = {}; + return resolved; + } + const env: Record = {}; + for (const [key, rawBinding] of Object.entries(record)) { + if (!ENV_KEY_RE.test(key)) { + throw unprocessable(`Invalid environment variable name: ${key}`); + } + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) { + throw unprocessable(`Invalid environment binding for key: ${key}`); + } + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "plain") { + env[key] = binding.value; + } else { + env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version); + } + } + resolved.env = env; + return resolved; + }, + }; +}