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 @@
1import { useState, type ReactNode } from "react";1import { useState, type ReactNode } from "react";
2import { Link } from "@tanstack/react-router";
2import {3import {
3 type SecretListItem,4 type SecretListItem,
4 type Loadable,5 type Loadable,
@@ -247,6 +248,22 @@ function SecretRow(props: { · 242 unmodified lines
247 {createdDate}248 {createdDate}
248 </span>249 </span>
249 </div>250 </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 )}
250 </div>267 </div>
251 </div>268 </div>
252 <div className="flex items-center gap-1.5 shrink-0">269 <div className="flex items-center gap-1.5 shrink-0">
packages/control-plane/src/api/local/api.ts+10−1
@@ -1,5 +1,5 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { LocalInstallationSchema } from "#schema";2import { LocalInstallationSchema, SecretMaterialPurposeSchema } from "#schema";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4 4
5import {5import {
@@ -25,12 +25,20 @@ export type InstanceConfig = typeof InstanceConfigSchema.Type; · 19 unmodified lines
25 25
26// -- Secrets CRUD schemas ---------------------------------------------------26// -- Secrets CRUD schemas ---------------------------------------------------
27 27
28export const SecretLinkedSourceSchema = Schema.Struct({
29 sourceId: Schema.String,
30 sourceName: Schema.String,
31});
32
33export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type;
34
28export const SecretListItemSchema = Schema.Struct({35export const SecretListItemSchema = Schema.Struct({
29 id: Schema.String,36 id: Schema.String,
30 name: Schema.NullOr(Schema.String),37 name: Schema.NullOr(Schema.String),
31 purpose: Schema.String,38 purpose: Schema.String,
32 createdAt: Schema.Number,39 createdAt: Schema.Number,
33 updatedAt: Schema.Number,40 updatedAt: Schema.Number,
41 linkedSources: Schema.Array(SecretLinkedSourceSchema),
34});42});
35 43
36export type SecretListItem = typeof SecretListItemSchema.Type;44export type SecretListItem = typeof SecretListItemSchema.Type;
@@ -38,6 +46,7 @@ export type SecretListItem = typeof SecretListItemSchema.Type; · 1 unmodified line
38export const CreateSecretPayloadSchema = Schema.Struct({46export const CreateSecretPayloadSchema = Schema.Struct({
39 name: Schema.String,47 name: Schema.String,
40 value: Schema.String,48 value: Schema.String,
49 purpose: Schema.optional(SecretMaterialPurposeSchema),
41});50});
42 51
43export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type;52export 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
90 const rows = yield* store.secretMaterials.listAll().pipe(90 const rows = yield* store.secretMaterials.listAll().pipe(
91 Effect.mapError(() => storageError("Failed listing secrets.")),91 Effect.mapError(() => storageError("Failed listing secrets.")),
92 );92 );
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 }));
94 }),100 }),
95 )101 )
96 .handle("createSecret", ({ payload }) =>102 .handle("createSecret", ({ payload }) =>
97 Effect.gen(function* () {103 Effect.gen(function* () {
98 const name = payload.name.trim();104 const name = payload.name.trim();
99 const value = payload.value;105 const value = payload.value;
106 const purpose = payload.purpose ?? "auth_material";
100 107
101 if (name.length === 0) {108 if (name.length === 0) {
102 return yield* Effect.fail(109 return yield* Effect.fail(
@@ -115,7 +122,7 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 12 unmodified lines
115 yield* store.secretMaterials.upsert({122 yield* store.secretMaterials.upsert({
116 id,123 id,
117 name,124 name,
118 purpose: "auth_material",125 purpose,
119 value,126 value,
120 createdAt: now,127 createdAt: now,
121 updatedAt: now,128 updatedAt: now,
@@ -127,7 +134,7 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 5 unmodified lines
127 id,134 id,
128 name,135 name,
129 providerId: POSTGRES_SECRET_PROVIDER_ID,136 providerId: POSTGRES_SECRET_PROVIDER_ID,
130 purpose: "auth_material",137 purpose,
131 createdAt: now,138 createdAt: now,
132 updatedAt: now,139 updatedAt: now,
133 } satisfies CreateSecretResult;140 } satisfies CreateSecretResult;
packages/control-plane/src/persistence/repos/secret-materials-repo.ts+63−1
@@ -4,7 +4,7 @@ import { · 3 unmodified lines
4} from "#schema";4} from "#schema";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import { Schema } from "effect";6import { Schema } from "effect";
7import { eq, desc } from "drizzle-orm";7import { eq, desc, or, and } from "drizzle-orm";
8 8
9import type { DrizzleClient } from "../client";9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";10import type { DrizzleTables } from "../schema";
@@ -90,4 +90,66 @@ export const createSecretMaterialsRepo = ( · 79 unmodified lines
90 90
91 return deleted.length > 0;91 return deleted.length > 0;
92 }),92 }),
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 }),
93});155});

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