History

Fix scoped source config inheritance (#392)

Rhys Sullivan committed 6 months ago

Commit ab0feed1 parent f1259355 files · +234 −16Browse files

Unified · Split

5 files
  1. packages/plugins/graphql/src/api/handlers.ts +1 −1
  2. packages/plugins/graphql/src/sdk/plugin.test.ts +2 −1
  3. packages/plugins/graphql/src/sdk/plugin.ts +4 −2
  4. packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +172 −0
  5. packages/plugins/openapi/src/sdk/plugin.ts +55 −12
packages/plugins/graphql/src/api/handlers.ts+1−1
@@ -56,7 +56,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 55 unmodified lines
56 });56 });
57 return {57 return {
58 toolCount: result.toolCount,58 toolCount: result.toolCount,
59 namespace: payload.namespace ?? "graphql",59 namespace: result.namespace,
60 };60 };
61 })),61 })),
62 )62 )
packages/plugins/graphql/src/sdk/plugin.test.ts+2−1
@@ -98,6 +98,7 @@ describe("graphqlPlugin", () => { · 97 unmodified lines
98 namespace: "test_api",98 namespace: "test_api",
99 });99 });
100 expect(result.toolCount).toBe(2);100 expect(result.toolCount).toBe(2);
101 expect(result.namespace).toBe("test_api");
101 102
102 const tools = yield* executor.tools.list();103 const tools = yield* executor.tools.list();
103 const ids = tools.map((t) => t.id);104 const ids = tools.map((t) => t.id);
@@ -249,7 +250,7 @@ describe("graphqlPlugin", () => { · 145 unmodified lines
249 introspectionJson,250 introspectionJson,
250 namespace: "via_static",251 namespace: "via_static",
251 });252 });
252 expect(result).toEqual({ toolCount: 2 });253 expect(result).toEqual({ toolCount: 2, namespace: "via_static" });
253 254
254 const tools = yield* executor.tools.list();255 const tools = yield* executor.tools.list();
255 expect(256 expect(
packages/plugins/graphql/src/sdk/plugin.ts+4−2
@@ -95,7 +95,10 @@ export interface GraphqlPluginExtension { · 94 unmodified lines
95 /** Add a GraphQL endpoint and register its operations as tools */95 /** Add a GraphQL endpoint and register its operations as tools */
96 readonly addSource: (96 readonly addSource: (
97 config: GraphqlSourceConfig,97 config: GraphqlSourceConfig,
98 ) => Effect.Effect<{ readonly toolCount: number }, GraphqlExtensionFailure>;98 ) => Effect.Effect<
99 { readonly toolCount: number; readonly namespace: string },
100 GraphqlExtensionFailure
101 >;
99 102
100 /** Remove all tools from a previously added GraphQL source by namespace.103 /** Remove all tools from a previously added GraphQL source by namespace.
101 * `scope` pins the cleanup to the exact row — without it a shadowed104 * `scope` pins the cleanup to the exact row — without it a shadowed
@@ -411,7 +414,6 @@ export const graphqlPlugin = definePlugin( · 309 unmodified lines
411 )414 )
412 : Effect.void,415 : Effect.void,
413 ),416 ),
414 Effect.map(({ toolCount }) => ({ toolCount })),
415 ),417 ),
416 418
417 removeSource: (namespace, scope) =>419 removeSource: (namespace, scope) =>
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+172−0
@@ -606,4 +606,176 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 605 unmodified lines
606 );606 );
607 }),607 }),
608 );608 );
609
610 it.effect("user-scope source shadows inherit the org baseUrl", () =>
611 Effect.gen(function* () {
612 const memorySecretsPlugin = definePlugin(() => ({
613 id: "memory-secrets" as const,
614 storage: () => ({}),
615 secretProviders: [],
616 }));
617
618 const httpClient = yield* HttpClient.HttpClient;
619 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
620 const plugins = [
621 openApiPlugin({ httpClientLayer: clientLayer }),
622 memorySecretsPlugin(),
623 ] as const;
624
625 const schema = collectSchemas(plugins);
626 const adapter = makeMemoryAdapter({ schema });
627 const blobs = makeInMemoryBlobStore();
628 const now = new Date();
629 const orgScope = new Scope({
630 id: ScopeId.make("org"),
631 name: "acme-org",
632 createdAt: now,
633 });
634 const aliceScope = new Scope({
635 id: ScopeId.make("user-alice"),
636 name: "alice",
637 createdAt: now,
638 });
639
640 const adminExec = yield* createExecutor({
641 scopes: [orgScope],
642 adapter,
643 blobs,
644 plugins,
645 });
646 const aliceExec = yield* createExecutor({
647 scopes: [aliceScope, orgScope],
648 adapter,
649 blobs,
650 plugins,
651 });
652
653 yield* adminExec.openapi.addSpec({
654 spec: specJson,
655 scope: orgScope.id as string,
656 namespace: "vercel",
657 baseUrl: "https://api.vercel.example",
658 });
659
660 yield* aliceExec.openapi.addSpec({
661 spec: specJson,
662 scope: aliceScope.id as string,
663 namespace: "vercel",
664 });
665
666 const source = yield* aliceExec.openapi.getSource(
667 "vercel",
668 aliceScope.id as string,
669 );
670 expect(source?.scope).toBe(aliceScope.id);
671 expect(source?.config.baseUrl).toBe("https://api.vercel.example");
672 }),
673 );
674
675 it.effect("user-scope source shadows resolve inherited org bindings at the org source scope", () =>
676 Effect.gen(function* () {
677 const secretStore = new Map<string, string>();
678 const key = (scope: string, id: string) => `${scope}\u0000${id}`;
679 const memoryProvider: SecretProvider = {
680 key: "memory",
681 writable: true,
682 get: (id, scope) =>
683 Effect.sync(() => secretStore.get(key(scope, id)) ?? null),
684 set: (id, value, scope) =>
685 Effect.sync(() => {
686 secretStore.set(key(scope, id), value);
687 }),
688 delete: (id, scope) =>
689 Effect.sync(() => secretStore.delete(key(scope, id))),
690 };
691 const memorySecretsPlugin = definePlugin(() => ({
692 id: "memory-secrets" as const,
693 storage: () => ({}),
694 secretProviders: [memoryProvider],
695 }));
696
697 const httpClient = yield* HttpClient.HttpClient;
698 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
699 const plugins = [
700 openApiPlugin({ httpClientLayer: clientLayer }),
701 memorySecretsPlugin(),
702 ] as const;
703
704 const schema = collectSchemas(plugins);
705 const adapter = makeMemoryAdapter({ schema });
706 const blobs = makeInMemoryBlobStore();
707 const now = new Date();
708 const orgScope = new Scope({
709 id: ScopeId.make("org"),
710 name: "acme-org",
711 createdAt: now,
712 });
713 const aliceScope = new Scope({
714 id: ScopeId.make("user-alice"),
715 name: "alice",
716 createdAt: now,
717 });
718
719 const adminExec = yield* createExecutor({
720 scopes: [orgScope],
721 adapter,
722 blobs,
723 plugins,
724 });
725 const aliceExec = yield* createExecutor({
726 scopes: [aliceScope, orgScope],
727 adapter,
728 blobs,
729 plugins,
730 });
731
732 yield* adminExec.openapi.addSpec({
733 spec: specJson,
734 scope: orgScope.id as string,
735 namespace: "vercel",
736 baseUrl: "",
737 headers: {
738 Authorization: new ConfiguredHeaderBinding({
739 kind: "binding",
740 slot: "auth:token",
741 prefix: "Bearer ",
742 }),
743 },
744 });
745 yield* aliceExec.openapi.addSpec({
746 spec: specJson,
747 scope: aliceScope.id as string,
748 namespace: "vercel",
749 });
750
751 yield* aliceExec.secrets.set(
752 new SetSecretInput({
753 id: SecretId.make("alice_vercel_pat"),
754 scope: aliceScope.id,
755 name: "Alice Vercel PAT",
756 value: "alice-token",
757 }),
758 );
759 yield* aliceExec.openapi.setSourceBinding(
760 new OpenApiSourceBindingInput({
761 sourceId: "vercel",
762 sourceScope: orgScope.id,
763 scope: aliceScope.id,
764 slot: "auth:token",
765 value: {
766 kind: "secret",
767 secretId: SecretId.make("alice_vercel_pat"),
768 },
769 }),
770 );
771
772 const result = (yield* aliceExec.tools.invoke(
773 "vercel.projects.list",
774 {},
775 autoApprove,
776 )) as { data: { authorization?: string } | null; error: unknown };
777 expect(result.error).toBeNull();
778 expect(result.data?.authorization).toBe("Bearer alice-token");
779 }),
780 );
609});781});
packages/plugins/openapi/src/sdk/plugin.ts+55−12
@@ -458,9 +458,50 @@ const canonicalizeOAuth2 = ( · 457 unmodified lines
458 };458 };
459};459};
460 460
461interface EffectiveSourceConfig {
462 readonly config: SourceConfig;
463 readonly headersSource: StoredSource;
464 readonly oauth2Source: StoredSource;
465}
466
461const resolveEffectiveSourceConfig = (467const resolveEffectiveSourceConfig = (
468 ctx: PluginCtx<OpenapiStore>,
462 base: StoredSource,469 base: StoredSource,
463): Effect.Effect<SourceConfig, never> => Effect.succeed(base.config);470): Effect.Effect<EffectiveSourceConfig, StorageFailure> =>
471 Effect.gen(function* () {
472 const rank = new Map(ctx.scopes.map((scope, index) => [scope.id as string, index] as const));
473 const baseRank = rank.get(base.scope) ?? Infinity;
474 const fallback = (yield* ctx.storage.listSources())
475 .filter((source) => source.namespace === base.namespace)
476 .filter((source) => (rank.get(source.scope) ?? Infinity) > baseRank)
477 .sort(
478 (a, b) =>
479 (rank.get(a.scope) ?? Infinity) -
480 (rank.get(b.scope) ?? Infinity),
481 )[0];
482
483 if (!fallback) {
484 return {
485 config: base.config,
486 headersSource: base,
487 oauth2Source: base,
488 };
489 }
490
491 const hasBaseHeaders = Object.keys(base.config.headers ?? {}).length > 0;
492 return {
493 config: {
494 ...base.config,
495 sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl,
496 baseUrl: base.config.baseUrl || fallback.config.baseUrl,
497 namespace: base.config.namespace ?? fallback.config.namespace,
498 headers: hasBaseHeaders ? base.config.headers : fallback.config.headers,
499 oauth2: base.config.oauth2 ?? fallback.config.oauth2,
500 },
501 headersSource: hasBaseHeaders ? base : fallback,
502 oauth2Source: base.config.oauth2 ? base : fallback,
503 };
504 });
464 505
465const resolveConfiguredHeaders = (506const resolveConfiguredHeaders = (
466 ctx: PluginCtx<OpenapiStore>,507 ctx: PluginCtx<OpenapiStore>,
@@ -741,8 +782,9 @@ export const openApiPlugin = definePlugin( · 274 unmodified lines
741 Effect.gen(function* () {782 Effect.gen(function* () {
742 const existing = yield* ctx.storage.getSource(sourceId, scope);783 const existing = yield* ctx.storage.getSource(sourceId, scope);
743 if (!existing) return;784 if (!existing) return;
744 const resolvedConfig = yield* resolveEffectiveSourceConfig(existing);785 const effective = yield* resolveEffectiveSourceConfig(ctx, existing);
745 const sourceUrl = existing.config.sourceUrl;786 const resolvedConfig = effective.config;
787 const sourceUrl = resolvedConfig.sourceUrl;
746 if (!sourceUrl) return;788 if (!sourceUrl) return;
747 const specText = yield* resolveSpecText(sourceUrl).pipe(789 const specText = yield* resolveSpecText(sourceUrl).pipe(
748 Effect.provide(httpClientLayer),790 Effect.provide(httpClientLayer),
@@ -754,8 +796,8 @@ export const openApiPlugin = definePlugin( · 5 unmodified lines
754 name: existing.name,796 name: existing.name,
755 baseUrl: resolvedConfig.baseUrl,797 baseUrl: resolvedConfig.baseUrl,
756 namespace: existing.namespace,798 namespace: existing.namespace,
757 headers: resolvedConfig.headers,799 headers: existing.config.headers,
758 oauth2: resolvedConfig.oauth2,800 oauth2: existing.config.oauth2,
759 });801 });
760 });802 });
761 803
@@ -819,10 +861,10 @@ export const openApiPlugin = definePlugin( · 57 unmodified lines
819 Effect.gen(function* () {861 Effect.gen(function* () {
820 const source = yield* ctx.storage.getSource(namespace, scope);862 const source = yield* ctx.storage.getSource(namespace, scope);
821 if (!source) return null;863 if (!source) return null;
822 const config = yield* resolveEffectiveSourceConfig(source);864 const effective = yield* resolveEffectiveSourceConfig(ctx, source);
823 return {865 return {
824 ...source,866 ...source,
825 config,867 config: effective.config,
826 };868 };
827 }),869 }),
828 870
@@ -1246,12 +1288,13 @@ export const openApiPlugin = definePlugin( · 417 unmodified lines
1246 );1288 );
1247 }1289 }
1248 1290
1249 const config = yield* resolveEffectiveSourceConfig(source);1291 const effective = yield* resolveEffectiveSourceConfig(ctx, source);
1292 const config = effective.config;
1250 const resolvedHeaders = yield* resolveConfiguredHeaders(ctx, {1293 const resolvedHeaders = yield* resolveConfiguredHeaders(ctx, {
1251 sourceId: op.sourceId,1294 sourceId: op.sourceId,
1252 sourceScope: toolScope,1295 sourceScope: effective.headersSource.scope,
1253 headers: config.headers ?? {},1296 headers: config.headers ?? {},
1254 legacyHeaders: source.legacy?.headers,1297 legacyHeaders: effective.headersSource.legacy?.headers,
1255 }).pipe(1298 }).pipe(
1256 Effect.mapError((err) => new Error(err.message)),1299 Effect.mapError((err) => new Error(err.message)),
1257 );1300 );
@@ -1263,9 +1306,9 @@ export const openApiPlugin = definePlugin( · 5 unmodified lines
1263 if (config.oauth2) {1306 if (config.oauth2) {
1264 const connectionId = yield* resolveOAuthConnectionId(ctx, {1307 const connectionId = yield* resolveOAuthConnectionId(ctx, {
1265 sourceId: op.sourceId,1308 sourceId: op.sourceId,
1266 sourceScope: toolScope,1309 sourceScope: effective.oauth2Source.scope,
1267 oauth2: config.oauth2,1310 oauth2: config.oauth2,
1268 legacyOAuth2: source.legacy?.oauth2,1311 legacyOAuth2: effective.oauth2Source.legacy?.oauth2,
1269 });1312 });
1270 if (!connectionId) {1313 if (!connectionId) {
1271 return yield* Effect.fail(1314 return yield* Effect.fail(