History

Move source auth handling into plugins

Rhys Sullivan committed 7 months ago

Commit db9b84d1 parent 02d549c15 files · +98 −125Browse files

Unified · Split

15 files
  1. apps/docs/developer/core-model.mdx +12 −7
  2. apps/docs/developer/credentials-and-auth.mdx +14 −11
  3. apps/docs/developer/extending-executor.mdx +10 −3
  4. apps/docs/developer/mcp.mdx +2 −2
  5. apps/docs/developer/overview.mdx +4 −4
  6. bun.lock +1 −0
  7. packages/platform/sdk/src/runtime/index.ts +0 −1
  8. packages/platform/sdk/src/runtime/sources/source-credential-interactions.ts +0 −34
  9. packages/sources/core/src/shared.ts +0 −20
  10. plugins/google-discovery/sdk/index.ts +1 −3
  11. plugins/graphql/sdk/index.ts +1 −3
  12. plugins/openapi/react/index.tsx +36 −14
  13. plugins/openapi/sdk/index.ts +13 −21
  14. plugins/openapi/shared/index.ts +3 −2
  15. plugins/openapi/shared/package.json +1 −0
apps/docs/developer/core-model.mdx+12−7
@@ -57,17 +57,17 @@ They answer: · 56 unmodified lines
5757
5858 The operation layer exists because raw upstream documents are not a good direct query surface for search, policy, and invocation lookup.
5959
60−### Credential
60+### Secret material
6161
62−Credentials are private auth bindings.
62+Secret material is private auth state.
6363
64−They answer:
64+It answers:
6565
66−- who can authenticate for this binding?
67−- for which slot?
68−- using what auth kind and secret refs?
66+- which private values exist for this workspace actor?
67+- which source bindings refer to them?
68+- which plugin-owned auth configs use those secret refs?
6969
70−Credentials are actor-scoped by default because two users in the same workspace may connect different identities to the same source.
70+Secret material is actor-scoped by default because two users in the same workspace may connect different identities to the same source.
7171
7272 ## Why the split matters
7373
@@ -115,6 +115,11 @@ Adapters are more specific: · 41 unmodified lines
115115
116116 The important distinction is that execution now belongs to adapters. The shared catalog stays broad and stable, while connect/auth/invoke behavior is described through adapter strategies instead of one top-level runtime family enum.
117117
118+The same is true for auth behavior:
119+
120+- core owns secret storage and private state plumbing
121+- plugins own auth schemas, request shaping, OAuth flows, and refresh logic
122+
118123 ## Why this is the right shape
119124
120125 The current model is designed so that new support usually means:
apps/docs/developer/credentials-and-auth.mdx+14−11
@@ -5,11 +5,14 @@ description: "How executor models auth, interactive flows, and actor-scoped cred · 4 unmodified lines
55
66 Auth in executor is not treated as a side detail on the source row.
77
8−It is modeled as its own layer because auth has different ownership and lifecycle semantics from source bindings and recipe materialization.
8+The important boundary is:
9+
10+- core owns secret material storage, actor scoping, and private state plumbing
11+- plugins own auth schemas and auth behavior for each source kind
912
1013 ## Auth ownership
1114
12−Credentials are scoped to:
15+Secret material is scoped to:
1316
1417 - workspace
1518 - source binding
@@ -20,7 +23,7 @@ This is important because different users in the same workspace may connect diff · 4 unmodified lines
2023
2124 ## Auth slots
2225
23−Executor distinguishes where auth is used.
26+Plugins may distinguish where auth is used.
2427
2528 Important current slots:
2629
@@ -33,7 +36,7 @@ Why this matters: · 6 unmodified lines
3336 - some need auth only to fetch or generate the materialization
3437 - some may eventually need setup/bootstrap-specific auth
3538
36−This is why source auth is more than a single token field.
39+This is why plugin-owned source auth is more than a single token field.
3740
3841 ## Static credentials
3942
@@ -45,11 +48,11 @@ Examples: · 5 unmodified lines
4548 - OAuth access token
4649 - OAuth refresh token
4750
48−The control plane stores secret refs, not raw secret values inline on the source or credential rows.
51+The control plane stores secret refs, not raw secret values inline on the source or plugin-owned auth configs.
4952
5053 ## Interactive auth sessions
5154
52−Interactive auth, such as OAuth flows, is modeled separately from stored credentials.
55+Interactive auth, such as OAuth flows, is modeled separately from stored secret material.
5356
5457 That separation exists because:
5558
@@ -57,7 +60,7 @@ That separation exists because: · 1 unmodified line
5760 - it may be tied to an interaction or execution
5861 - it is not the same thing as a resolved credential
5962
60−This is why executor has auth sessions and credentials as separate concepts.
63+This is why executor keeps auth sessions separate from stored secret material.
6164
6265 ## Shareable config vs private state
6366
@@ -65,7 +68,7 @@ Project config under `.executor/executor.jsonc` is intended to stay portable and · 1 unmodified line
6568
6669 Private auth state does not belong in the workspace tree:
6770
68−- actor-scoped credentials
71+- actor-scoped secret material
6972 - OAuth sessions
7073 - refresh leases
7174 - secret material
@@ -97,17 +100,17 @@ The likely next auth-layer primitive is an auth provider or auth material resolv · 25 unmodified lines
97100 - produces runtime auth material on demand
98101 - caches it with expiry metadata
99102
100−That is broader than sources and should be reusable by future source adapters, tool providers, and code bundles.
103+That is broader than sources and should be reusable by future plugins, tool providers, and code bundles.
101104
102105 ## Why auth is separated from recipes
103106
104107 Recipes are shareable integration definitions.
105−Credentials are private installation-specific state.
108+Secret material is private installation-specific state.
106109
107110 That separation is important both for correctness and for shareability:
108111
109112 - a recipe can be portable
110113 - a source binding can be local
111−- credentials stay private
114+- secret material stays private
112115
113116 This is the main reason auth is not modeled as part of the shareable recipe layer.
apps/docs/developer/extending-executor.mdx+10−3
@@ -36,6 +36,12 @@ The happy path for a new integration is: · 35 unmodified lines
3636
3737 The adapter should absorb provider-specific logic so the core does not have to.
3838
39+That includes auth behavior:
40+
41+- plugin-owned auth config
42+- plugin-owned OAuth flows and refresh logic
43+- core-owned secret material services
44+
3945 ## What a new adapter should not do
4046
4147 Avoid these patterns:
@@ -54,7 +60,8 @@ A good new adapter should make the core system feel boring. · 12 unmodified lines
5460 That means:
5561
5662 - persistence still looks like recipe/revision/operations
57−- auth still flows through the same credential/session primitives
63+- auth still uses the same secret material and private-state plumbing
64+- auth behavior itself stays plugin-owned
5865 - discovery and inspection still work through the same catalog ideas
5966 - execution still delegates through the same tool and adapter boundaries
6067
@@ -69,7 +76,7 @@ The current architecture is well-suited to future additions like: · 8 unmodified lines
6976 - snippet bundles
7077 - richer auth material providers
7178
72−Those should mostly be adapter and auth-layer work, not another persistence redesign.
79+Those should mostly be adapter and secret-material-layer work, not another persistence redesign.
7380
7481 ## Why this is the best setup
7582
@@ -80,4 +87,4 @@ It is trying to keep the codebase in a state where: · 4 unmodified lines
8087 - new support lands as extensions
8188 - source support does not require philosophy rewrites every few weeks
8289
83−That is the real reason the system is shaped around adapters, recipes, revisions, operations, and actor-scoped credentials.
90+That is the real reason the system is shaped around adapters, recipes, revisions, operations, and actor-scoped private state.
apps/docs/developer/mcp.mdx+2−2
@@ -9,7 +9,7 @@ MCP shows up in executor in two different ways. · 8 unmodified lines
99
1010 This is the source side of MCP.
1111
12−When a user connects an MCP endpoint, executor treats it as an adapter with MCP-specific connection, discovery, and invocation behavior. The control plane persists the binding and credential state, while MCP-specific execution behavior lives behind the adapter and codemode layers.
12+When a user connects an MCP endpoint, executor treats it as an adapter with MCP-specific connection, discovery, invocation, and OAuth behavior. The control plane persists the binding and secret material state, while MCP-specific auth and execution behavior lives behind the adapter and codemode layers.
1313
1414 This is why MCP is modeled as its own adapter instead of being flattened into a generic HTTP execution model.
1515
@@ -39,7 +39,7 @@ Keeping that distinction clear avoids collapsing very different responsibilities · 23 unmodified lines
3939
4040 Broadly:
4141
42−- control plane owns workspace state, bindings, credentials, and execution lifecycle
42+- control plane owns workspace state, bindings, secret material, and execution lifecycle
4343 - `packages/hosts/mcp` owns the local MCP server surface
4444 - `packages/platform/server` mounts the `/mcp` handler
4545
apps/docs/developer/overview.mdx+4−4
@@ -7,7 +7,7 @@ Executor is built around one central idea: · 6 unmodified lines
77
88 - turn connected systems into a durable, discoverable tool catalog
99 - run TypeScript against that catalog inside a controlled runtime
10−- keep connection state, auth, execution, and inspection in one local control plane
10+- keep connection state, secret material, execution, and inspection in one local control plane
1111
1212 ## One-line architecture
1313
@@ -16,7 +16,7 @@ Executor is a local control plane that: · 2 unmodified lines
1616 1. stores connected integrations as sources
1717 2. materializes those sources into callable tools
1818 3. exposes those tools to the runtime, the UI, and MCP clients
19−4. resolves auth and policy at invocation time
19+4. resolves secret material and policy at invocation time
2020
2121 ## Major packages
2222
@@ -48,7 +48,7 @@ The product core. · 25 unmodified lines
4848 Responsibilities:
4949
5050 - source connection and lifecycle
51−- auth and credential state
51+- secret material and private auth state
5252 - persistence
5353 - tool discovery and inspection
5454 - execution management
@@ -76,7 +76,7 @@ That is why executor has strong internal concepts like: · 21 unmodified lines
7676 - recipes and revisions
7777 - materialized operations
7878 - schema bundles
79−- actor-scoped credentials
79+- actor-scoped secret material
8080
8181 Those are not UI concepts. They are the durable internal model that lets executor support multiple integration styles without rewriting the core every time.
8282
bun.lock+1−0
@@ -683,6 +683,7 @@ · 682 unmodified lines
683683 "name": "@executor/plugin-openapi-shared",
684684 "version": "0.0.0",
685685 "dependencies": {
686+ "@executor/platform-sdk": "workspace:*",
686687 "@executor/source-core": "workspace:*",
687688 "effect": "catalog:",
688689 "yaml": "^2.8.1",
packages/platform/sdk/src/runtime/index.ts+0−1
@@ -99,7 +99,6 @@ export * from "./execution/live"; · 98 unmodified lines
9999 export * from "./catalog/schema-type-signature";
100100 export * from "./catalog/source/runtime";
101101 export * from "./catalog/source/sync";
102−export * from "./sources/source-credential-interactions";
103102 export * from "./sources/source-store";
104103 export * from "./executor-state-store";
105104 export * from "./execution/scope/environment";
packages/platform/sdk/src/runtime/sources/source-credential-interactions.ts+0−34
@@ -1,34 +0,0 @@
1−import {
2− SecretRefSchema,
3− type SecretRef,
4−} from "#schema";
5−import * as Schema from "effect/Schema";
6−
7−export const SourceCredentialSelectionContentSchema = Schema.Union(
8− Schema.Struct({
9− authKind: Schema.Literal("none"),
10− }),
11− Schema.Struct({
12− authKind: Schema.Literal("bearer"),
13− tokenRef: SecretRefSchema,
14− }),
15−);
16−
17−export type SourceCredentialSelectionContent =
18− typeof SourceCredentialSelectionContentSchema.Type;
19−
20−export const decodeSourceCredentialSelectionContent = Schema.decodeUnknownSync(
21− SourceCredentialSelectionContentSchema,
22−);
23−
24−export const createSourceCredentialSelectionNoneContent =
25− (): SourceCredentialSelectionContent => ({
26− authKind: "none",
27− });
28−
29−export const createSourceCredentialSelectionBearerContent = (
30− tokenRef: SecretRef,
31−): SourceCredentialSelectionContent => ({
32− authKind: "bearer",
33− tokenRef,
34−});
packages/sources/core/src/shared.ts+0−20
@@ -1,4 +1,3 @@
1−import * as Data from "effect/Data";
21 import * as Effect from "effect/Effect";
32
43 export const parseJsonValue = <T>(input: {
@@ -14,22 +13,3 @@ export const parseJsonValue = <T>(input: { · 9 unmodified lines
1413 ? new Error(`Invalid ${input.label}: ${cause.message}`)
1514 : new Error(`Invalid ${input.label}: ${String(cause)}`),
1615 });
17−
18−export class SourceCredentialRequiredError extends Data.TaggedError(
19− "SourceCredentialRequiredError",
20−)<{
21− readonly slot: "runtime" | "import";
22− readonly message: string;
23−}> {
24− constructor(
25− slot: "runtime" | "import",
26− message: string,
27− ) {
28− super({ slot, message });
29− }
30−}
31−
32−export const isSourceCredentialRequiredError = (
33− error: unknown,
34−): error is SourceCredentialRequiredError =>
35− error instanceof SourceCredentialRequiredError;
plugins/google-discovery/sdk/index.ts+1−3
@@ -10,7 +10,6 @@ import { · 9 unmodified lines
1010 import {
1111 createCatalogImportMetadata,
1212 createSourceCatalogSyncResult,
13− SourceCredentialRequiredError,
1413 } from "@executor/source-core";
1514 import type { Source } from "@executor/platform-sdk/schema";
1615 import type {
@@ -294,8 +293,7 @@ const fetchGoogleDiscoveryDocumentWithHeaders = (input: { · 277 unmodified lines
294293 signal: AbortSignal.timeout(15_000),
295294 });
296295 if (response.status === 401 || response.status === 403) {
297− throw new SourceCredentialRequiredError(
298− "import",
296+ throw new Error(
299297 `Google Discovery fetch requires credentials (status ${response.status})`,
300298 );
301299 }
plugins/graphql/sdk/index.ts+1−3
@@ -9,7 +9,6 @@ import * as Schema from "effect/Schema"; · 8 unmodified lines
99 import {
1010 createCatalogImportMetadata,
1111 createSourceCatalogSyncResult,
12− SourceCredentialRequiredError,
1312 } from "@executor/source-core";
1413 import type { Source } from "@executor/platform-sdk/schema";
1514 import type {
@@ -200,8 +199,7 @@ const fetchGraphqlIntrospectionDocumentWithHeaders = (input: { · 184 unmodified lines
200199
201200 if (!response.ok) {
202201 if (response.status === 401 || response.status === 403) {
203− throw new SourceCredentialRequiredError(
204− "import",
202+ throw new Error(
205203 `GraphQL introspection requires credentials (status ${response.status})`,
206204 );
207205 }
plugins/openapi/react/index.tsx+36−14
@@ -38,7 +38,7 @@ import { · 37 unmodified lines
3838 type SourceToolExplorerSearch,
3939 useSourcePluginNavigation,
4040 useSourcePluginSearch,
41−} from "@executor/react/source-plugins";
41+} from "@executor/react/plugins";
4242 import {
4343 openApiHttpApiExtension,
4444 } from "@executor/plugin-openapi-http";
@@ -169,6 +169,31 @@ const inputFromConfig = ( · 124 unmodified lines
169169 auth: config.auth,
170170 });
171171
172+const secretValue = (input: OpenApiConnectInput["auth"]): string =>
173+ input.kind === "bearer"
174+ ? JSON.stringify(input.tokenSecretRef)
175+ : "";
176+
177+const authFromSecretValue = (
178+ authKind: OpenApiConnectInput["auth"]["kind"],
179+ value: string,
180+): OpenApiConnectInput["auth"] => {
181+ if (authKind === "none") {
182+ return {
183+ kind: "none",
184+ };
185+ }
186+
187+ if (!value) {
188+ throw new Error("Select a secret for bearer auth.");
189+ }
190+
191+ return {
192+ kind: "bearer",
193+ tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"],
194+ };
195+};
196+
172197 const useAvailableSecrets = (
173198 openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>,
174199 ) => {
@@ -205,9 +230,7 @@ function OpenApiSourceForm(props: { · 30 unmodified lines
205230 props.initialValue.auth.kind,
206231 );
207232 const [tokenSecretRef, setTokenSecretRef] = useState(
208− props.initialValue.auth.kind === "bearer"
209− ? props.initialValue.auth.tokenSecretRef
210− : "",
233+ secretValue(props.initialValue.auth),
211234 );
212235 const [error, setError] = useState<string | null>(null);
213236 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);
@@ -309,19 +332,12 @@ function OpenApiSourceForm(props: { · 95 unmodified lines
309332 }
310333
311334 try {
335+ const auth = authFromSecretValue(authKind, tokenSecretRef);
312336 await submitMutation.mutateAsync({
313337 name: trimmedName,
314338 specUrl: trimmedSpecUrl,
315339 baseUrl: trimmedBaseUrl || null,
316− auth:
317− authKind === "bearer"
318− ? {
319− kind: "bearer",
320− tokenSecretRef: tokenSecretRef.trim(),
321− }
322− : {
323− kind: "none",
324− },
340+ auth,
325341 });
326342 } catch (cause) {
327343 setError(cause instanceof Error ? cause.message : "Failed saving source.");
@@ -398,7 +414,13 @@ function OpenApiSourceForm(props: { · 70 unmodified lines
398414 >
399415 <option value="">Select a secret</option>
400416 {availableSecrets.map((secret) => (
401− <option key={secret.id} value={secret.id}>
417+ <option
418+ key={`${secret.providerId}:${secret.id}`}
419+ value={JSON.stringify({
420+ providerId: secret.providerId,
421+ handle: secret.id,
422+ })}
423+ >
402424 {secret.name || secret.id}
403425 </option>
404426 ))}
plugins/openapi/sdk/index.ts+13−21
@@ -13,6 +13,9 @@ import type { · 12 unmodified lines
1313 ExecutorSourceConnector,
1414 SourcePluginRuntime,
1515 } from "@executor/platform-sdk/plugins";
16+import {
17+ SecretMaterialResolverService,
18+} from "@executor/platform-sdk/runtime";
1619 import {
1720 OpenApiConnectionAuthSchema,
1821 deriveOpenApiNamespace,
@@ -64,12 +67,6 @@ export type OpenApiSourceStorage = { · 45 unmodified lines
6467 }) => Effect.Effect<void, Error, never>;
6568 };
6669
67−export type OpenApiSecrets = {
68− resolve: (input: {
69− ref: string;
70− }) => Effect.Effect<string, Error, never>;
71−};
72−
7370 export type OpenApiSdk = {
7471 previewDocument: (
7572 input: OpenApiPreviewRequest,
@@ -93,7 +90,6 @@ export type OpenApiSdk = { · 17 unmodified lines
9390
9491 export type OpenApiSdkPluginOptions = {
9592 storage: OpenApiSourceStorage;
96− secrets?: OpenApiSecrets;
9793 };
9894
9995 const OpenApiExecutorAddInputSchema = Schema.Struct({
@@ -414,22 +410,18 @@ const decodeResponseBody = async (response: Response): Promise<unknown> => { · 314 unmodified lines
414410 };
415411
416412 const resolveBearerToken = (
417− options: OpenApiSdkPluginOptions,
418413 stored: OpenApiStoredSourceData,
419−): Effect.Effect<string | null, Error, never> => {
420− if (stored.auth.kind === "none") {
414+): Effect.Effect<string | null, Error, any> => {
415+ const { auth } = stored;
416+ if (auth.kind === "none") {
421417 return Effect.succeed(null);
422418 }
423419
424− if (!options.secrets) {
425− return Effect.fail(
426− new Error("OpenAPI bearer auth is configured, but no secret resolver is available."),
427− );
428− }
429−
430− return options.secrets.resolve({
431− ref: stored.auth.tokenSecretRef,
432− }).pipe(Effect.map((token) => token.trim()));
420+ return Effect.flatMap(SecretMaterialResolverService, (resolveSecretMaterial) =>
421+ resolveSecretMaterial({
422+ ref: auth.tokenSecretRef,
423+ }).pipe(Effect.map((token) => token.trim()))
424+ );
433425 };
434426
435427 const openApiDocumentHeaders = (input: {
@@ -550,7 +542,7 @@ const createOpenApiSourceRuntime = ( · 114 unmodified lines
550542 });
551543 }
552544
553− const bearerToken = yield* resolveBearerToken(options, stored);
545+ const bearerToken = yield* resolveBearerToken(stored);
554546 const fetched = yield* fetchOpenApiDocument({
555547 stored,
556548 bearerToken,
@@ -686,7 +678,7 @@ const createOpenApiSourceRuntime = ( · 129 unmodified lines
686678 }
687679 }
688680
689− const bearerToken = yield* resolveBearerToken(options, stored);
681+ const bearerToken = yield* resolveBearerToken(stored);
690682 if (bearerToken && bearerToken.length > 0) {
691683 headers.authorization = `Bearer ${bearerToken}`;
692684 }
plugins/openapi/shared/index.ts+3−2
@@ -3,8 +3,9 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines
33 import YAML from "yaml";
44
55 import {
6+ SecretRefSchema,
67 StringMapSchema,
7−} from "@executor/source-core";
8+} from "@executor/platform-sdk/schema";
89
910 export const OpenApiConnectionAuthSchema = Schema.Union(
1011 Schema.Struct({
@@ -12,7 +13,7 @@ export const OpenApiConnectionAuthSchema = Schema.Union( · 1 unmodified line
1213 }),
1314 Schema.Struct({
1415 kind: Schema.Literal("bearer"),
15− tokenSecretRef: Schema.String,
16+ tokenSecretRef: SecretRefSchema,
1617 }),
1718 );
1819
plugins/openapi/shared/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 "typecheck": "bunx tsc --noEmit -p tsconfig.json"
1313 },
1414 "dependencies": {
15+ "@executor/platform-sdk": "workspace:*",
1516 "@executor/source-core": "workspace:*",
1617 "effect": "catalog:",
1718 "yaml": "^2.8.1"