History

Keep OpenAPI credentials bound to destinations

Rhys Sullivan committed 6 months ago

Commit 84baa511 parent 416073e2 files · +101 −19Browse files

Unified · Split

2 files
  1. packages/plugins/openapi/src/sdk/plugin.test.ts +67 −8
  2. packages/plugins/openapi/src/sdk/plugin.ts +34 −11
packages/plugins/openapi/src/sdk/plugin.test.ts+67−8
@@ -944,7 +944,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 943 unmodified lines
944944 }),
945945 );
946946
947− it.effect("updateSource on user shadow does not mutate the org row", () =>
947+ it.effect("updateSource on user shadow cannot override the inherited base URL", () =>
948948 Effect.gen(function* () {
949949 const httpClient = yield* HttpClient.HttpClient;
950950 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
@@ -970,25 +970,84 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 19 unmodified lines
970970 spec: specJson,
971971 scope: String(USER_SCOPE),
972972 namespace: "shared",
973− baseUrl: "https://user.example.com",
974973 name: "User Source",
975974 });
976975
977− yield* executor.openapi.updateSource("shared", String(USER_SCOPE), {
978− name: "User Renamed",
979− baseUrl: "https://user-new.example.com",
980− });
976+ const updateResult = yield* executor.openapi
977+ .updateSource("shared", String(USER_SCOPE), {
978+ name: "User Renamed",
979+ baseUrl: "https://user-new.example.com",
980+ })
981+ .pipe(
982+ Effect.match({
983+ onFailure: (error) => error,
984+ onSuccess: () => null,
985+ }),
986+ );
981987
982988 const userView = yield* executor.openapi.getSource("shared", String(USER_SCOPE));
983989 const orgView = yield* executor.openapi.getSource("shared", String(ORG_SCOPE));
984990
985− expect(userView?.name).toBe("User Renamed");
986− expect(userView?.config.baseUrl).toBe("https://user-new.example.com");
991+ expect(updateResult).toMatchObject({ _tag: "OpenApiOAuthError" });
992+ expect(userView?.name).toBe("User Source");
993+ expect(userView?.config.baseUrl).toBe("https://org.example.com");
987994 expect(orgView?.name).toBe("Org Source");
988995 expect(orgView?.config.baseUrl).toBe("https://org.example.com");
989996 }),
990997 );
991998
999+ it.effect("addSpec on user shadow cannot override the inherited base URL", () =>
1000+ Effect.gen(function* () {
1001+ const server = yield* makeOpenApiTestServer({ spec });
1002+ const executor = yield* createExecutor(
1003+ makeTestConfig({
1004+ scopes: stackedScopes,
1005+ plugins: [
1006+ openApiPlugin({ httpClientLayer: server.httpClientLayer }),
1007+ memorySecretsPlugin(),
1008+ ] as const,
1009+ }),
1010+ );
1011+
1012+ yield* executor.secrets.set(
1013+ new SetSecretInput({
1014+ id: SecretId.make("org-api-token"),
1015+ scope: ORG_SCOPE,
1016+ name: "Org API token",
1017+ value: "org-secret",
1018+ }),
1019+ );
1020+
1021+ yield* executor.openapi.addSpec({
1022+ spec: server.specJson,
1023+ scope: String(ORG_SCOPE),
1024+ namespace: "shadow_auth",
1025+ baseUrl: "https://org.example.com",
1026+ credentialTargetScope: String(ORG_SCOPE),
1027+ headers: {
1028+ Authorization: { secretId: "org-api-token", prefix: "Bearer " },
1029+ },
1030+ });
1031+
1032+ const addResult = yield* executor.openapi
1033+ .addSpec({
1034+ spec: server.specJson,
1035+ scope: String(USER_SCOPE),
1036+ namespace: "shadow_auth",
1037+ baseUrl: server.baseUrl,
1038+ name: "User Shadow",
1039+ })
1040+ .pipe(
1041+ Effect.match({
1042+ onFailure: (error) => error,
1043+ onSuccess: () => null,
1044+ }),
1045+ );
1046+
1047+ expect(addResult).toMatchObject({ _tag: "OpenApiOAuthError" });
1048+ }),
1049+ );
1050+
9921051 it.effect(
9931052 "refreshing a user shadow uses inherited spec-fetch credentials without copying them",
9941053 () =>
packages/plugins/openapi/src/sdk/plugin.ts+34−11
@@ -529,20 +529,29 @@ const targetScopeForBinding = ( · 528 unmodified lines
529529 );
530530 };
531531
532−const resolveEffectiveSourceConfig = (
532+const findOuterSource = (
533533 ctx: PluginCtx<OpenapiStore>,
534− base: StoredSource,
535−): Effect.Effect<EffectiveSourceConfig, StorageFailure> =>
534+ namespace: string,
535+ scope: string,
536+): Effect.Effect<StoredSource | null, StorageFailure> =>
536537 Effect.gen(function* () {
537538 const ranks = scopeRanks(ctx);
538− const baseRank = scopeRank(ranks, base.scope);
539− let fallback: StoredSource | null = null;
539+ const baseRank = scopeRank(ranks, scope);
540540 for (let index = baseRank + 1; index < ctx.scopes.length; index++) {
541− const scope = ctx.scopes[index];
542− if (!scope) continue;
543− fallback = yield* ctx.storage.getSource(base.namespace, scope.id);
544− if (fallback) break;
541+ const candidateScope = ctx.scopes[index];
542+ if (!candidateScope) continue;
543+ const source = yield* ctx.storage.getSource(namespace, candidateScope.id);
544+ if (source) return source;
545545 }
546+ return null;
547+ });
548+
549+const resolveEffectiveSourceConfig = (
550+ ctx: PluginCtx<OpenapiStore>,
551+ base: StoredSource,
552+): Effect.Effect<EffectiveSourceConfig, StorageFailure> =>
553+ Effect.gen(function* () {
554+ const fallback = yield* findOuterSource(ctx, base.namespace, base.scope);
546555
547556 if (!fallback) {
548557 return {
@@ -561,7 +570,7 @@ const resolveEffectiveSourceConfig = ( · 12 unmodified lines
561570 config: {
562571 ...base.config,
563572 sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl,
564− baseUrl: base.config.baseUrl || fallback.config.baseUrl,
573+ baseUrl: fallback.config.baseUrl,
565574 namespace: base.config.namespace ?? fallback.config.namespace,
566575 headers: hasBaseHeaders ? base.config.headers : fallback.config.headers,
567576 queryParams: hasBaseQueryParams ? base.config.queryParams : fallback.config.queryParams,
@@ -806,6 +815,12 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 238 unmodified lines
806815 Option.getOrElse(result.title, () => "api")
807816 .toLowerCase()
808817 .replace(/[^a-z0-9]+/g, "_");
818+ const outerSource = yield* findOuterSource(ctx, namespace, input.scope);
819+ if (outerSource && input.baseUrl !== undefined && input.baseUrl.trim() !== "") {
820+ return yield* new OpenApiOAuthError({
821+ message: "OpenAPI source shadows inherit the outer source base URL",
822+ });
823+ }
809824
810825 const hoistedDefs: Record<string, unknown> = {};
811826 if (doc.components?.schemas) {
@@ -814,7 +829,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 2 unmodified lines
814829 }
815830 }
816831
817− const baseUrl = input.baseUrl ?? resolveBaseUrl(result.servers);
832+ const baseUrl = outerSource ? undefined : (input.baseUrl ?? resolveBaseUrl(result.servers));
818833 const canonicalHeaders = canonicalizeHeaders(input.headers);
819834 const canonicalQueryParams = canonicalizeCredentialMap(
820835 input.queryParams,
@@ -1073,6 +1088,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 252 unmodified lines
10731088 ...(input.oauth2 !== undefined ? ["oauth2:"] : []),
10741089 ];
10751090 const targetScope = input.credentialTargetScope ?? scope;
1091+ if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") {
1092+ const outerSource = yield* findOuterSource(ctx, namespace, scope);
1093+ if (outerSource) {
1094+ return yield* new OpenApiOAuthError({
1095+ message: "OpenAPI source shadows inherit the outer source base URL",
1096+ });
1097+ }
1098+ }
10761099 if (affectedPrefixes.length > 0 || directBindings.length > 0) {
10771100 yield* validateOpenApiBindingTarget(ctx, {
10781101 sourceId: namespace,