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

588 lines
18 KiB
TypeScript

"use server";
import { z } from "zod";
import { auth } from "@/auth.config";
import { apiBaseUrl } from "@/lib";
import { REGISTRY_ACCESS } from "@/lib/registry/access";
import {
evaluateRegistryAccess,
evaluateRegistryProviderAccess,
} from "@/lib/registry/access.server";
import { isActiveRegistryCredential } from "@/lib/registry/credential-task";
import { getRegistryNotInstallableMessage } from "@/lib/registry/installability";
import {
buildRegistryProviderOptions,
REGISTRY_PROVIDER_DISCOVERY,
type RegistryProviderDiscoveryResult,
} from "@/lib/registry/provider-options";
import {
REGISTRY_ARTIFACT_ACTION,
REGISTRY_BOOTSTRAP_STATE,
REGISTRY_CATALOG,
REGISTRY_CREDENTIAL_ACTION,
REGISTRY_CREDENTIAL_READ,
REGISTRY_ENDPOINT,
REGISTRY_FAILURE,
REGISTRY_MUTATION,
REGISTRY_SUBMISSION,
type RegistryAddArtifactInput,
type RegistryArtifactRemovalResult,
type RegistryBootstrapResult,
type RegistryBootstrapState,
type RegistryCollectionsResult,
type RegistryCredentialActionResult,
type RegistryCredentialReadResult,
type RegistryCredentialStatus,
type RegistryCredentialSubmitResult,
type RegistryFailureResult,
type RegistryMutationResult,
} from "@/types/registry";
import {
adaptRegistryCredentialStatus,
adaptRegistryTenantArtifacts,
classifyRegistryFailure,
classifyRegistryMutationRefusal,
classifyRegistryRemovalConflict,
collectCompleteRegistryCatalog,
isRegistryCollection,
isRegistryDisabledResponse,
parseRegistryArtifactSubmission,
parseRegistryCredentialSubmission,
RegistryCatalogPageError,
} from "./registry.adapter";
const REGISTRY_REQUEST_TIMEOUT_MS = 15_000;
async function getRegistryAccess(): Promise<string | null> {
const accessToken = (await auth())?.accessToken;
const access = await evaluateRegistryAccess(accessToken);
return access.status === REGISTRY_ACCESS.ELIGIBLE && accessToken?.trim()
? accessToken
: null;
}
async function readRegistryResponse(
accessToken: string,
resource: string,
endpoint: (typeof REGISTRY_ENDPOINT)[keyof typeof REGISTRY_ENDPOINT],
credential: RegistryCredentialStatus | null = null,
searchParams?: URLSearchParams,
): Promise<Response | RegistryFailureResult> {
const url = new URL(`${apiBaseUrl}/registry/${resource}`);
if (searchParams) url.search = searchParams.toString();
let response: Response;
try {
response = await fetch(url.toString(), {
cache: "no-store",
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS),
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${accessToken}`,
},
});
} catch {
return { status: REGISTRY_FAILURE.ERROR };
}
if (response.ok) return response;
// A backend with Registry disabled answers 404: hide it like a denial.
if (await isRegistryDisabledResponse(response))
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
return endpoint === REGISTRY_ENDPOINT.PROVIDERS ||
endpoint === REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS
? classifyDiscoveryFailure(response, endpoint, accessToken, credential)
: classifyRegistryFailure(response, endpoint, credential);
}
async function readRegistryCredential(accessToken: string) {
const result = await readRegistryResponse(
accessToken,
"credential",
REGISTRY_ENDPOINT.CREDENTIAL,
);
if (!(result instanceof Response)) return result;
const credential = adaptRegistryCredentialStatus(
await result.json().catch(() => undefined),
);
return credential
? { status: REGISTRY_CREDENTIAL_READ.STATUS, credential }
: { status: REGISTRY_FAILURE.ERROR };
}
async function readRegistryTenantArtifacts(accessToken: string) {
const result = await readRegistryResponse(
accessToken,
"artifacts",
REGISTRY_ENDPOINT.MUTATION,
);
if (!(result instanceof Response)) return result;
const tenantArtifacts = adaptRegistryTenantArtifacts(
await result.json().catch(() => undefined),
);
return tenantArtifacts
? { status: "ready" as const, tenantArtifacts }
: { status: REGISTRY_FAILURE.ERROR };
}
async function classifyDiscoveryFailure(
response: Response,
endpoint:
| typeof REGISTRY_ENDPOINT.PROVIDERS
| typeof REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
accessToken: string,
credential: RegistryCredentialStatus | null,
) {
if (response.status === 409 && credential === null) {
const currentCredential = await readRegistryCredential(accessToken);
if (currentCredential.status === REGISTRY_FAILURE.ACCESS_DENIED) {
return currentCredential;
}
credential =
currentCredential.status === REGISTRY_CREDENTIAL_READ.STATUS
? currentCredential.credential
: null;
}
return classifyRegistryFailure(response, endpoint, credential);
}
async function readRegistryProviders(
accessToken: string,
credential: RegistryCredentialStatus | null,
) {
const result = await readRegistryResponse(
accessToken,
"providers",
REGISTRY_ENDPOINT.PROVIDERS,
credential,
);
if (!(result instanceof Response)) return result;
const payload = await result.json().catch(() => undefined);
const metadata = z
.object({
data: z.array(
z.object({
id: z.string(),
attributes: z
.object({
name: z.string().optional(),
logo_url: z.string().nullable().optional(),
})
.optional(),
}),
),
})
.safeParse(payload);
return isRegistryCollection(payload)
? {
status: "ready" as const,
providers: metadata.success
? metadata.data.data.map((provider) => ({
type: provider.id,
label: provider.attributes?.name || provider.id,
...(provider.attributes?.logo_url
? { logoUrl: provider.attributes.logo_url }
: {}),
}))
: [],
}
: { status: REGISTRY_FAILURE.ERROR };
}
export async function getInstalledRegistryProviderOptions(): Promise<RegistryProviderDiscoveryResult> {
const access = (await auth())?.accessToken;
const permission = await evaluateRegistryProviderAccess(access);
if (permission.status === REGISTRY_ACCESS.UNKNOWN)
return { status: REGISTRY_PROVIDER_DISCOVERY.UNKNOWN };
if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE)
return { status: REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED };
const [catalog, installed, providers] = await Promise.all([
readCompleteRegistryCatalog(access, null),
readRegistryTenantArtifacts(access),
readRegistryProviders(access, null),
]);
if (
[catalog.status, installed.status, providers.status].some(
(status) => status === REGISTRY_FAILURE.ACCESS_DENIED,
)
)
return { status: REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED };
if (
catalog.status !== REGISTRY_CATALOG.COMPLETE ||
installed.status !== "ready" ||
providers.status !== "ready"
)
return { status: REGISTRY_PROVIDER_DISCOVERY.ERROR };
return {
status: REGISTRY_PROVIDER_DISCOVERY.READY,
options: buildRegistryProviderOptions(
catalog.artifacts,
installed.tenantArtifacts,
providers.providers,
),
};
}
async function readCompleteRegistryCatalog(
accessToken: string,
credential: RegistryCredentialStatus | null,
) {
try {
return await collectCompleteRegistryCatalog(async (_page, searchParams) => {
const result = await readRegistryResponse(
accessToken,
"available-artifacts",
REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
credential,
searchParams,
);
if (!(result instanceof Response))
throw new RegistryCatalogPageError(result);
return result.json();
});
} catch (error) {
return error instanceof RegistryCatalogPageError
? error.failure
: { status: REGISTRY_FAILURE.ERROR };
}
}
async function confirmRegistryMutation(
accessToken: string,
normalizedName: string,
shouldBePresent: boolean,
expectedVersion?: string,
): Promise<RegistryMutationResult> {
const tenantArtifacts = await readRegistryTenantArtifacts(accessToken);
if (tenantArtifacts.status === REGISTRY_FAILURE.ACCESS_DENIED)
return tenantArtifacts;
if (
tenantArtifacts.status !== "ready" ||
tenantArtifacts.tenantArtifacts.some(
(artifact) => artifact.normalizedName === normalizedName,
) !== shouldBePresent ||
(expectedVersion !== undefined &&
tenantArtifacts.tenantArtifacts.find(
(artifact) => artifact.normalizedName === normalizedName,
)?.resolvedVersion !== expectedVersion.trim())
) {
return { status: "refresh_failed" };
}
return {
status: "confirmed",
tenantArtifacts: tenantArtifacts.tenantArtifacts,
};
}
function bootstrapReady(
state: RegistryBootstrapState,
): RegistryBootstrapResult {
return { status: REGISTRY_BOOTSTRAP_STATE.READY, state };
}
function bootstrapFailure(
failure: RegistryFailureResult,
): RegistryBootstrapResult {
if (failure.status === REGISTRY_FAILURE.ACCESS_DENIED) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
}
return bootstrapReady({
status:
failure.status === REGISTRY_FAILURE.ONBOARDING
? REGISTRY_BOOTSTRAP_STATE.ERROR
: failure.status,
});
}
export async function getRegistryBootstrap(): Promise<RegistryBootstrapResult> {
const access = await getRegistryAccess();
if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED };
const credentialRead = await readRegistryCredential(access);
if (credentialRead.status !== REGISTRY_CREDENTIAL_READ.STATUS) {
return bootstrapFailure(credentialRead);
}
const tenantArtifactsRead = await readRegistryTenantArtifacts(access);
if (tenantArtifactsRead.status !== "ready") {
return bootstrapFailure(tenantArtifactsRead);
}
const { credential } = credentialRead;
const { tenantArtifacts } = tenantArtifactsRead;
if (!isActiveRegistryCredential(credential)) {
return bootstrapReady({
status: credential.validationPending
? REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING
: REGISTRY_BOOTSTRAP_STATE.ONBOARDING,
credential,
tenantArtifacts,
});
}
const catalog = await readCompleteRegistryCatalog(access, credential);
if (catalog.status === REGISTRY_FAILURE.ACCESS_DENIED) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
}
if (catalog.status === REGISTRY_CATALOG.INCOMPLETE) {
return bootstrapReady({
status: REGISTRY_BOOTSTRAP_STATE.INCOMPLETE,
catalog,
});
}
if (catalog.status !== REGISTRY_CATALOG.COMPLETE) {
return bootstrapFailure(catalog);
}
return bootstrapReady({
status: REGISTRY_BOOTSTRAP_STATE.READY,
credential,
catalog,
tenantArtifacts,
});
}
export async function refreshRegistryCredential(): Promise<RegistryCredentialReadResult> {
const access = await getRegistryAccess();
if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED };
return readRegistryCredential(access);
}
export async function refreshRegistryCollections(): Promise<RegistryCollectionsResult> {
const access = (await auth())?.accessToken;
const permission = await evaluateRegistryAccess(access);
if (permission.status === REGISTRY_ACCESS.UNKNOWN)
return { status: REGISTRY_FAILURE.ERROR };
if (permission.status !== REGISTRY_ACCESS.ELIGIBLE || !access?.trim())
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
const catalog = await readCompleteRegistryCatalog(access, null);
if (catalog.status !== REGISTRY_CATALOG.COMPLETE) return catalog;
const tenantArtifactsRead = await readRegistryTenantArtifacts(access);
return tenantArtifactsRead.status === "ready"
? {
status: REGISTRY_CATALOG.COMPLETE,
catalog,
tenantArtifacts: tenantArtifactsRead.tenantArtifacts,
}
: tenantArtifactsRead;
}
export async function addRegistryArtifact({
normalizedName,
versionSpec,
}: RegistryAddArtifactInput): Promise<RegistryMutationResult> {
const access = await getRegistryAccess();
if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED } as const;
if (
typeof normalizedName !== "string" ||
!normalizedName.trim() ||
(versionSpec !== undefined && typeof versionSpec !== "string")
)
return { status: REGISTRY_FAILURE.ERROR };
const catalog = await readCompleteRegistryCatalog(access, null);
if (catalog.status !== REGISTRY_CATALOG.COMPLETE) {
return catalog.status === REGISTRY_CATALOG.INCOMPLETE
? { status: REGISTRY_FAILURE.ERROR }
: catalog;
}
const artifact = catalog.artifacts.find(
(entry) => entry.normalizedName === normalizedName,
);
if (!artifact?.isInstallable)
return {
status: REGISTRY_MUTATION.REFUSED,
message: getRegistryNotInstallableMessage(artifact?.notInstallableReason),
};
const selectedVersion = versionSpec?.trim() || "latest";
let response: Response;
try {
response = await fetch(`${apiBaseUrl}/registry/artifacts`, {
method: "POST",
cache: "no-store",
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS),
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${access}`,
},
body: JSON.stringify({
data: {
type: "registry-artifacts",
attributes: {
normalized_name: normalizedName,
version_spec: selectedVersion,
},
},
}),
});
} catch {
return { status: REGISTRY_FAILURE.ERROR } as const;
}
if (response.status === 401 || response.status === 403) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED } as const;
}
if (response.status === 409) {
return { status: REGISTRY_FAILURE.ONBOARDING };
}
if (!response.ok) {
return (
(await classifyRegistryMutationRefusal(response)) ?? {
status: REGISTRY_FAILURE.ERROR,
}
);
}
const submission = await parseRegistryArtifactSubmission(response);
return submission.status === REGISTRY_SUBMISSION.PENDING
? { status: REGISTRY_ARTIFACT_ACTION.SUBMITTED, taskId: submission.taskId }
: { status: REGISTRY_FAILURE.ERROR };
}
export async function confirmRegistryArtifactAddition(
normalizedName: string,
expectedVersion?: string,
): Promise<RegistryMutationResult> {
const access = await getRegistryAccess();
if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED };
if (
expectedVersion !== undefined &&
(typeof expectedVersion !== "string" || !expectedVersion.trim())
) {
return { status: REGISTRY_FAILURE.ERROR };
}
return confirmRegistryMutation(access, normalizedName, true, expectedVersion);
}
export async function removeRegistryArtifact(
normalizedName: string,
): Promise<RegistryArtifactRemovalResult> {
const access = await getRegistryAccess();
if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED };
let response: Response;
try {
response = await fetch(
`${apiBaseUrl}/registry/artifacts/${encodeURIComponent(normalizedName)}`,
{
method: "DELETE",
cache: "no-store",
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS),
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${access}`,
},
},
);
} catch {
return { status: REGISTRY_FAILURE.ERROR };
}
if (response.status === 401 || response.status === 403) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
}
if (response.status === 409) {
return (
(await classifyRegistryRemovalConflict(response)) ?? {
status: REGISTRY_FAILURE.ERROR,
}
);
}
if (!response.ok) return { status: REGISTRY_FAILURE.ERROR };
return confirmRegistryMutation(access, normalizedName, false);
}
export async function submitRegistryCredential(
key: string,
): Promise<RegistryCredentialSubmitResult> {
const access = await getRegistryAccess();
if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED };
const priorCredential = await readRegistryCredential(access);
if (priorCredential.status !== REGISTRY_CREDENTIAL_READ.STATUS) {
return priorCredential;
}
let response: Response;
try {
response = await fetch(`${apiBaseUrl}/registry/credential`, {
method: "POST",
cache: "no-store",
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS),
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${access}`,
},
body: JSON.stringify({
data: {
type: "registry-credentials",
attributes: { api_key: key.trim() },
},
}),
});
} catch {
return { status: REGISTRY_FAILURE.ERROR };
}
if (response.status === 401 || response.status === 403) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
}
// The task settles client-side through the task watcher; this action only
// hands back the verified task identity so the caller can watch it.
const submission = await parseRegistryCredentialSubmission(response);
if (submission.status !== REGISTRY_SUBMISSION.PENDING) {
return priorCredential.credential.configured
? {
status: REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED,
credential: priorCredential.credential,
}
: { status: REGISTRY_FAILURE.ERROR };
}
return {
status: REGISTRY_CREDENTIAL_ACTION.SUBMITTED,
taskId: submission.taskId,
priorConfigured: priorCredential.credential.configured,
};
}
export async function disconnectRegistryCredential(): Promise<RegistryCredentialActionResult> {
const access = await getRegistryAccess();
if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED };
let response: Response;
try {
response = await fetch(`${apiBaseUrl}/registry/credential`, {
method: "DELETE",
cache: "no-store",
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS),
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${access}`,
},
});
} catch {
return { status: REGISTRY_FAILURE.ERROR };
}
if (response.status === 401 || response.status === 403) {
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
}
const credential = await readRegistryCredential(access);
const tenantArtifacts = await readRegistryTenantArtifacts(access);
if (credential.status !== REGISTRY_CREDENTIAL_READ.STATUS) return credential;
if (tenantArtifacts.status !== "ready") return tenantArtifacts;
if (!response.ok) return { status: REGISTRY_FAILURE.ERROR };
return {
status: REGISTRY_CREDENTIAL_ACTION.DISCONNECTED,
credential: credential.credential,
tenantArtifacts: tenantArtifacts.tenantArtifacts,
};
}