mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
569 lines
17 KiB
TypeScript
569 lines
17 KiB
TypeScript
"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);
|
||
}
|
||
};
|