Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions packages/plugins/openapi/src/react/AddOpenApiSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ import {
googleStandardUserOAuthPresets,
openApiPresets,
} from "../sdk/presets";
import {
secretCredentialBindingsSubmitError,
type PendingSecretCredentialBinding,
} from "../sdk/secret-binding-validation";
import { GOOGLE_BUNDLE_PRESET_ID } from "../sdk/google-presets";
import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
import {
Expand Down Expand Up @@ -719,17 +723,10 @@ export default function AddOpenApiSource(props: {
const resolvedBaseUrl = baseUrl.trim();
const sourceScope = ScopeId.make(scopeId);

type PendingSecretBinding = {
readonly slot: string;
readonly secretId: string;
readonly scope: ScopeId;
readonly secretScope: ScopeId;
};

const configuredHeaders: Record<string, { kind: "secret"; prefix?: string }> = {};
const headerBindings: PendingSecretBinding[] = [];
const headerBindings: PendingSecretCredentialBinding[] = [];
const configuredQueryParams: Record<string, string | { kind: "secret"; prefix?: string }> = {};
const queryParamBindings: PendingSecretBinding[] = [];
const queryParamBindings: PendingSecretCredentialBinding[] = [];
for (const ch of customHeaders) {
if (!ch.name.trim()) continue;
const slot = headerBindingSlot(ch.name.trim());
Expand Down Expand Up @@ -768,7 +765,7 @@ export default function AddOpenApiSource(props: {
string,
string | { kind: "secret"; prefix?: string }
> = {};
const specFetchBindings: PendingSecretBinding[] = [];
const specFetchBindings: PendingSecretCredentialBinding[] = [];
for (const header of specFetchCredentials.headers) {
const name = header.name.trim();
if (!name || !header.secretId) continue;
Expand Down Expand Up @@ -1641,6 +1638,41 @@ export default function AddOpenApiSource(props: {
return;
}

const pendingSecretBindings: PendingSecretCredentialBinding[] = [
...headerBindings,
...queryParamBindings,
...specFetchBindings,
...(configuredOAuth2 && oauth2ClientIdSecretId
? [
{
slot: configuredOAuth2.clientIdSlot,
secretId: oauth2ClientIdSecretId,
scope: clientIdBindingScope,
secretScope: clientIdBindingScope,
},
]
: []),
...(configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId
? [
{
slot: configuredOAuth2.clientSecretSlot,
secretId: oauth2ClientSecretSecretId,
scope: clientSecretBindingScope,
secretScope: clientSecretBindingScope,
},
]
: []),
];
const missingSecretMessage = secretCredentialBindingsSubmitError(
pendingSecretBindings,
secretList,
);
if (missingSecretMessage) {
setAddError(missingSecretMessage);
setAdding(false);
return;
}

const namespace = resolvedSourceId;
const specForAdd =
googleServicePickerEnabled && selectedGooglePresets.length > 0
Expand Down
47 changes: 47 additions & 0 deletions packages/plugins/openapi/src/sdk/secret-binding-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "@effect/vitest";

import { ScopeId } from "@executor-js/sdk/shared";

import {
findMissingSecretCredentialBindings,
secretCredentialBindingsSubmitError,
type PendingSecretCredentialBinding,
} from "./secret-binding-validation";

const binding = (secretId: string, secretScope: string): PendingSecretCredentialBinding => ({
slot: "header:authorization",
secretId,
scope: ScopeId.make(secretScope),
secretScope: ScopeId.make(secretScope),
});

describe("OpenAPI secret binding validation", () => {
it("keeps bindings whose secret exists in the selected credential scope", () => {
const missing = findMissingSecretCredentialBindings(
[binding("cloudflare-api-authorization", "mulroy-cloud")],
[{ id: "cloudflare-api-authorization", scopeId: "mulroy-cloud" }],
);

expect(missing).toEqual([]);
});

it("returns a submit-blocking error when a selected secret no longer exists", () => {
const message = secretCredentialBindingsSubmitError(
[binding("cloudflare-dmmulroy-api-authorization", "mulroy-cloud")],
[{ id: "cloudflare-api-authorization-dmmulroy", scopeId: "mulroy-cloud" }],
);

expect(message).toContain("cloudflare-dmmulroy-api-authorization");
expect(message).toContain("before adding the source");
});

it("requires the secret to exist in the same credential scope", () => {
const staleOrgBinding = binding("api-token", "mulroy-cloud");
const missing = findMissingSecretCredentialBindings(
[staleOrgBinding],
[{ id: "api-token", scopeId: "user-dmmulroy" }],
);

expect(missing).toEqual([staleOrgBinding]);
});
});
42 changes: 42 additions & 0 deletions packages/plugins/openapi/src/sdk/secret-binding-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ScopeId } from "@executor-js/sdk/shared";

export interface PendingSecretCredentialBinding {
readonly slot: string;
readonly secretId: string;
readonly scope: ScopeId;
readonly secretScope: ScopeId;
}

export interface AvailableSecretRef {
readonly id: string;
readonly scopeId: string | ScopeId;
}

const secretKey = (scopeId: string | ScopeId, secretId: string): string =>
`${String(scopeId)}\u0000${secretId}`;

export const findMissingSecretCredentialBindings = (
bindings: readonly PendingSecretCredentialBinding[],
secrets: readonly AvailableSecretRef[],
): readonly PendingSecretCredentialBinding[] => {
const available = new Set(secrets.map((secret) => secretKey(secret.scopeId, secret.id)));
return bindings.filter(
(binding) => !available.has(secretKey(binding.secretScope, binding.secretId)),
);
};

export const missingSecretCredentialBindingsMessage = (
missing: readonly PendingSecretCredentialBinding[],
): string | null => {
if (missing.length === 0) return null;
if (missing.length === 1) {
return `Secret "${missing[0]!.secretId}" no longer exists in the selected credential scope. Choose an existing secret or create a new one before adding the source.`;
}
return `${missing.length} selected secrets no longer exist in their selected credential scopes. Choose existing secrets or create new ones before adding the source.`;
};

export const secretCredentialBindingsSubmitError = (
bindings: readonly PendingSecretCredentialBinding[],
secrets: readonly AvailableSecretRef[],
): string | null =>
missingSecretCredentialBindingsMessage(findMissingSecretCredentialBindings(bindings, secrets));