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

569 lines
17 KiB
TypeScript
Raw 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;
/**
* Providers matching the given ids, batched with `filter[id__in]` (page size
* `PROVIDERS_PAGE_MAX`, the server max, which also bounds the id batch size)
* rather than one `GET /providers/{id}` per id. Shared by every action below
* that resolves providers by id; a batch that fails to fetch leaves its
* providers out of the result rather than failing the rest, and it is on each
* caller to say what "missing" means for its own map. Not exported: an
* exported function in this `"use server"` module becomes a callable server
* action.
*/
const fetchProvidersByIds = async (
providerIds: string[],
): Promise<ProvidersApiResponse["data"]> => {
const uniqueIds = Array.from(new Set(providerIds.filter(Boolean)));
if (uniqueIds.length === 0) {
return [];
}
const headers = await getAuthHeaders({ contentType: false });
const providers: ProvidersApiResponse["data"] = [];
for (let start = 0; start < uniqueIds.length; start += PROVIDERS_PAGE_MAX) {
const batch = uniqueIds.slice(start, start + PROVIDERS_PAGE_MAX);
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;
providers.push(...(result?.data ?? []));
} catch {
// A failed batch leaves its providers out of the result rather than
// failing the rest.
}
}
return providers;
};
/**
* 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.
*/
export const getProviderUidsByIds = async (
providerIds: string[],
): Promise<Record<string, string>> => {
const uidById: Record<string, string> = {};
for (const provider of await fetchProvidersByIds(providerIds)) {
const uid = provider?.attributes?.uid;
if (typeof provider?.id === "string" && typeof uid === "string") {
uidById[provider.id] = uid;
}
}
return uidById;
};
/**
* `connection.last_checked_at` for each given provider, keyed by id. Read before
* a batch of connection checks is dispatched, so `resolveProviderConnectionState`
* can tell a check's own result apart from an older one already on record by
* comparing values, never by comparing the browser's clock against the server's
* (see that function for why). A provider missing from the response -- the batch
* read failed, or it was deleted mid-flight -- is left out of the map rather than
* defaulted, so callers can tell "no prior check" (`null`) from "unknown".
*/
export const getProviderConnectionBaselines = async (
providerIds: string[],
): Promise<Record<string, string | null>> => {
const baselineById: Record<string, string | null> = {};
for (const provider of await fetchProvidersByIds(providerIds)) {
if (typeof provider?.id === "string") {
baselineById[provider.id] =
provider.attributes?.connection?.last_checked_at ?? null;
}
}
return baselineById;
};
/**
* Uid and `connection.last_checked_at` for each given provider, keyed by id, read
* with a single batched `filter[id__in]` request. The organization onboarding
* apply step needs both right after creating providers: the uid to match each one
* back to the candidate it was created for (see `getProviderUidsByIds`), and the
* baseline to compare a dispatched check's result against (see
* `getProviderConnectionBaselines`). Reading them together avoids fetching the
* same set of providers twice.
*/
export const getProviderUidsAndConnectionBaselines = async (
providerIds: string[],
): Promise<{
uidById: Record<string, string>;
baselineById: Record<string, string | null>;
}> => {
const uidById: Record<string, string> = {};
const baselineById: Record<string, string | null> = {};
for (const provider of await fetchProvidersByIds(providerIds)) {
if (typeof provider?.id !== "string") {
continue;
}
const uid = provider.attributes?.uid;
if (typeof uid === "string") {
uidById[provider.id] = uid;
}
baselineById[provider.id] =
provider.attributes?.connection?.last_checked_at ?? null;
}
return { uidById, baselineById };
};
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);
}
};