Files
paperclip/server/src/services/secrets.ts
Forgotten 11901ae5d8 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>
2026-02-19 15:43:52 -06:00

365 lines
12 KiB
TypeScript

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