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

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