3ea3020a76
secretsSvc.usages() previously only scanned agent env bindings. Skills can
reference a secret via metadata.sourceAuthSecretId (set by the PAT auth
feature), so removing a secret without checking those references could orphan
a sibling skill's PAT pointer.
- Extend usages() to return { agents, skills } with both reference kinds.
- Update remove() to block when either array is non-empty.
- /secrets/:id/usages now responds with { agents, skills }.
- The delete dialog displays both kinds inline so the operator knows which
references to detach first.
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
import { and, desc, eq, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { companySecrets, companySecretVersions, companySkills } from "@paperclipai/db";
|
|
import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclipai/shared";
|
|
import { envBindingSchema } from "@paperclipai/shared";
|
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
|
import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js";
|
|
import { agentService } from "./agents.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) {
|
|
type NormalizeEnvOptions = {
|
|
strictMode?: boolean;
|
|
fieldPath?: string;
|
|
};
|
|
|
|
type SecretUsageAgent = { id: string; name: string; envKeys: string[] };
|
|
type SecretUsageSkill = { id: string; name: string; slug: string };
|
|
type SecretUsages = { agents: SecretUsageAgent[]; skills: SecretUsageSkill[] };
|
|
|
|
async function getById(id: string) {
|
|
return db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function usages(companyId: string, secretId: string): Promise<SecretUsages> {
|
|
const agents = agentService(db);
|
|
const allAgents = await agents.list(companyId);
|
|
const agentRefs: SecretUsageAgent[] = [];
|
|
for (const agent of allAgents) {
|
|
const config = asRecord(agent.adapterConfig);
|
|
if (!config) continue;
|
|
const env = asRecord(config.env);
|
|
if (!env) continue;
|
|
const matchingKeys: string[] = [];
|
|
for (const [key, rawBinding] of Object.entries(env)) {
|
|
const binding = asRecord(rawBinding);
|
|
if (!binding) continue;
|
|
if (binding.type === "secret_ref" && binding.secretId === secretId) {
|
|
matchingKeys.push(key);
|
|
}
|
|
}
|
|
if (matchingKeys.length > 0) {
|
|
agentRefs.push({ id: agent.id, name: agent.name, envKeys: matchingKeys });
|
|
}
|
|
}
|
|
|
|
const skillRows = await db
|
|
.select({
|
|
id: companySkills.id,
|
|
name: companySkills.name,
|
|
slug: companySkills.slug,
|
|
})
|
|
.from(companySkills)
|
|
.where(and(
|
|
eq(companySkills.companyId, companyId),
|
|
sql`${companySkills.metadata} ->> 'sourceAuthSecretId' = ${secretId}`,
|
|
));
|
|
const skillRefs: SecretUsageSkill[] = skillRows.map((row) => ({
|
|
id: row.id,
|
|
name: row.name,
|
|
slug: row.slug,
|
|
}));
|
|
|
|
return { agents: agentRefs, skills: skillRefs };
|
|
}
|
|
|
|
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?: NormalizeEnvOptions,
|
|
): Promise<AgentEnvConfig> {
|
|
const record = asRecord(envValue);
|
|
if (!record) throw unprocessable(`${opts?.fieldPath ?? "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,
|
|
resolveSecretValue,
|
|
|
|
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;
|
|
const used = await usages(secret.companyId, secretId);
|
|
if (used.agents.length > 0 || used.skills.length > 0) {
|
|
const agentNames = used.agents.map((agent) => agent.name);
|
|
const skillNames = used.skills.map((skill) => skill.name);
|
|
const names = [...agentNames, ...skillNames].sort((left, right) => left.localeCompare(right));
|
|
throw unprocessable(
|
|
`Cannot delete secret "${secret.name}" while it is still used by ${names.join(", ")}. Detach it from those references first.`,
|
|
{ secretId, usedByAgents: used.agents, usedBySkills: used.skills },
|
|
);
|
|
}
|
|
await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
|
|
return secret;
|
|
},
|
|
|
|
usages,
|
|
|
|
normalizeAdapterConfigForPersistence: async (
|
|
companyId: string,
|
|
adapterConfig: Record<string, unknown>,
|
|
opts?: { strictMode?: boolean },
|
|
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
|
|
|
|
normalizeEnvBindingsForPersistence: async (
|
|
companyId: string,
|
|
envValue: unknown,
|
|
opts?: NormalizeEnvOptions,
|
|
) => normalizeEnvConfig(companyId, envValue, 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): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
|
|
const record = asRecord(envValue);
|
|
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>() };
|
|
const resolved: Record<string, string> = {};
|
|
const secretKeys = new Set<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);
|
|
secretKeys.add(key);
|
|
}
|
|
}
|
|
return { env: resolved, secretKeys };
|
|
},
|
|
|
|
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
|
|
const resolved = { ...adapterConfig };
|
|
const secretKeys = new Set<string>();
|
|
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
|
return { config: resolved, secretKeys };
|
|
}
|
|
const record = asRecord(adapterConfig.env);
|
|
if (!record) {
|
|
resolved.env = {};
|
|
return { config: resolved, secretKeys };
|
|
}
|
|
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);
|
|
secretKeys.add(key);
|
|
}
|
|
}
|
|
resolved.env = env;
|
|
return { config: resolved, secretKeys };
|
|
},
|
|
};
|
|
}
|