History

show linked sources on the secrets page

Enrich the listSecrets API response with a linkedSources array per secret by joining credentials, source-credential bindings, and sources. Each secret row in the UI now displays clickable source name badges that link to the source detail page.

Rhys Sullivan committed 8 months ago

Commit 49b30351 parent 419deab4 files · +100 −5Browse files

Unified · Split

4 files
  1. apps/web/src/views/secrets.tsx +17 −0
  2. packages/control-plane/src/api/local/api.ts +10 −1
  3. packages/control-plane/src/api/local/http.ts +10 −3
  4. packages/control-plane/src/persistence/repos/secret-materials-repo.ts +63 −1
apps/web/src/views/secrets.tsx+17−0
@@ -1,4 +1,5 @@
11 import { useState, type ReactNode } from "react";
2+import { Link } from "@tanstack/react-router";
23 import {
34 type SecretListItem,
45 type Loadable,
@@ -247,6 +248,22 @@ function SecretRow(props: { · 242 unmodified lines
247248 {createdDate}
248249 </span>
249250 </div>
251+ {secret.linkedSources.length > 0 && (
252+ <div className="flex items-center gap-1.5 mt-1 flex-wrap">
253+ <span className="text-[10px] text-muted-foreground/50">Used by</span>
254+ {secret.linkedSources.map((ls) => (
255+ <Link
256+ key={ls.sourceId}
257+ to="/sources/$sourceId"
258+ params={{ sourceId: ls.sourceId }}
259+ search={{ tab: "model" }}
260+ className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-1.5 py-0.5 text-[10px] font-medium text-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
261+ >
262+ {ls.sourceName}
263+ </Link>
264+ ))}
265+ </div>
266+ )}
250267 </div>
251268 </div>
252269 <div className="flex items-center gap-1.5 shrink-0">
packages/control-plane/src/api/local/api.ts+10−1
@@ -1,5 +1,5 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2−import { LocalInstallationSchema } from "#schema";
2+import { LocalInstallationSchema, SecretMaterialPurposeSchema } from "#schema";
33 import * as Schema from "effect/Schema";
44
55 import {
@@ -25,12 +25,20 @@ export type InstanceConfig = typeof InstanceConfigSchema.Type; · 19 unmodified lines
2525
2626 // -- Secrets CRUD schemas ---------------------------------------------------
2727
28+export const SecretLinkedSourceSchema = Schema.Struct({
29+ sourceId: Schema.String,
30+ sourceName: Schema.String,
31+});
32+
33+export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type;
34+
2835 export const SecretListItemSchema = Schema.Struct({
2936 id: Schema.String,
3037 name: Schema.NullOr(Schema.String),
3138 purpose: Schema.String,
3239 createdAt: Schema.Number,
3340 updatedAt: Schema.Number,
41+ linkedSources: Schema.Array(SecretLinkedSourceSchema),
3442 });
3543
3644 export type SecretListItem = typeof SecretListItemSchema.Type;
@@ -38,6 +46,7 @@ export type SecretListItem = typeof SecretListItemSchema.Type; · 1 unmodified line
3846 export const CreateSecretPayloadSchema = Schema.Struct({
3947 name: Schema.String,
4048 value: Schema.String,
49+ purpose: Schema.optional(SecretMaterialPurposeSchema),
4150 });
4251
4352 export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type;
packages/control-plane/src/api/local/http.ts+10−3
@@ -90,13 +90,20 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 89 unmodified lines
9090 const rows = yield* store.secretMaterials.listAll().pipe(
9191 Effect.mapError(() => storageError("Failed listing secrets.")),
9292 );
93− return rows;
93+ const linkedSourcesMap = yield* store.secretMaterials.listLinkedSources().pipe(
94+ Effect.mapError(() => storageError("Failed loading linked sources.")),
95+ );
96+ return rows.map((row) => ({
97+ ...row,
98+ linkedSources: linkedSourcesMap.get(row.id) ?? [],
99+ }));
94100 }),
95101 )
96102 .handle("createSecret", ({ payload }) =>
97103 Effect.gen(function* () {
98104 const name = payload.name.trim();
99105 const value = payload.value;
106+ const purpose = payload.purpose ?? "auth_material";
100107
101108 if (name.length === 0) {
102109 return yield* Effect.fail(
@@ -115,7 +122,7 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 12 unmodified lines
115122 yield* store.secretMaterials.upsert({
116123 id,
117124 name,
118− purpose: "auth_material",
125+ purpose,
119126 value,
120127 createdAt: now,
121128 updatedAt: now,
@@ -127,7 +134,7 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 5 unmodified lines
127134 id,
128135 name,
129136 providerId: POSTGRES_SECRET_PROVIDER_ID,
130− purpose: "auth_material",
137+ purpose,
131138 createdAt: now,
132139 updatedAt: now,
133140 } satisfies CreateSecretResult;
packages/control-plane/src/persistence/repos/secret-materials-repo.ts+63−1
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
44 } from "#schema";
55 import * as Option from "effect/Option";
66 import { Schema } from "effect";
7−import { eq, desc } from "drizzle-orm";
7+import { eq, desc, or, and } from "drizzle-orm";
88
99 import type { DrizzleClient } from "../client";
1010 import type { DrizzleTables } from "../schema";
@@ -90,4 +90,66 @@ export const createSecretMaterialsRepo = ( · 79 unmodified lines
9090
9191 return deleted.length > 0;
9292 }),
93+
94+ /**
95+ * For each secret, find the sources that reference it via credentials.
96+ * Returns a map of secretId -> Array<{ sourceId, sourceName }>.
97+ */
98+ listLinkedSources: () =>
99+ client.use("rows.secret_materials.list_linked_sources", async (db) => {
100+ // Find all credentials that reference any postgres-stored secret
101+ // and join through bindings to sources.
102+ const rows = await db
103+ .select({
104+ tokenHandle: tables.credentialsTable.tokenHandle,
105+ tokenProviderId: tables.credentialsTable.tokenProviderId,
106+ refreshTokenHandle: tables.credentialsTable.refreshTokenHandle,
107+ refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId,
108+ sourceId: tables.sourceCredentialBindingsTable.sourceId,
109+ sourceName: tables.sourcesTable.name,
110+ })
111+ .from(tables.credentialsTable)
112+ .innerJoin(
113+ tables.sourceCredentialBindingsTable,
114+ eq(tables.credentialsTable.id, tables.sourceCredentialBindingsTable.credentialId),
115+ )
116+ .innerJoin(
117+ tables.sourcesTable,
118+ and(
119+ eq(tables.sourceCredentialBindingsTable.workspaceId, tables.sourcesTable.workspaceId),
120+ eq(tables.sourceCredentialBindingsTable.sourceId, tables.sourcesTable.sourceId),
121+ ),
122+ )
123+ .where(
124+ or(
125+ eq(tables.credentialsTable.tokenProviderId, "postgres"),
126+ eq(tables.credentialsTable.refreshTokenProviderId, "postgres"),
127+ ),
128+ );
129+
130+ const result = new Map<string, Array<{ sourceId: string; sourceName: string }>>();
131+
132+ for (const row of rows) {
133+ const addLink = (secretId: string) => {
134+ let links = result.get(secretId);
135+ if (!links) {
136+ links = [];
137+ result.set(secretId, links);
138+ }
139+ // Avoid duplicate entries
140+ if (!links.some((l) => l.sourceId === row.sourceId)) {
141+ links.push({ sourceId: row.sourceId, sourceName: row.sourceName });
142+ }
143+ };
144+
145+ if (row.tokenProviderId === "postgres") {
146+ addLink(row.tokenHandle);
147+ }
148+ if (row.refreshTokenProviderId === "postgres" && row.refreshTokenHandle) {
149+ addLink(row.refreshTokenHandle);
150+ }
151+ }
152+
153+ return result;
154+ }),
93155 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t