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:
Forgotten
2026-02-19 15:43:52 -06:00
parent d26b67ebc3
commit 11901ae5d8
22 changed files with 1083 additions and 61 deletions

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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) => {

View File

@@ -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";

View 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;
}