From e38bf7f05d28ea7154ba2f4a8b55f3b2a6d13893 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 6 May 2026 14:53:47 -0500 Subject: [PATCH] Show secret reference counts Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + packages/shared/src/types/index.ts | 2 + packages/shared/src/types/secrets.ts | 13 ++ server/src/__tests__/secrets-service.test.ts | 43 ++++ server/src/routes/secrets.ts | 2 +- server/src/services/secrets.ts | 220 ++++++++++++++++++- ui/src/api/secrets.ts | 4 +- ui/src/pages/Secrets.render.test.tsx | 89 ++++++++ ui/src/pages/Secrets.tsx | 73 +++++- 9 files changed, 434 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4e7e702c76b..08f775510f2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -539,6 +539,8 @@ export type { SecretProviderConfigHealthDetails, SecretProviderConfigHealthResponse, CompanySecretBinding, + CompanySecretBindingTarget, + CompanySecretUsageBinding, CompanySecretVersion, SecretAccessEvent, SecretAccessOutcome, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e05b308ab24..4afa0a046e7 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -249,6 +249,8 @@ export type { SecretProviderConfigHealthDetails, SecretProviderConfigHealthResponse, CompanySecretBinding, + CompanySecretBindingTarget, + CompanySecretUsageBinding, CompanySecretVersion, SecretAccessEvent, SecretAccessOutcome, diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 5f2d4db90a9..1cf151673f4 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -56,6 +56,7 @@ export interface CompanySecret { deletedAt: Date | null; createdByAgentId: string | null; createdByUserId: string | null; + referenceCount?: number; createdAt: Date; updatedAt: Date; } @@ -163,6 +164,18 @@ export interface CompanySecretBinding { updatedAt: Date; } +export interface CompanySecretBindingTarget { + type: SecretBindingTargetType; + id: string; + label: string; + href: string | null; + status: string | null; +} + +export interface CompanySecretUsageBinding extends CompanySecretBinding { + target: CompanySecretBindingTarget; +} + export interface SecretAccessEvent { id: string; companyId: string; diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 5ba3e6a08f4..be006ed31ed 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; import { + agents, companies, companySecretBindings, companySecretProviderConfigs, @@ -47,6 +48,7 @@ describeEmbeddedPostgres("secretService", () => { await db.delete(companySecretVersions); await db.delete(companySecrets); await db.delete(companySecretProviderConfigs); + await db.delete(agents); await db.delete(companies); }); @@ -123,6 +125,47 @@ describeEmbeddedPostgres("secretService", () => { ).rejects.toThrow(/already exists/i); }); + it("reports reference counts and resolves binding target labels", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `referenced-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + const [agent] = await db + .insert(agents) + .values({ + companyId, + name: "CodexCoder", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + }) + .returning(); + + await svc.syncEnvBindingsForTarget( + companyId, + { targetType: "agent", targetId: agent!.id }, + { + OPENAI_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + ); + + const listed = await svc.list(companyId); + expect(listed.find((row) => row.id === secret.id)?.referenceCount).toBe(1); + + const bindings = await svc.listBindingReferences(companyId, secret.id); + expect(bindings).toHaveLength(1); + expect(bindings[0]?.target).toMatchObject({ + type: "agent", + id: agent!.id, + label: "CodexCoder", + href: "/agents/codexcoder", + status: "idle", + }); + }); + it("enforces binding context and records value-free access events", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index d39247a379f..4f211859db4 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -347,7 +347,7 @@ export function secretRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); - const bindings = await svc.listBindings(existing.companyId, existing.id); + const bindings = await svc.listBindingReferences(existing.companyId, existing.id); res.json({ secretId: existing.id, bindings }); }); diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 0bdeaa8de5f..c3a8060f666 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1,14 +1,21 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + agents, companySecretBindings, companySecretProviderConfigs, companySecrets, companySecretVersions, + environments, + heartbeatRuns, + issues, + projects, + routines, secretAccessEvents, } from "@paperclipai/db"; import type { AgentEnvConfig, + CompanySecretBindingTarget, EnvBinding, SecretBindingTargetType, SecretProvider, @@ -19,7 +26,10 @@ import type { } from "@paperclipai/shared"; import { createSecretProviderConfigSchema, + deriveProjectUrlKey, envBindingSchema, + isUuidLike, + normalizeAgentUrlKey, secretProviderConfigPayloadSchema, updateSecretProviderConfigSchema, } from "@paperclipai/shared"; @@ -497,6 +507,171 @@ export function secretService(db: Db) { return normalized; } + function collectTargetIds( + bindings: Array, + targetType: SecretBindingTargetType, + opts?: { uuidOnly?: boolean }, + ) { + return [ + ...new Set( + bindings + .filter((binding) => binding.targetType === targetType) + .map((binding) => binding.targetId) + .filter((id) => !opts?.uuidOnly || isUuidLike(id)), + ), + ]; + } + + function fallbackBindingTarget(binding: typeof companySecretBindings.$inferSelect): CompanySecretBindingTarget { + return { + type: binding.targetType as SecretBindingTargetType, + id: binding.targetId, + label: binding.targetId, + href: null, + status: null, + }; + } + + async function buildBindingTargetMap( + companyId: string, + bindings: Array, + ) { + const targetMap = new Map(); + const setTarget = (target: CompanySecretBindingTarget) => { + targetMap.set(`${target.type}:${target.id}`, target); + }; + + const agentIds = collectTargetIds(bindings, "agent", { uuidOnly: true }); + if (agentIds.length > 0) { + const rows = await db + .select({ + id: agents.id, + name: agents.name, + title: agents.title, + status: agents.status, + }) + .from(agents) + .where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))); + for (const row of rows) { + setTarget({ + type: "agent", + id: row.id, + label: row.title ? `${row.name} (${row.title})` : row.name, + href: `/agents/${normalizeAgentUrlKey(row.name) ?? row.id}`, + status: row.status, + }); + } + } + + const projectIds = collectTargetIds(bindings, "project", { uuidOnly: true }); + if (projectIds.length > 0) { + const rows = await db + .select({ + id: projects.id, + name: projects.name, + status: projects.status, + }) + .from(projects) + .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds))); + for (const row of rows) { + setTarget({ + type: "project", + id: row.id, + label: row.name, + href: `/projects/${deriveProjectUrlKey(row.name, row.id)}`, + status: row.status, + }); + } + } + + const environmentIds = collectTargetIds(bindings, "environment", { uuidOnly: true }); + if (environmentIds.length > 0) { + const rows = await db + .select({ + id: environments.id, + name: environments.name, + status: environments.status, + }) + .from(environments) + .where(and(eq(environments.companyId, companyId), inArray(environments.id, environmentIds))); + for (const row of rows) { + setTarget({ + type: "environment", + id: row.id, + label: row.name, + href: "/company/settings/environments", + status: row.status, + }); + } + } + + const routineIds = collectTargetIds(bindings, "routine", { uuidOnly: true }); + if (routineIds.length > 0) { + const rows = await db + .select({ + id: routines.id, + title: routines.title, + status: routines.status, + }) + .from(routines) + .where(and(eq(routines.companyId, companyId), inArray(routines.id, routineIds))); + for (const row of rows) { + setTarget({ + type: "routine", + id: row.id, + label: row.title, + href: `/routines/${row.id}`, + status: row.status, + }); + } + } + + const issueIds = collectTargetIds(bindings, "issue", { uuidOnly: true }); + if (issueIds.length > 0) { + const rows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds))); + for (const row of rows) { + setTarget({ + type: "issue", + id: row.id, + label: row.identifier ? `${row.identifier} ${row.title}` : row.title, + href: `/issues/${row.identifier ?? row.id}`, + status: row.status, + }); + } + } + + const runIds = collectTargetIds(bindings, "run", { uuidOnly: true }); + if (runIds.length > 0) { + const rows = await db + .select({ + id: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + status: heartbeatRuns.status, + }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runIds))); + for (const row of rows) { + setTarget({ + type: "run", + id: row.id, + label: `Run ${row.id.slice(0, 8)}`, + href: `/agents/${row.agentId}/runs/${row.id}`, + status: row.status, + }); + } + } + + return targetMap; + } + return { listProviders: () => listSecretProviders(), @@ -679,12 +854,28 @@ export function secretService(db: Db) { return { ...health, checkedAt }; }, - list: (companyId: string) => - db - .select() - .from(companySecrets) - .where(eq(companySecrets.companyId, companyId)) - .orderBy(desc(companySecrets.createdAt)), + list: async (companyId: string) => { + const [secrets, referenceCounts] = await Promise.all([ + db + .select() + .from(companySecrets) + .where(eq(companySecrets.companyId, companyId)) + .orderBy(desc(companySecrets.createdAt)), + db + .select({ + secretId: companySecretBindings.secretId, + count: sql`count(*)::int`, + }) + .from(companySecretBindings) + .where(eq(companySecretBindings.companyId, companyId)) + .groupBy(companySecretBindings.secretId), + ]); + const countsBySecretId = new Map(referenceCounts.map((row) => [row.secretId, row.count])); + return secrets.map((secret) => ({ + ...secret, + referenceCount: countsBySecretId.get(secret.id) ?? 0, + })); + }, listBindings: (companyId: string, secretId?: string) => db @@ -697,6 +888,21 @@ export function secretService(db: Db) { ) .orderBy(desc(companySecretBindings.createdAt)), + listBindingReferences: async (companyId: string, secretId: string) => { + const bindings = await db + .select() + .from(companySecretBindings) + .where(and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId))) + .orderBy(desc(companySecretBindings.createdAt)); + const targetMap = await buildBindingTargetMap(companyId, bindings); + return bindings.map((binding) => ({ + ...binding, + target: + targetMap.get(`${binding.targetType}:${binding.targetId}`) ?? + fallbackBindingTarget(binding), + })); + }, + listAccessEvents: (companyId: string, secretId: string) => db .select() diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index 0c198a2c6fb..3fc43b18738 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -1,6 +1,6 @@ import type { CompanySecret, - CompanySecretBinding, + CompanySecretUsageBinding, CompanySecretProviderConfig, SecretAccessEvent, SecretManagedMode, @@ -14,7 +14,7 @@ import { api } from "./client"; export interface SecretUsageResponse { secretId: string; - bindings: CompanySecretBinding[]; + bindings: CompanySecretUsageBinding[]; } export interface CreateSecretInput { diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index 29bf8be995b..954b240940c 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -2,6 +2,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -216,4 +217,92 @@ describe("Secrets page layout", () => { vaultRoot.unmount(); }); }); + + it("opens reference details from the secrets table count", async () => { + mockSecretsApi.list.mockResolvedValue([ + { + id: "secret-openai", + companyId: "company-1", + key: "openai_api_key", + name: "OPENAI_API_KEY", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: "user-1", + referenceCount: 2, + createdAt: new Date("2026-05-06T00:00:00.000Z"), + updatedAt: new Date("2026-05-06T00:00:00.000Z"), + }, + ]); + mockSecretsApi.usage.mockResolvedValue({ + secretId: "secret-openai", + bindings: [ + { + id: "binding-agent", + companyId: "company-1", + secretId: "secret-openai", + targetType: "agent", + targetId: "agent-1", + configPath: "env.OPENAI_API_KEY", + versionSelector: "latest", + required: true, + label: null, + target: { + type: "agent", + id: "agent-1", + label: "CodexCoder", + href: "/agents/codexcoder", + status: "idle", + }, + createdAt: new Date("2026-05-06T00:00:00.000Z"), + updatedAt: new Date("2026-05-06T00:00:00.000Z"), + }, + ], + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + const referencesButton = container.querySelector( + 'button[aria-label="View references for OPENAI_API_KEY"]', + ) as HTMLButtonElement | null; + expect(referencesButton?.textContent).toBe("2"); + + await act(async () => { + referencesButton?.click(); + }); + await flushReact(); + + expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai"); + expect(document.body.textContent).toContain("Secret references"); + expect(document.body.textContent).toContain("CodexCoder"); + expect(document.body.textContent).toContain("env.OPENAI_API_KEY"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 9e138a22d92..4a239967df2 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -24,9 +24,10 @@ import { Filter, Info, } from "lucide-react"; +import { Link } from "react-router-dom"; import type { CompanySecret, - CompanySecretBinding, + CompanySecretUsageBinding, CompanySecretProviderConfig, SecretAccessEvent, SecretManagedMode, @@ -348,6 +349,7 @@ export function Secrets() { const [statusFilter, setStatusFilter] = useState("active"); const [providerFilter, setProviderFilter] = useState("all"); const [selectedSecretId, setSelectedSecretId] = useState(null); + const [usageDialogSecretId, setUsageDialogSecretId] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [createMode, setCreateMode] = useState("managed"); const [createForm, setCreateForm] = useState({ @@ -418,6 +420,10 @@ export function Secrets() { () => secrets.find((secret) => secret.id === selectedSecretId) ?? null, [secrets, selectedSecretId], ); + const usageDialogSecret = useMemo( + () => secrets.find((secret) => secret.id === usageDialogSecretId) ?? null, + [secrets, usageDialogSecretId], + ); const selectedCreateProvider = useMemo( () => providers.find((provider) => provider.id === createForm.provider) ?? null, [providers, createForm.provider], @@ -484,6 +490,14 @@ export function Secrets() { enabled: Boolean(selectedSecret), }); + const usageDialogQuery = useQuery({ + queryKey: usageDialogSecret + ? queryKeys.secrets.usage(usageDialogSecret.id) + : ["secrets", "usage-dialog", "__disabled__"], + queryFn: () => secretsApi.usage(usageDialogSecret!.id), + enabled: Boolean(usageDialogSecret), + }); + function invalidateAll(extraIds: string[] = []) { if (!selectedCompanyId) return; queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); @@ -812,6 +826,7 @@ export function Secrets() { Version Last rotated Last resolved + References Reference @@ -847,6 +862,20 @@ export function Secrets() { {formatRelative(secret.lastResolvedAt)} + + + {secret.managedMode === "external_reference" ? ( @@ -1009,6 +1038,28 @@ export function Secrets() { + !open && setUsageDialogSecretId(null)} + > + + + Secret references + + {usageDialogSecret + ? `${usageDialogSecret.name} is referenced by ${usageDialogSecret.referenceCount ?? 0} ${ + (usageDialogSecret.referenceCount ?? 0) === 1 ? "place" : "places" + }.` + : null} + + + + + + @@ -1948,7 +1999,7 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo ); } -function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretBinding[] }) { +function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: CompanySecretUsageBinding[] }) { if (loading) { return
Loading…
; } @@ -1966,10 +2017,24 @@ function SecretUsageTab({ loading, bindings }: { loading: boolean; bindings: Com key={binding.id} className="rounded-md border border-border bg-muted/30 p-2 text-xs" > -
- {binding.targetType} +
+ {binding.target.type} v{binding.versionSelector}
+
+ {binding.target.href ? ( + + {binding.target.label} + + ) : ( + {binding.target.label} + )} + {binding.target.status ? ( + + {binding.target.status.replaceAll("_", " ")} + + ) : null} +
{binding.targetId}