Implement secrets service with local encryption, redaction, and runtime resolution
Add AES-256-GCM local encrypted secrets provider with auto-generated master key, stub providers for AWS/GCP/Vault, and a secrets service that normalizes adapter configs (converting sensitive inline values to secret refs in strict mode) and resolves secret refs back to plain values at runtime. Extract redaction utilities from agent routes into shared module. Redact sensitive values in activity logs, config revisions, and approval payloads. Block rollback of revisions containing redacted secrets. Filter hidden issues from list queries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
66
server/src/__tests__/redaction.test.ts
Normal file
66
server/src/__tests__/redaction.test.ts
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
59
server/src/redaction.ts
Normal file
59
server/src/redaction.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const redacted: Record<string, unknown> = {};
|
||||
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<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (!payload) return null;
|
||||
if (!isPlainObject(payload)) return payload;
|
||||
return sanitizeRecord(payload);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
}
|
||||
|
||||
function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
|
||||
const redacted: Record<string, unknown> = {};
|
||||
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<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (!payload) return null;
|
||||
if (Array.isArray(payload) || typeof payload !== "object") {
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
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<string, unknown> | 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<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
|
||||
const changedTopLevelKeys = Object.keys(patch).sort();
|
||||
const details: Record<string, unknown> = { 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<ReturnType<typeof svc.getById>>) {
|
||||
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<string, unknown>),
|
||||
{ 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<string, unknown>,
|
||||
) ?? {};
|
||||
const requestedRuntimeConfig =
|
||||
redactEventPayload(
|
||||
(normalizedHireInput.runtimeConfig ?? agent.runtimeConfig) as Record<string, unknown>,
|
||||
) ?? {};
|
||||
const requestedMetadata =
|
||||
redactEventPayload(
|
||||
((normalizedHireInput.metadata ?? agent.metadata ?? {}) as Record<string, unknown>),
|
||||
) ?? {};
|
||||
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<string, unknown>),
|
||||
{ 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<string, unknown>) };
|
||||
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);
|
||||
|
||||
@@ -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<T extends { payload: Record<string, unknown> }>(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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
165
server/src/routes/secrets.ts
Normal file
165
server/src/routes/secrets.ts
Normal file
@@ -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;
|
||||
}
|
||||
32
server/src/secrets/external-stub-providers.ts
Normal file
32
server/src/secrets/external-stub-providers.ts
Normal file
@@ -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");
|
||||
135
server/src/secrets/local-encrypted-provider.ts
Normal file
135
server/src/secrets/local-encrypted-provider.ts
Normal file
@@ -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));
|
||||
},
|
||||
};
|
||||
30
server/src/secrets/provider-registry.ts
Normal file
30
server/src/secrets/provider-registry.ts
Normal file
@@ -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<SecretProvider, SecretProviderModule>(
|
||||
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);
|
||||
}
|
||||
22
server/src/secrets/types.ts
Normal file
22
server/src/secrets/types.ts
Normal file
@@ -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<string>;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof agents.$inferSelect, ConfigRevisionField>,
|
||||
): AgentConfigSnapshot {
|
||||
const adapterConfig =
|
||||
typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig)
|
||||
? sanitizeRecord(row.adapterConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const runtimeConfig =
|
||||
typeof row.runtimeConfig === "object" && row.runtimeConfig !== null && !Array.isArray(row.runtimeConfig)
|
||||
? sanitizeRecord(row.runtimeConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const metadata =
|
||||
typeof row.metadata === "object" && row.metadata !== null && !Array.isArray(row.metadata)
|
||||
? sanitizeRecord(row.metadata as Record<string, unknown>)
|
||||
: 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<string, unknown>).some((entry) => containsRedactedMarker(entry));
|
||||
}
|
||||
|
||||
function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) {
|
||||
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, {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
364
server/src/services/secrets.ts
Normal file
364
server/src/services/secrets.ts
Normal file
@@ -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<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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<string, unknown>,
|
||||
externalRef: secret.externalRef,
|
||||
});
|
||||
}
|
||||
|
||||
async function normalizeEnvConfig(
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
opts?: { strictMode?: boolean },
|
||||
): Promise<AgentEnvConfig> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
opts?: { strictMode?: boolean },
|
||||
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
|
||||
|
||||
normalizeHireApprovalPayloadForPersistence: async (
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
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<string, string>;
|
||||
const resolved: Record<string, string> = {};
|
||||
|
||||
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<string, unknown>) => {
|
||||
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<string, string> = {};
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user