forked from farhoodlabs/paperclip
feat(secrets): company secrets management UI
New /company/settings/secrets page with create, rotate, edit, and delete flows. Adds a Secrets entry to the company settings sidebar (and tab nav). Each row shows name, description, version, and time since last rotation; per-row actions open dialogs for rotate (textarea for new value), edit (name + description), and delete (confirmation). Server-side adds secretService.usages() to enumerate agents that reference a secret via env bindings, and rejects deletion when any usage exists. The delete dialog reads the blocking usage list from the error body and renders it inline so the user knows which agents to detach first.
This commit is contained in:
@@ -132,6 +132,19 @@ export function secretRoutes(db: Db) {
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.get("/secrets/:id/usages", 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 agents = await svc.usages(existing.companyId, id);
|
||||
res.json({ agents });
|
||||
});
|
||||
|
||||
router.delete("/secrets/:id", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclipai/sh
|
||||
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 =
|
||||
@@ -44,6 +45,8 @@ export function secretService(db: Db) {
|
||||
fieldPath?: string;
|
||||
};
|
||||
|
||||
type SecretUsageAgent = { id: string; name: string; envKeys: string[] };
|
||||
|
||||
async function getById(id: string) {
|
||||
return db
|
||||
.select()
|
||||
@@ -52,6 +55,30 @@ export function secretService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function usages(companyId: string, secretId: string): Promise<SecretUsageAgent[]> {
|
||||
const agents = agentService(db);
|
||||
const allAgents = await agents.list(companyId);
|
||||
const out: 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) {
|
||||
out.push({ id: agent.id, name: agent.name, envKeys: matchingKeys });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function getByName(companyId: string, name: string) {
|
||||
return db
|
||||
.select()
|
||||
@@ -287,10 +314,20 @@ export function secretService(db: Db) {
|
||||
remove: async (secretId: string) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) return null;
|
||||
const used = await usages(secret.companyId, secretId);
|
||||
if (used.length > 0) {
|
||||
const names = used.map((agent) => agent.name).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 agents first.`,
|
||||
{ secretId, usedByAgents: used },
|
||||
);
|
||||
}
|
||||
await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
|
||||
return secret;
|
||||
},
|
||||
|
||||
usages,
|
||||
|
||||
normalizeAdapterConfigForPersistence: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
|
||||
Reference in New Issue
Block a user