Show secret reference counts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
1 parent 20c0b61866
commit e38bf7f05d
9 files changed
+434 -14

No files matched your search

+2
View File
@@ -539,6 +539,8 @@ export type {
SecretProviderConfigHealthDetails,
SecretProviderConfigHealthResponse,
CompanySecretBinding,
CompanySecretBindingTarget,
CompanySecretUsageBinding,
CompanySecretVersion,
SecretAccessEvent,
SecretAccessOutcome,
+2
View File
@@ -249,6 +249,8 @@ export type {
SecretProviderConfigHealthDetails,
SecretProviderConfigHealthResponse,
CompanySecretBinding,
CompanySecretBindingTarget,
CompanySecretUsageBinding,
CompanySecretVersion,
SecretAccessEvent,
SecretAccessOutcome,
+13
View File
@@ -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;
@@ -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);
+1 -1
View File
@@ -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 });
});
+213 -7
View File
@@ -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<typeof companySecretBindings.$inferSelect>,
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<typeof companySecretBindings.$inferSelect>,
) {
const targetMap = new Map<string, CompanySecretBindingTarget>();
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<number>`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()
+2 -2
View File
@@ -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 {
+89
View File
@@ -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(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
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();
});
});
});
+69 -4
View File
@@ -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<SecretStatus | "all">("active");
const [providerFilter, setProviderFilter] = useState<SecretProvider | "all">("all");
const [selectedSecretId, setSelectedSecretId] = useState<string | null>(null);
const [usageDialogSecretId, setUsageDialogSecretId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [createMode, setCreateMode] = useState<CreateMode>("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() {
<th className="px-2 py-2 text-left font-medium">Version</th>
<th className="px-2 py-2 text-left font-medium">Last rotated</th>
<th className="px-2 py-2 text-left font-medium">Last resolved</th>
<th className="px-2 py-2 text-left font-medium">References</th>
<th className="px-2 py-2 text-left font-medium">Reference</th>
<th className="px-3 py-2"></th>
</tr>
@@ -847,6 +862,20 @@ export function Secrets() {
<td className="px-2 py-2.5 text-xs text-muted-foreground">
{formatRelative(secret.lastResolvedAt)}
</td>
<td className="px-2 py-2.5 text-xs">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
aria-label={`View references for ${secret.name}`}
onClick={(event) => {
event.stopPropagation();
setUsageDialogSecretId(secret.id);
}}
>
{secret.referenceCount ?? 0}
</Button>
</td>
<td className="px-2 py-2.5 text-xs">
{secret.managedMode === "external_reference" ? (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground">
@@ -1009,6 +1038,28 @@ export function Secrets() {
</SheetContent>
</Sheet>
<Dialog
open={Boolean(usageDialogSecret)}
onOpenChange={(open) => !open && setUsageDialogSecretId(null)}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Secret references</DialogTitle>
<DialogDescription>
{usageDialogSecret
? `${usageDialogSecret.name} is referenced by ${usageDialogSecret.referenceCount ?? 0} ${
(usageDialogSecret.referenceCount ?? 0) === 1 ? "place" : "places"
}.`
: null}
</DialogDescription>
</DialogHeader>
<SecretUsageTab
loading={usageDialogQuery.isPending}
bindings={usageDialogQuery.data?.bindings ?? []}
/>
</DialogContent>
</Dialog>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
@@ -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 <div className="py-6 text-center text-xs text-muted-foreground">Loading…</div>;
}
@@ -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"
>
<div className="flex items-center justify-between">
<span className="font-medium capitalize">{binding.targetType}</span>
<div className="flex items-center justify-between gap-2">
<span className="font-medium capitalize">{binding.target.type}</span>
<span className="font-mono text-muted-foreground">v{binding.versionSelector}</span>
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-2">
{binding.target.href ? (
<Link to={binding.target.href} className="truncate font-medium text-primary hover:underline">
{binding.target.label}
</Link>
) : (
<span className="truncate font-medium">{binding.target.label}</span>
)}
{binding.target.status ? (
<Badge variant="outline" className="h-5 px-1.5 text-[10px] font-normal">
{binding.target.status.replaceAll("_", " ")}
</Badge>
) : null}
</div>
<div className="font-mono text-[11px] text-muted-foreground break-all">
{binding.targetId}
</div>