Skip to content

Commit a2e9835

Browse files
authored
Normalize plugin secret/connection refs + add usages SDK surface (#521)
* Add secret/connection usages SDK surface + UI placeholder Plugins gain optional `usagesForSecret` / `usagesForConnection` callbacks that the executor fans out across via `executor.secrets.usages(id)` and `executor.connections.usages(id)`. Secret/connection removal is now RESTRICT — refuses with `SecretInUseError` / `ConnectionInUseError` when any plugin reports the id as in use, surfacing the count to the caller. API gains GET `/secrets/:id/usages` and `/connections/:id/usages` endpoints. React atoms and a small "Used by …" footer on each row in the Secrets and Connections tabs round out the surface. No plugin implements `usagesForSecret`/`usagesForConnection` yet, so the footer renders nothing and remove still succeeds for everything. Plugin migration lands in follow-up commits. * Normalize graphql plugin secret/connection refs Move secret/connection references out of the JSON columns on graphql_source (auth, headers, query_params) into: - two new flat columns auth_kind + auth_connection_id (indexed) - two child tables graphql_source_header / graphql_source_query_param, each with kind/text_value/secret_id/secret_prefix and an indexed secret_id This lets usagesForSecret and usagesForConnection answer "where is X referenced?" with a single indexed SELECT instead of scanning JSON, and secrets.remove / connections.remove now correctly RESTRICT when a graphql source still references the id. Migration 0007 backfills existing JSON via json_each + json_extract, then drops the old columns. Three tests cover the backfill against a hand-seeded pre-migration DB; three plugin-level tests cover usage fan-out and remove RESTRICT. * Normalize openapi plugin secret/connection refs Move secret/connection references out of the JSON columns on openapi_source and openapi_source_binding into: - openapi_source_binding gains kind/secret_id/connection_id/text_value columns (replacing the discriminated-union json value) - openapi_source_query_param: child table for query_params, mirrors graphql_source_query_param shape - openapi_source_spec_fetch_header / spec_fetch_query_param: child tables for invocation_config.specFetchCredentials.{headers,queryParams} Drops openapi_source.query_params and openapi_source.invocation_config JSON columns. headers and oauth2 stay JSON because they hold slot names rather than direct refs — the slots resolve through bindings, which ARE normalized. Plugin gains usagesForSecret / usagesForConnection that fan out across all four locations in one indexed SELECT each. secrets.remove now correctly RESTRICTs when an openapi binding still uses the secret. Migration 0008 backfills via json_extract / json_each, then drops the old columns. Three migration tests cover the backfill from a hand-seeded pre-migration DB; two plugin-level tests cover usage fan-out and remove RESTRICT. * Normalize mcp plugin secret/connection refs Move secret/connection references out of the JSON `mcp_source.config` column into: - Flat columns on mcp_source: auth_kind, auth_header_name, auth_secret_id, auth_secret_prefix, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id (each indexed where it carries a ref). Replaces the McpConnectionAuth json blob. - mcp_source_header / mcp_source_query_param: child tables for the remote source's SecretBackedMap entries (same shape as graphql / openapi child tables). The remaining structural fields (transport, endpoint, command, args, etc.) stay as JSON in `config` because they're plugin-private and vary by transport. mcp_binding.binding stays JSON too — McpToolBinding carries no refs and inputSchema/outputSchema are arbitrary user JSON. Plugin gains usagesForSecret / usagesForConnection. Migration 0009 backfills via json_extract / json_each, then strips the extracted fields with json_remove. Three migration tests cover backfill from a hand-seeded pre-migration DB; two plugin-level tests cover usage fan-out across header-auth and oauth2 sources. * Normalize google-discovery plugin secret/connection refs Move secret/connection references out of the JSON google_discovery_source.config column into: - Flat columns on google_discovery_source: auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes (each indexed where it carries a ref). Replaces the GoogleDiscoveryAuth json. - google_discovery_source_credential_header / google_discovery_source_credential_query_param: child tables for the optional GoogleDiscoveryFetchCredentials SecretBackedMap entries. The remaining structural fields (discoveryUrl, service, version, rootUrl, servicePath) stay in `config` as JSON — they're plugin-private and never carry refs. google_discovery_binding stays JSON too: the GoogleDiscoveryMethodBinding has no refs, and method input/output schemas are arbitrary user data. Plugin gains usagesForSecret / usagesForConnection. Migration 0010 backfills via json_extract / json_each, then strips the extracted fields with json_remove. Three migration tests cover backfill from a hand-seeded pre-migration DB; one plugin-level test covers usage fan-out across auth_*_secret_id and auth_connection_id. * Consolidate plugin normalization into single migration Merge graphql/openapi/mcp/google-discovery normalization into one migration per app: 0007_normalize_plugin_secret_refs (local sqlite) and 0008_normalize_plugin_secret_refs (cloud pg, mcp-only since cloud doesn't ship google-discovery). Each test now imports a shared PRE_0007_SQL helper that seeds all four plugins' parent tables at the pre-migration shape — needed because the single consolidated migration touches every plugin in one transaction. * Address review: legacy openapi OAuth + strict usage gate + shadow-aware delete Three issues from review: 1. migrateOpenApi was early-returning when invocation_config column was missing (post-0007). Rows with legacy OAuth in oauth2 column silently lost their migration to a Connection. Fixed: - migrate-connections now reads oauth2 column even without invocation_config, and adapts its UPDATE statement accordingly. - Both 0007 (sqlite) and 0008 (pg) migrations COALESCE invocation_config.oauth2 into the oauth2 column before dropping invocation_config, so legacy data isn't lost mid-migration. 2. secrets.remove / connections.remove gated on a lenient usage helper that swallowed plugin failures into empty arrays — a transient plugin error could silently allow a delete that creates dangling refs. Added secretsUsagesStrict / connectionsUsagesStrict that propagate plugin failures as StorageFailure and wired them into the delete RESTRICT path. Display path keeps the lenient variant. 3. The RESTRICT check was global by id, conflicting with the documented shadowing model: deleting a user-scope override should reveal the outer fallback, but the global check blocked any delete whose id is referenced anywhere. Now scoped: only enforce RESTRICT when the delete would leave no row with that id (i.e., the reference would actually dangle).
1 parent ea306d0 commit a2e9835

41 files changed

Lines changed: 10327 additions & 211 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/cloud/drizzle/0008_normalize_plugin_secret_refs.sql‎

Lines changed: 388 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/cloud/drizzle/meta/0008_snapshot.json‎

Lines changed: 2594 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/cloud/drizzle/meta/_journal.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@
5757
"when": 1777567556847,
5858
"tag": "0007_military_young_avengers",
5959
"breakpoints": true
60+
},
61+
{
62+
"idx": 8,
63+
"version": "7",
64+
"when": 1778004191000,
65+
"tag": "0008_normalize_plugin_secret_refs",
66+
"breakpoints": true
6067
}
6168
]
62-
}
69+
}

‎apps/cloud/src/services/executor-schema.ts‎

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,7 @@ export const openapi_source = pgTable("openapi_source", {
122122
source_url: text('source_url'),
123123
base_url: text('base_url'),
124124
headers: jsonb('headers'),
125-
query_params: jsonb('query_params'),
126-
oauth2: jsonb('oauth2'),
127-
invocation_config: jsonb('invocation_config').notNull()
125+
oauth2: jsonb('oauth2')
128126
}, (table) => [
129127
primaryKey({ columns: [table.scope_id, table.id] }),
130128
index("openapi_source_scope_id_idx").on(table.scope_id),
@@ -147,25 +145,121 @@ export const openapi_source_binding = pgTable("openapi_source_binding", {
147145
source_scope_id: text('source_scope_id').notNull(),
148146
target_scope_id: text('target_scope_id').notNull(),
149147
slot: text('slot').notNull(),
150-
value: jsonb('value').notNull(),
148+
kind: text('kind', { enum: ['secret', 'connection', 'text'] }).notNull(),
149+
secret_id: text('secret_id'),
150+
connection_id: text('connection_id'),
151+
text_value: text('text_value'),
151152
created_at: timestamp('created_at').notNull(),
152153
updated_at: timestamp('updated_at').notNull()
153154
}, (table) => [
154155
index("openapi_source_binding_source_id_idx").on(table.source_id),
155156
index("openapi_source_binding_source_scope_id_idx").on(table.source_scope_id),
156157
index("openapi_source_binding_target_scope_id_idx").on(table.target_scope_id),
157158
index("openapi_source_binding_slot_idx").on(table.slot),
159+
index("openapi_source_binding_secret_id_idx").on(table.secret_id),
160+
index("openapi_source_binding_connection_id_idx").on(table.connection_id),
161+
]);
162+
163+
export const openapi_source_query_param = pgTable("openapi_source_query_param", {
164+
id: text('id').notNull(),
165+
scope_id: text('scope_id').notNull(),
166+
source_id: text('source_id').notNull(),
167+
name: text('name').notNull(),
168+
kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
169+
text_value: text('text_value'),
170+
secret_id: text('secret_id'),
171+
secret_prefix: text('secret_prefix')
172+
}, (table) => [
173+
primaryKey({ columns: [table.scope_id, table.id] }),
174+
index("openapi_source_query_param_scope_id_idx").on(table.scope_id),
175+
index("openapi_source_query_param_source_id_idx").on(table.source_id),
176+
index("openapi_source_query_param_secret_id_idx").on(table.secret_id),
177+
]);
178+
179+
export const openapi_source_spec_fetch_header = pgTable("openapi_source_spec_fetch_header", {
180+
id: text('id').notNull(),
181+
scope_id: text('scope_id').notNull(),
182+
source_id: text('source_id').notNull(),
183+
name: text('name').notNull(),
184+
kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
185+
text_value: text('text_value'),
186+
secret_id: text('secret_id'),
187+
secret_prefix: text('secret_prefix')
188+
}, (table) => [
189+
primaryKey({ columns: [table.scope_id, table.id] }),
190+
index("openapi_source_spec_fetch_header_scope_id_idx").on(table.scope_id),
191+
index("openapi_source_spec_fetch_header_source_id_idx").on(table.source_id),
192+
index("openapi_source_spec_fetch_header_secret_id_idx").on(table.secret_id),
193+
]);
194+
195+
export const openapi_source_spec_fetch_query_param = pgTable("openapi_source_spec_fetch_query_param", {
196+
id: text('id').notNull(),
197+
scope_id: text('scope_id').notNull(),
198+
source_id: text('source_id').notNull(),
199+
name: text('name').notNull(),
200+
kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
201+
text_value: text('text_value'),
202+
secret_id: text('secret_id'),
203+
secret_prefix: text('secret_prefix')
204+
}, (table) => [
205+
primaryKey({ columns: [table.scope_id, table.id] }),
206+
index("openapi_source_spec_fetch_query_param_scope_id_idx").on(table.scope_id),
207+
index("openapi_source_spec_fetch_query_param_source_id_idx").on(table.source_id),
208+
index("openapi_source_spec_fetch_query_param_secret_id_idx").on(table.secret_id),
158209
]);
159210

160211
export const mcp_source = pgTable("mcp_source", {
161212
id: text('id').notNull(),
162213
scope_id: text('scope_id').notNull(),
163214
name: text('name').notNull(),
164215
config: jsonb('config').notNull(),
216+
auth_kind: text('auth_kind', { enum: ['none', 'header', 'oauth2'] }).default("none").notNull(),
217+
auth_header_name: text('auth_header_name'),
218+
auth_secret_id: text('auth_secret_id'),
219+
auth_secret_prefix: text('auth_secret_prefix'),
220+
auth_connection_id: text('auth_connection_id'),
221+
auth_client_id_secret_id: text('auth_client_id_secret_id'),
222+
auth_client_secret_secret_id: text('auth_client_secret_secret_id'),
165223
created_at: timestamp('created_at').notNull()
166224
}, (table) => [
167225
primaryKey({ columns: [table.scope_id, table.id] }),
168226
index("mcp_source_scope_id_idx").on(table.scope_id),
227+
index("mcp_source_auth_secret_id_idx").on(table.auth_secret_id),
228+
index("mcp_source_auth_connection_id_idx").on(table.auth_connection_id),
229+
index("mcp_source_auth_client_id_secret_id_idx").on(table.auth_client_id_secret_id),
230+
index("mcp_source_auth_client_secret_secret_id_idx").on(table.auth_client_secret_secret_id),
231+
]);
232+
233+
export const mcp_source_header = pgTable("mcp_source_header", {
234+
id: text('id').notNull(),
235+
scope_id: text('scope_id').notNull(),
236+
source_id: text('source_id').notNull(),
237+
name: text('name').notNull(),
238+
kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
239+
text_value: text('text_value'),
240+
secret_id: text('secret_id'),
241+
secret_prefix: text('secret_prefix')
242+
}, (table) => [
243+
primaryKey({ columns: [table.scope_id, table.id] }),
244+
index("mcp_source_header_scope_id_idx").on(table.scope_id),
245+
index("mcp_source_header_source_id_idx").on(table.source_id),
246+
index("mcp_source_header_secret_id_idx").on(table.secret_id),
247+
]);
248+
249+
export const mcp_source_query_param = pgTable("mcp_source_query_param", {
250+
id: text('id').notNull(),
251+
scope_id: text('scope_id').notNull(),
252+
source_id: text('source_id').notNull(),
253+
name: text('name').notNull(),
254+
kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
255+
text_value: text('text_value'),
256+
secret_id: text('secret_id'),
257+
secret_prefix: text('secret_prefix')
258+
}, (table) => [
259+
primaryKey({ columns: [table.scope_id, table.id] }),
260+
index("mcp_source_query_param_scope_id_idx").on(table.scope_id),
261+
index("mcp_source_query_param_source_id_idx").on(table.source_id),
262+
index("mcp_source_query_param_secret_id_idx").on(table.secret_id),
169263
]);
170264

171265
export const mcp_binding = pgTable("mcp_binding", {
@@ -185,12 +279,44 @@ export const graphql_source = pgTable("graphql_source", {
185279
scope_id: text('scope_id').notNull(),
186280
name: text('name').notNull(),
187281
endpoint: text('endpoint').notNull(),
188-
headers: jsonb('headers'),
189-
query_params: jsonb('query_params'),
190-
auth: jsonb('auth')
282+
auth_kind: text('auth_kind', { enum: ['none', 'oauth2'] }).default("none").notNull(),
283+
auth_connection_id: text('auth_connection_id')
191284
}, (table) => [
192285
primaryKey({ columns: [table.scope_id, table.id] }),
193286
index("graphql_source_scope_id_idx").on(table.scope_id),
287+
index("graphql_source_auth_connection_id_idx").on(table.auth_connection_id),
288+
]);
289+
290+
export const graphql_source_header = pgTable("graphql_source_header", {
291+
id: text('id').notNull(),
292+
scope_id: text('scope_id').notNull(),
293+
source_id: text('source_id').notNull(),
294+
name: text('name').notNull(),
295+
kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
296+
text_value: text('text_value'),
297+
secret_id: text('secret_id'),
298+
secret_prefix: text('secret_prefix')
299+
}, (table) => [
300+
primaryKey({ columns: [table.scope_id, table.id] }),
301+
index("graphql_source_header_scope_id_idx").on(table.scope_id),
302+
index("graphql_source_header_source_id_idx").on(table.source_id),
303+
index("graphql_source_header_secret_id_idx").on(table.secret_id),
304+
]);
305+
306+
export const graphql_source_query_param = pgTable("graphql_source_query_param", {
307+
id: text('id').notNull(),
308+
scope_id: text('scope_id').notNull(),
309+
source_id: text('source_id').notNull(),
310+
name: text('name').notNull(),
311+
kind: text('kind', { enum: ['text', 'secret'] }).notNull(),
312+
text_value: text('text_value'),
313+
secret_id: text('secret_id'),
314+
secret_prefix: text('secret_prefix')
315+
}, (table) => [
316+
primaryKey({ columns: [table.scope_id, table.id] }),
317+
index("graphql_source_query_param_scope_id_idx").on(table.scope_id),
318+
index("graphql_source_query_param_source_id_idx").on(table.source_id),
319+
index("graphql_source_query_param_secret_id_idx").on(table.secret_id),
194320
]);
195321

196322
export const graphql_operation = pgTable("graphql_operation", {

0 commit comments

Comments
 (0)