Files
prowler/ui/actions/providers/providers.ts

496 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { apiBaseUrl, getAuthHeaders, getFormValue } from "@/lib";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { buildSecretConfig } from "@/lib/provider-credentials/build-credentials";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import { appendSanitizedProviderInFilters } from "@/lib/provider-filters";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { ProvidersApiResponse, ProviderType } from "@/types/providers";
export const getProviders = async ({
page = 1,
query = "",
sort = "",
filters = {},
pageSize = 10,
}: {
page?: number;
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
pageSize?: number;
}): Promise<ProvidersApiResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
if (isNaN(Number(page)) || page < 1) redirect("/providers");
const url = new URL(`${apiBaseUrl}/providers?include=provider_groups`);
if (page) url.searchParams.append("page[number]", page.toString());
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
appendSanitizedProviderInFilters(url, filters);
try {
const response = await fetch(url.toString(), {
headers,
});
return (await handleApiResponse(response)) as
| ProvidersApiResponse
| undefined;
} catch (error) {
console.error("Error fetching providers:", error);
return undefined;
}
};
/**
* Fetches all providers by iterating through all pages.
* This is useful when you need the complete list of providers without pagination limits,
* such as for dropdown menus or selection lists.
*/
export const getAllProviders = async ({
query = "",
sort = "",
filters = {},
}: {
query?: string;
sort?: string;
filters?: Record<string, string | string[] | undefined>;
} = {}): Promise<ProvidersApiResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const pageSize = 100; // Use larger page size to minimize API calls
const maxPages = 50; // Safety limit: 50 pages × 100 = 5000 providers max
let currentPage = 1;
const allProviders: ProvidersApiResponse["data"] = [];
let lastResponse: ProvidersApiResponse | undefined;
let hasMorePages = true;
try {
while (hasMorePages && currentPage <= maxPages) {
const url = new URL(`${apiBaseUrl}/providers?include=provider_groups`);
url.searchParams.append("page[number]", currentPage.toString());
url.searchParams.append("page[size]", pageSize.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
appendSanitizedProviderInFilters(url, filters);
const response = await fetch(url.toString(), { headers });
const data = (await handleApiResponse(response)) as
| ProvidersApiResponse
| undefined;
if (!data?.data || data.data.length === 0) {
hasMorePages = false;
continue;
}
allProviders.push(...data.data);
lastResponse = data;
// Check if we've fetched all pages
const totalPages = data.meta?.pagination?.pages || 1;
if (currentPage >= totalPages) {
hasMorePages = false;
} else {
currentPage++;
}
}
// Return combined response with all providers
if (lastResponse) {
return {
...lastResponse,
data: allProviders,
meta: {
...lastResponse.meta,
pagination: {
...lastResponse.meta?.pagination,
page: 1,
pages: 1,
count: allProviders.length,
},
},
};
}
return undefined;
} catch (error) {
console.error("Error fetching all providers:", error);
return undefined;
}
};
export const getProvider = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: false });
const providerId = formData.get("id");
const url = new URL(`${apiBaseUrl}/providers/${providerId}`);
try {
const response = await fetch(url.toString(), {
headers,
});
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
/** Server max for `page[size]`, which also bounds the id batch size. */
const PROVIDERS_PAGE_MAX = 100;
/**
* Uids of the given providers, keyed by provider id. A provider's `uid` is the
* candidate it was created for (AWS account id / GCP project id), so this is what
* matches an apply's created providers back to the selection. Batched with
* `filter[id__in]` rather than one `GET /providers/{id}` per id.
*/
export const getProviderUidsByIds = async (
providerIds: string[],
): Promise<Record<string, string>> => {
const uniqueIds = Array.from(new Set(providerIds.filter(Boolean)));
if (uniqueIds.length === 0) {
return {};
}
const headers = await getAuthHeaders({ contentType: false });
const batches: string[][] = [];
for (let start = 0; start < uniqueIds.length; start += PROVIDERS_PAGE_MAX) {
batches.push(uniqueIds.slice(start, start + PROVIDERS_PAGE_MAX));
}
const uidById: Record<string, string> = {};
for (const batch of batches) {
const url = new URL(`${apiBaseUrl}/providers`);
url.searchParams.set("filter[id__in]", batch.join(","));
url.searchParams.set("page[size]", String(PROVIDERS_PAGE_MAX));
try {
const response = await fetch(url.toString(), { headers });
const result = (await handleApiResponse(response)) as
| ProvidersApiResponse
| undefined;
for (const provider of result?.data ?? []) {
const uid = provider?.attributes?.uid;
if (typeof provider?.id === "string" && typeof uid === "string") {
uidById[provider.id] = uid;
}
}
} catch {
// A failed batch leaves its providers unmapped rather than failing the rest.
}
}
return uidById;
};
export const updateProvider = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID);
const providerAlias = formData.get(ProviderCredentialFields.PROVIDER_ALIAS);
const url = new URL(`${apiBaseUrl}/providers/${providerId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "providers",
id: providerId,
attributes: { alias: providerAlias },
},
}),
});
return handleApiResponse(response, "/providers");
} catch (error) {
return handleApiError(error);
}
};
export const addProvider = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const providerType = formData.get("providerType") as ProviderType;
const providerUid = formData.get("providerUid") as string;
const providerAlias = formData.get("providerAlias") as string;
const url = new URL(`${apiBaseUrl}/providers`);
try {
const bodyData = {
data: {
type: "providers",
attributes: {
provider: providerType,
uid: providerUid,
...(providerAlias?.trim() && { alias: providerAlias.trim() }),
},
},
};
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(bodyData),
});
return handleApiResponse(response, "/providers");
} catch (error) {
return handleApiError(error);
}
};
export const addCredentialsProvider = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/providers/secrets`);
const providerId = getFormValue(
formData,
ProviderCredentialFields.PROVIDER_ID,
);
const providerType = getFormValue(
formData,
ProviderCredentialFields.PROVIDER_TYPE,
) as ProviderType;
const providerUid = getFormValue(
formData,
ProviderCredentialFields.PROVIDER_UID,
) as string | undefined;
try {
// For IaC provider, fetch the provider data to get the repository URL from uid
if (providerType === "iac") {
const providerUrl = new URL(`${apiBaseUrl}/providers/${providerId}`);
const providerResponse = await fetch(providerUrl.toString(), {
headers: await getAuthHeaders({ contentType: false }),
});
if (providerResponse.ok) {
const providerData = await providerResponse.json();
const providerUid = providerData?.data?.attributes?.uid;
// Add the repository URL to formData using the provider's uid
if (providerUid) {
formData.append(ProviderCredentialFields.REPOSITORY_URL, providerUid);
}
}
}
const { secretType, secret } = buildSecretConfig(
formData,
providerType,
providerUid,
);
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "provider-secrets",
attributes: { secret_type: secretType, secret },
relationships: {
provider: {
data: { id: providerId, type: "providers" },
},
},
},
}),
});
return handleApiResponse(response, "/providers");
} catch (error) {
return handleApiError(error);
}
};
export const updateCredentialsProvider = async (
credentialsId: string,
formData: FormData,
) => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/providers/secrets/${credentialsId}`);
const providerType = getFormValue(
formData,
ProviderCredentialFields.PROVIDER_TYPE,
) as ProviderType;
try {
const { secretType, secret } = buildSecretConfig(formData, providerType);
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "provider-secrets",
id: credentialsId,
attributes: { secret_type: secretType, secret },
},
}),
});
return handleApiResponse(response, "/providers");
} catch (error) {
return handleApiError(error);
}
};
export const checkConnectionProvider = async (
formData: FormData,
{ revalidate = true }: { revalidate?: boolean } = {},
) => {
const headers = await getAuthHeaders({ contentType: false });
const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID);
const url = new URL(`${apiBaseUrl}/providers/${providerId}/connection`);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
// Batches opt out: revalidating here would re-render the providers page
// once per provider.
return handleApiResponse(response, revalidate ? "/providers" : undefined);
} catch (error) {
return handleApiError(error);
}
};
/** Connection checks in flight at once. */
const CONNECTION_CHECK_CONCURRENCY_LIMIT = 10;
/**
* Dispatches a connection check per provider, returning the task testing each
* one keyed by provider id. A failed dispatch is reported under `error` and
* never cancels the rest of the batch.
*
* The fan-out belongs here, not in the caller: client-invoked server actions run
* one at a time through Next's action queue, so a client-side loop is serialized
* whatever concurrency it declares.
*/
export const startProviderConnectionChecks = async (
providerIds: string[],
): Promise<Record<string, { taskId?: string; error?: unknown }>> => {
const uniqueIds = Array.from(new Set(providerIds.filter(Boolean)));
const outcomes: Record<string, { taskId?: string; error?: unknown }> = {};
await runWithConcurrencyLimit(
uniqueIds,
CONNECTION_CHECK_CONCURRENCY_LIMIT,
async (providerId) => {
const formData = new FormData();
formData.set(ProviderCredentialFields.PROVIDER_ID, providerId);
try {
const result = await checkConnectionProvider(formData, {
revalidate: false,
});
if (result?.error || result?.errors?.length) {
outcomes[providerId] = { error: result };
return;
}
outcomes[providerId] = { taskId: result?.data?.id };
} catch (error) {
outcomes[providerId] = { error: handleApiError(error) };
}
},
);
return outcomes;
};
/** Called once after a batch of checks, which revalidate nothing themselves. */
export const revalidateProviders = async () => {
revalidatePath("/providers");
};
export const deleteCredentials = async (secretId: string) => {
const headers = await getAuthHeaders({ contentType: false });
if (!secretId) {
return { error: "Secret ID is required" };
}
const url = new URL(`${apiBaseUrl}/providers/secrets/${secretId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
try {
const errorData = await response.json();
throw new Error(
errorData?.message || "Failed to delete the credentials",
);
} catch {
throw new Error("Failed to delete the credentials");
}
}
let data = null;
if (response.status !== 204) {
data = await response.json();
}
revalidatePath("/providers");
return data || { success: true };
} catch (error) {
handleApiError(error);
}
};
export const deleteProvider = async (formData: FormData) => {
const headers = await getAuthHeaders({ contentType: false });
const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID);
if (!providerId) {
return { error: "Provider ID is required" };
}
const url = new URL(`${apiBaseUrl}/providers/${providerId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
try {
const errorData = await response.json();
throw new Error(errorData?.message || "Failed to delete the provider");
} catch {
throw new Error("Failed to delete the provider");
}
}
let data = null;
if (response.status !== 204) {
data = await response.json();
}
revalidatePath("/providers");
return data || { success: true };
} catch (error) {
handleApiError(error);
}
};