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
944 }),944 }),
945 );945 );
946 946
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", () =>
948 Effect.gen(function* () {948 Effect.gen(function* () {
949 const httpClient = yield* HttpClient.HttpClient;949 const httpClient = yield* HttpClient.HttpClient;
950 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);950 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
@@ -970,25 +970,84 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 19 unmodified lines
970 spec: specJson,970 spec: specJson,
971 scope: String(USER_SCOPE),971 scope: String(USER_SCOPE),
972 namespace: "shared",972 namespace: "shared",
973 baseUrl: "https://user.example.com",
974 name: "User Source",973 name: "User Source",
975 });974 });
976 975
977 yield* executor.openapi.updateSource("shared", String(USER_SCOPE), {976 const updateResult = yield* executor.openapi
978 name: "User Renamed",977 .updateSource("shared", String(USER_SCOPE), {
979 baseUrl: "https://user-new.example.com",978 name: "User Renamed",
980 });979 baseUrl: "https://user-new.example.com",
980 })
981 .pipe(
982 Effect.match({
983 onFailure: (error) => error,
984 onSuccess: () => null,
985 }),
986 );
981 987
982 const userView = yield* executor.openapi.getSource("shared", String(USER_SCOPE));988 const userView = yield* executor.openapi.getSource("shared", String(USER_SCOPE));
983 const orgView = yield* executor.openapi.getSource("shared", String(ORG_SCOPE));989 const orgView = yield* executor.openapi.getSource("shared", String(ORG_SCOPE));
984 990
985 expect(userView?.name).toBe("User Renamed");991 expect(updateResult).toMatchObject({ _tag: "OpenApiOAuthError" });
986 expect(userView?.config.baseUrl).toBe("https://user-new.example.com");992 expect(userView?.name).toBe("User Source");
993 expect(userView?.config.baseUrl).toBe("https://org.example.com");
987 expect(orgView?.name).toBe("Org Source");994 expect(orgView?.name).toBe("Org Source");
988 expect(orgView?.config.baseUrl).toBe("https://org.example.com");995 expect(orgView?.config.baseUrl).toBe("https://org.example.com");
989 }),996 }),
990 );997 );
991 998
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
992 it.effect(1051 it.effect(
993 "refreshing a user shadow uses inherited spec-fetch credentials without copying them",1052 "refreshing a user shadow uses inherited spec-fetch credentials without copying them",
994 () =>1053 () =>
packages/plugins/openapi/src/sdk/plugin.ts+34−11
@@ -529,20 +529,29 @@ const targetScopeForBinding = ( · 528 unmodified lines
529 );529 );
530};530};
531 531
532const resolveEffectiveSourceConfig = (532const findOuterSource = (
533 ctx: PluginCtx<OpenapiStore>,533 ctx: PluginCtx<OpenapiStore>,
534 base: StoredSource,534 namespace: string,
535): Effect.Effect<EffectiveSourceConfig, StorageFailure> =>535 scope: string,
536): Effect.Effect<StoredSource | null, StorageFailure> =>
536 Effect.gen(function* () {537 Effect.gen(function* () {
537 const ranks = scopeRanks(ctx);538 const ranks = scopeRanks(ctx);
538 const baseRank = scopeRank(ranks, base.scope);539 const baseRank = scopeRank(ranks, scope);
539 let fallback: StoredSource | null = null;
540 for (let index = baseRank + 1; index < ctx.scopes.length; index++) {540 for (let index = baseRank + 1; index < ctx.scopes.length; index++) {
541 const scope = ctx.scopes[index];541 const candidateScope = ctx.scopes[index];
542 if (!scope) continue;542 if (!candidateScope) continue;
543 fallback = yield* ctx.storage.getSource(base.namespace, scope.id);543 const source = yield* ctx.storage.getSource(namespace, candidateScope.id);
544 if (fallback) break;544 if (source) return source;
545 }545 }
546 return null;
547 });
548
549const 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);
546 555
547 if (!fallback) {556 if (!fallback) {
548 return {557 return {
@@ -561,7 +570,7 @@ const resolveEffectiveSourceConfig = ( · 12 unmodified lines
561 config: {570 config: {
562 ...base.config,571 ...base.config,
563 sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl,572 sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl,
564 baseUrl: base.config.baseUrl || fallback.config.baseUrl,573 baseUrl: fallback.config.baseUrl,
565 namespace: base.config.namespace ?? fallback.config.namespace,574 namespace: base.config.namespace ?? fallback.config.namespace,
566 headers: hasBaseHeaders ? base.config.headers : fallback.config.headers,575 headers: hasBaseHeaders ? base.config.headers : fallback.config.headers,
567 queryParams: hasBaseQueryParams ? base.config.queryParams : fallback.config.queryParams,576 queryParams: hasBaseQueryParams ? base.config.queryParams : fallback.config.queryParams,
@@ -806,6 +815,12 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 238 unmodified lines
806 Option.getOrElse(result.title, () => "api")815 Option.getOrElse(result.title, () => "api")
807 .toLowerCase()816 .toLowerCase()
808 .replace(/[^a-z0-9]+/g, "_");817 .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 }
809 824
810 const hoistedDefs: Record<string, unknown> = {};825 const hoistedDefs: Record<string, unknown> = {};
811 if (doc.components?.schemas) {826 if (doc.components?.schemas) {
@@ -814,7 +829,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 2 unmodified lines
814 }829 }
815 }830 }
816 831
817 const baseUrl = input.baseUrl ?? resolveBaseUrl(result.servers);832 const baseUrl = outerSource ? undefined : (input.baseUrl ?? resolveBaseUrl(result.servers));
818 const canonicalHeaders = canonicalizeHeaders(input.headers);833 const canonicalHeaders = canonicalizeHeaders(input.headers);
819 const canonicalQueryParams = canonicalizeCredentialMap(834 const canonicalQueryParams = canonicalizeCredentialMap(
820 input.queryParams,835 input.queryParams,
@@ -1073,6 +1088,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 252 unmodified lines
1073 ...(input.oauth2 !== undefined ? ["oauth2:"] : []),1088 ...(input.oauth2 !== undefined ? ["oauth2:"] : []),
1074 ];1089 ];
1075 const targetScope = input.credentialTargetScope ?? scope;1090 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 }
1076 if (affectedPrefixes.length > 0 || directBindings.length > 0) {1099 if (affectedPrefixes.length > 0 || directBindings.length > 0) {
1077 yield* validateOpenApiBindingTarget(ctx, {1100 yield* validateOpenApiBindingTarget(ctx, {
1078 sourceId: namespace,1101 sourceId: namespace,