mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): complete Registry provider onboarding for Private Cloud (#12494)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
974f4251dd
commit
2198ba2d84
@@ -23,7 +23,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({
|
||||
|
||||
import { createNewUser, getUserByMe } from "./auth";
|
||||
|
||||
const userMeResponse = (roleAttributes: Record<string, boolean>) => ({
|
||||
const userMeResponse = (roleAttributes: Record<string, unknown>) => ({
|
||||
data: {
|
||||
type: "users",
|
||||
id: "019b1234-5678-7abc-9def-0123456789ab",
|
||||
@@ -43,7 +43,7 @@ const userMeResponse = (roleAttributes: Record<string, boolean>) => ({
|
||||
],
|
||||
});
|
||||
|
||||
const mockUserMe = (roleAttributes: Record<string, boolean>) => {
|
||||
const mockUserMe = (roleAttributes: Record<string, unknown>) => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify(userMeResponse(roleAttributes)), {
|
||||
status: 200,
|
||||
@@ -178,6 +178,30 @@ describe("auth actions", () => {
|
||||
expect(result.permissions.manage_users).toBe(true);
|
||||
});
|
||||
|
||||
it("should carry an exact manage_registry permission into the session", async () => {
|
||||
// Given
|
||||
mockUserMe({ manage_registry: true });
|
||||
|
||||
// When
|
||||
const result = await getUserByMe("access-token");
|
||||
|
||||
// Then
|
||||
expect(result.permissions.manage_registry).toBe(true);
|
||||
});
|
||||
|
||||
it.each([undefined, "true", "TRUE", 1])(
|
||||
"should deny a malformed manage_registry value of %j",
|
||||
async (manageRegistry) => {
|
||||
// Given
|
||||
mockUserMe({ manage_registry: manageRegistry });
|
||||
|
||||
// When
|
||||
const result = await getUserByMe("access-token");
|
||||
|
||||
// Then
|
||||
expect(result.permissions.manage_registry).toBe(false);
|
||||
},
|
||||
);
|
||||
it("should forward an abort signal when loading the current user", async () => {
|
||||
// Given
|
||||
mockUserMe({ manage_users: true });
|
||||
|
||||
+9
-60
@@ -4,7 +4,7 @@ import { AuthError } from "next-auth";
|
||||
|
||||
import { signIn, signOut } from "@/auth.config";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { UserMeError } from "@/lib/auth-errors";
|
||||
import { fetchCurrentUser } from "@/lib/auth/current-user";
|
||||
import { addAuthEvent } from "@/lib/sentry-breadcrumbs";
|
||||
import type { UtmParams } from "@/lib/utm";
|
||||
import type { SignInFormData, SignUpFormData } from "@/types";
|
||||
@@ -145,66 +145,15 @@ export const getUserByMe = async (
|
||||
accessToken: string,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const url = new URL(`${apiBaseUrl}/users/me?include=roles`);
|
||||
const currentUser = await fetchCurrentUser(accessToken, { signal });
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
response.status === 401
|
||||
? "Invalid or expired token"
|
||||
: response.status === 403
|
||||
? "Access denied"
|
||||
: response.status === 404
|
||||
? "User not found"
|
||||
: "Unable to load user";
|
||||
throw new UserMeError(errorMessage, response.status);
|
||||
}
|
||||
|
||||
const parsedResponse = await response.json();
|
||||
|
||||
const userRole = parsedResponse.included?.find(
|
||||
(item: any) => item.type === "roles",
|
||||
);
|
||||
|
||||
const permissions = {
|
||||
manage_users: userRole.attributes.manage_users || false,
|
||||
manage_account: userRole.attributes.manage_account || false,
|
||||
manage_providers: userRole.attributes.manage_providers || false,
|
||||
manage_scans: userRole.attributes.manage_scans || false,
|
||||
manage_ingestions: userRole.attributes.manage_ingestions || false,
|
||||
manage_integrations: userRole.attributes.manage_integrations || false,
|
||||
manage_billing: userRole.attributes.manage_billing || false,
|
||||
manage_alerts: userRole.attributes.manage_alerts || false,
|
||||
manage_lighthouse_ai_configuration:
|
||||
userRole.attributes.manage_lighthouse_ai_configuration || false,
|
||||
unlimited_visibility: userRole.attributes.unlimited_visibility || false,
|
||||
};
|
||||
|
||||
return {
|
||||
name: parsedResponse.data.attributes.name,
|
||||
email: parsedResponse.data.attributes.email,
|
||||
company: parsedResponse.data.attributes.company_name,
|
||||
dateJoined: parsedResponse.data.attributes.date_joined,
|
||||
permissions,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UserMeError) throw error;
|
||||
|
||||
throw new UserMeError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Network error or server unreachable",
|
||||
);
|
||||
}
|
||||
return {
|
||||
name: currentUser.name,
|
||||
email: currentUser.email,
|
||||
company: currentUser.company,
|
||||
dateJoined: currentUser.dateJoined,
|
||||
permissions: currentUser.permissions,
|
||||
};
|
||||
};
|
||||
|
||||
export async function logOut() {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json";
|
||||
import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json";
|
||||
const { fetchMock, getProviderSchemas, getAuthHeaders, revalidatePath } =
|
||||
vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getProviderSchemas: vi.fn(),
|
||||
getAuthHeaders: vi.fn(),
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders,
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath }));
|
||||
vi.mock("./provider-schemas", () => ({ getProviderSchemas }));
|
||||
|
||||
import { saveDynamicProviderCredentials } from "./dynamic-provider-credentials";
|
||||
|
||||
const input = {
|
||||
providerId: "account",
|
||||
secretType: "api_key",
|
||||
secret: { token: "private-value" },
|
||||
};
|
||||
const response = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status });
|
||||
const account = (secretId: string | null = null) => ({
|
||||
data: {
|
||||
id: "account",
|
||||
attributes: { provider: "acme" },
|
||||
relationships: { secret: { data: secretId ? { id: secretId } : null } },
|
||||
},
|
||||
});
|
||||
|
||||
describe("dynamic provider credential actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockReset();
|
||||
getAuthHeaders.mockResolvedValue({ Authorization: "Bearer test" });
|
||||
getProviderSchemas.mockResolvedValue({
|
||||
status: "success",
|
||||
providerType: "acme",
|
||||
secretTypes: {
|
||||
api_key: {
|
||||
type: "object",
|
||||
description: openaiSchema.description,
|
||||
properties: {
|
||||
token: { type: "string", format: "password", writeOnly: true },
|
||||
},
|
||||
required: ["token"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it("validates the account's current schema and sends JSON credentials without the builtin mapping", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(account()))
|
||||
.mockResolvedValueOnce(response({ data: { id: "saved" } }, 201));
|
||||
expect(await saveDynamicProviderCredentials(input)).toEqual({
|
||||
status: "saved",
|
||||
secretId: "saved",
|
||||
});
|
||||
expect(getProviderSchemas).toHaveBeenCalledWith("acme");
|
||||
const [url, request] = fetchMock.mock.calls[1];
|
||||
expect(url).toBe("https://api.test/api/v1/providers/secrets");
|
||||
expect(JSON.parse(request.body).data).toEqual({
|
||||
type: "provider-secrets",
|
||||
attributes: {
|
||||
secret_type: "api_key",
|
||||
secret: { token: "private-value" },
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { id: "account", type: "providers" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
it("updates the authoritative existing secret, including after a retry", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(account("existing")))
|
||||
.mockResolvedValueOnce(response({ data: { id: "existing" } }));
|
||||
expect((await saveDynamicProviderCredentials(input)).status).toBe("saved");
|
||||
expect(
|
||||
fetchMock.mock.calls[1][0].endsWith("/providers/secrets/existing"),
|
||||
).toBe(true);
|
||||
expect(fetchMock.mock.calls[1][1].method).toBe("PATCH");
|
||||
});
|
||||
it("validates and sends Template credentials with their JSON types", async () => {
|
||||
// Given
|
||||
const templateAccount = account();
|
||||
templateAccount.data.attributes.provider = "template";
|
||||
getProviderSchemas.mockResolvedValue({
|
||||
status: "success",
|
||||
providerType: "template",
|
||||
secretTypes: { static: templateSchema },
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(templateAccount))
|
||||
.mockResolvedValueOnce(response({ data: { id: "saved" } }, 201));
|
||||
const secret = {
|
||||
api_url: "https://api.example.test",
|
||||
api_key: "fixture-key-not-a-secret",
|
||||
verify_tls: false,
|
||||
timeout_seconds: 60,
|
||||
};
|
||||
|
||||
// When / Then
|
||||
expect(
|
||||
await saveDynamicProviderCredentials({
|
||||
...input,
|
||||
secretType: "static",
|
||||
secret,
|
||||
}),
|
||||
).toEqual({
|
||||
status: "saved",
|
||||
secretId: "saved",
|
||||
});
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body).data.attributes).toEqual(
|
||||
{
|
||||
secret_type: "static",
|
||||
secret,
|
||||
},
|
||||
);
|
||||
|
||||
// Server-side validation also rejects requests that bypass the form.
|
||||
fetchMock.mockReset().mockResolvedValueOnce(response(templateAccount));
|
||||
expect(
|
||||
await saveDynamicProviderCredentials({
|
||||
...input,
|
||||
secretType: "static",
|
||||
secret: { ...secret, timeout_seconds: 301 },
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "invalid",
|
||||
errors: { timeout_seconds: expect.any(String) },
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each([
|
||||
{ ...input, secretType: "invented" },
|
||||
{ ...input, secret: { token: "" } },
|
||||
{ ...input, secret: { token: "x", unknown: "hidden" } },
|
||||
])("does not write invalid credentials", async (values) => {
|
||||
fetchMock.mockResolvedValueOnce(response(account()));
|
||||
expect((await saveDynamicProviderCredentials(values)).status).not.toBe(
|
||||
"saved",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("fails closed for an absent schema, revoked permission, and malformed accounts", async () => {
|
||||
getProviderSchemas.mockResolvedValue({
|
||||
status: "success",
|
||||
providerType: "acme",
|
||||
secretTypes: {},
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(response(account()));
|
||||
expect((await saveDynamicProviderCredentials(input)).status).toBe(
|
||||
"schema_unavailable",
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(response({}, 403));
|
||||
expect((await saveDynamicProviderCredentials(input)).status).toBe(
|
||||
"access_denied",
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(response({}));
|
||||
expect((await saveDynamicProviderCredentials(input)).status).toBe("error");
|
||||
expect(fetchMock.mock.calls.every(([, options]) => !options.method)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("does not echo a rejected secret in errors", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(account()))
|
||||
.mockResolvedValueOnce(
|
||||
response({ errors: [{ detail: "private-value invalid" }] }, 400),
|
||||
);
|
||||
expect(
|
||||
JSON.stringify(await saveDynamicProviderCredentials(input)),
|
||||
).not.toContain("private-value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { parseRegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema";
|
||||
import { validateCredentialValues } from "@/lib/provider-credentials/provider-credential-values";
|
||||
import { isKnownProviderType } from "@/types/providers";
|
||||
|
||||
import { getProviderSchemas } from "./provider-schemas";
|
||||
|
||||
const resourceId = z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/);
|
||||
const inputSchema = z.object({
|
||||
providerId: resourceId,
|
||||
secretType: z.string().min(1),
|
||||
secret: z.unknown(),
|
||||
});
|
||||
const accountSchema = z.object({
|
||||
data: z.object({
|
||||
id: resourceId,
|
||||
attributes: z.object({ provider: z.string() }),
|
||||
relationships: z.object({
|
||||
secret: z.object({ data: z.object({ id: resourceId }).nullable() }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type DynamicCredentialsResult =
|
||||
| { status: "saved"; secretId: string }
|
||||
| { status: "invalid"; errors: Record<string, string> }
|
||||
| { status: "access_denied" | "schema_unavailable" | "error" };
|
||||
|
||||
export async function saveDynamicProviderCredentials(
|
||||
input: unknown,
|
||||
): Promise<DynamicCredentialsResult> {
|
||||
const parsed = inputSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
status: "invalid",
|
||||
errors: { _form: "Check the provider and credential fields." },
|
||||
};
|
||||
const { providerId, secretType, secret } = parsed.data;
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const accountResponse = await fetch(
|
||||
`${apiBaseUrl}/providers/${encodeURIComponent(providerId)}`,
|
||||
{ headers, cache: "no-store" },
|
||||
);
|
||||
if (accountResponse.status === 401 || accountResponse.status === 403)
|
||||
return { status: "access_denied" };
|
||||
if (!accountResponse.ok) return { status: "error" };
|
||||
const account = accountSchema.safeParse(await accountResponse.json());
|
||||
if (
|
||||
!account.success ||
|
||||
account.data.data.id !== providerId ||
|
||||
isKnownProviderType(account.data.data.attributes.provider)
|
||||
)
|
||||
return { status: "error" };
|
||||
const schemas = await getProviderSchemas(
|
||||
account.data.data.attributes.provider,
|
||||
);
|
||||
if (schemas.status === "access_denied") return { status: "access_denied" };
|
||||
if (
|
||||
schemas.status !== "success" ||
|
||||
!Object.hasOwn(schemas.secretTypes, secretType)
|
||||
)
|
||||
return { status: "schema_unavailable" };
|
||||
const schema = parseRegistryCredentialSchema(
|
||||
schemas.secretTypes[secretType],
|
||||
);
|
||||
if (!schema) return { status: "schema_unavailable" };
|
||||
const validated = validateCredentialValues(schema, secret);
|
||||
if (!validated.valid)
|
||||
return { status: "invalid", errors: validated.errors };
|
||||
|
||||
// Read the relationship again on every save so retries update a secret that
|
||||
// was already created, including after a lost response.
|
||||
const secretId = account.data.data.relationships.secret.data?.id;
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}/providers/secrets${secretId ? `/${encodeURIComponent(secretId)}` : ""}`,
|
||||
{
|
||||
method: secretId ? "PATCH" : "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "provider-secrets",
|
||||
...(secretId
|
||||
? { id: secretId }
|
||||
: {
|
||||
relationships: {
|
||||
provider: { data: { id: providerId, type: "providers" } },
|
||||
},
|
||||
}),
|
||||
attributes: { secret_type: secretType, secret: validated.secret },
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (response.status === 401 || response.status === 403)
|
||||
return { status: "access_denied" };
|
||||
// API validation details may echo credential values. Keep them out of both
|
||||
// client errors and application logs.
|
||||
if (!response.ok) return { status: "error" };
|
||||
const saved = z
|
||||
.object({ data: z.object({ id: resourceId }) })
|
||||
.safeParse(await response.json());
|
||||
if (!saved.success) return { status: "error" };
|
||||
revalidatePath("/providers");
|
||||
return { status: "saved", secretId: saved.data.data.id };
|
||||
} catch {
|
||||
return { status: "error" };
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./provider-schemas";
|
||||
export * from "./providers";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
adaptProviderSchemas,
|
||||
normalizeProviderType,
|
||||
} from "./provider-schemas.adapter";
|
||||
|
||||
describe("provider schemas adapter", () => {
|
||||
it("adapts a matching provider schema resource without interpreting schema keywords", () => {
|
||||
// Given
|
||||
const payload = {
|
||||
data: {
|
||||
type: "provider-schemas",
|
||||
id: "acme",
|
||||
attributes: {
|
||||
secret_types: {
|
||||
credentials: {
|
||||
type: "object",
|
||||
properties: { access_key: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptProviderSchemas(payload, "acme");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
status: "success",
|
||||
providerType: "acme",
|
||||
secretTypes: payload.data.attributes.secret_types,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null", null],
|
||||
["string scalar", "secret"],
|
||||
["number scalar", 1],
|
||||
["boolean scalar", true],
|
||||
])("rejects %s secret_types values", (_description, secretType) => {
|
||||
// Given
|
||||
const payload = {
|
||||
data: {
|
||||
type: "provider-schemas",
|
||||
id: "acme",
|
||||
attributes: { secret_types: { credentials: secretType } },
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptProviderSchemas(payload, "acme");
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed or contradictory documents without reading schema keywords", () => {
|
||||
// Given
|
||||
const document = {
|
||||
data: {
|
||||
type: "provider-schemas",
|
||||
id: "aws",
|
||||
attributes: { secret_types: {} },
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const results = [
|
||||
{ ...document, errors: [] },
|
||||
{ data: { ...document.data, id: "aws " } },
|
||||
{ data: { ...document.data, type: "providers" } },
|
||||
{ data: { ...document.data, attributes: { secret_types: { key: [] } } } },
|
||||
].map((payload) => adaptProviderSchemas(payload, "aws"));
|
||||
|
||||
// Then
|
||||
expect(results).toEqual([null, null, null, null]);
|
||||
expect(normalizeProviderType(" AWS ")).toBe("aws");
|
||||
expect(normalizeProviderType(" ")).toBeNull();
|
||||
expect(normalizeProviderType("a".repeat(51))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
PROVIDER_SCHEMA_STATUS,
|
||||
type ProviderSchemasSuccessResult,
|
||||
} from "@/types/provider-schema";
|
||||
|
||||
const providerTypeSchema = z.string().trim().toLowerCase().min(1).max(50);
|
||||
const providerSchemasDocumentSchema = z.strictObject({
|
||||
data: z.strictObject({
|
||||
type: z.literal("provider-schemas"),
|
||||
id: z.string().min(1).max(50),
|
||||
attributes: z.strictObject({
|
||||
secret_types: z.record(z.string(), z.record(z.string(), z.unknown())),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export function normalizeProviderType(value: unknown): string | null {
|
||||
const parsed = providerTypeSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function adaptProviderSchemas(
|
||||
payload: unknown,
|
||||
normalizedProviderType: string,
|
||||
): ProviderSchemasSuccessResult | null {
|
||||
const parsed = providerSchemasDocumentSchema.safeParse(payload);
|
||||
if (!parsed.success || parsed.data.data.id !== normalizedProviderType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: PROVIDER_SCHEMA_STATUS.SUCCESS,
|
||||
providerType: parsed.data.data.id,
|
||||
secretTypes: parsed.data.data.attributes.secret_types,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { authMock, fetchMock } = vi.hoisted(() => ({
|
||||
authMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/auth.config", () => ({ auth: authMock }));
|
||||
vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" }));
|
||||
|
||||
import { getProviderSchemas } from "./provider-schemas";
|
||||
|
||||
const schemaResponse = (providerType = "acme") =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
type: "provider-schemas",
|
||||
id: providerType,
|
||||
attributes: { secret_types: {} },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
|
||||
describe("getProviderSchemas", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
authMock.mockResolvedValue({ accessToken: "access-token" });
|
||||
fetchMock.mockResolvedValue(schemaResponse());
|
||||
});
|
||||
|
||||
it("requests the normalized provider schema with authenticated JSON:API headers", async () => {
|
||||
// When
|
||||
const result = await getProviderSchemas(" ACME ");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
status: "success",
|
||||
providerType: "acme",
|
||||
secretTypes: {},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/provider-schemas/acme",
|
||||
{
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: "Bearer access-token",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fetch invalid input and encodes a normalized path segment", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValueOnce(schemaResponse("acme/team"));
|
||||
|
||||
// When
|
||||
const invalid = await Promise.all([
|
||||
getProviderSchemas(" "),
|
||||
getProviderSchemas("a".repeat(51)),
|
||||
]);
|
||||
const encoded = await getProviderSchemas(" ACME/TEAM ");
|
||||
|
||||
// Then
|
||||
expect(invalid).toEqual([{ status: "error" }, { status: "error" }]);
|
||||
expect(encoded).toMatchObject({
|
||||
status: "success",
|
||||
providerType: "acme/team",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/provider-schemas/acme%2Fteam",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("denies an unauthenticated request without fetching", async () => {
|
||||
// Given
|
||||
authMock.mockResolvedValue({});
|
||||
|
||||
// When
|
||||
const result = await getProviderSchemas("acme");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ status: "access_denied" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, { status: "access_denied" }],
|
||||
[403, { status: "access_denied" }],
|
||||
[404, { status: "not_found" }],
|
||||
[409, { status: "unavailable" }],
|
||||
[500, { status: "error" }],
|
||||
])("maps HTTP %i to a safe result", async (status, expected) => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errors: [{ detail: "private detail" }] }), {
|
||||
status,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await getProviderSchemas("acme");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(expected);
|
||||
expect(JSON.stringify(result)).not.toContain("private detail");
|
||||
});
|
||||
|
||||
it("returns a generic safe error when fetch rejects", async () => {
|
||||
// Given
|
||||
const rejection = new Error("connection detail must not leak");
|
||||
fetchMock.mockRejectedValueOnce(rejection);
|
||||
|
||||
// When
|
||||
const result = await getProviderSchemas("acme");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ status: "error" });
|
||||
expect(JSON.stringify(result)).not.toContain(rejection.message);
|
||||
});
|
||||
|
||||
it("distinguishes a malformed success document from a transport failure", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ errors: [] })),
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await getProviderSchemas("acme");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ status: "malformed" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import {
|
||||
PROVIDER_SCHEMA_STATUS,
|
||||
type ProviderSchemasResult,
|
||||
} from "@/types/provider-schema";
|
||||
|
||||
import {
|
||||
adaptProviderSchemas,
|
||||
normalizeProviderType,
|
||||
} from "./provider-schemas.adapter";
|
||||
|
||||
export async function getProviderSchemas(
|
||||
providerType: unknown,
|
||||
): Promise<ProviderSchemasResult> {
|
||||
const normalizedProviderType = normalizeProviderType(providerType);
|
||||
if (!normalizedProviderType) return { status: PROVIDER_SCHEMA_STATUS.ERROR };
|
||||
|
||||
let accessToken: string | undefined;
|
||||
try {
|
||||
accessToken = (await auth())?.accessToken?.trim();
|
||||
} catch {
|
||||
return { status: PROVIDER_SCHEMA_STATUS.ERROR };
|
||||
}
|
||||
if (!accessToken) return { status: PROVIDER_SCHEMA_STATUS.ACCESS_DENIED };
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
`${apiBaseUrl}/provider-schemas/${encodeURIComponent(normalizedProviderType)}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Accept: "application/vnd.api+json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return { status: PROVIDER_SCHEMA_STATUS.ERROR };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { status: PROVIDER_SCHEMA_STATUS.ACCESS_DENIED };
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return { status: PROVIDER_SCHEMA_STATUS.NOT_FOUND };
|
||||
}
|
||||
if (response.status === 409) {
|
||||
return { status: PROVIDER_SCHEMA_STATUS.UNAVAILABLE };
|
||||
}
|
||||
if (!response.ok) return { status: PROVIDER_SCHEMA_STATUS.ERROR };
|
||||
|
||||
const schema = adaptProviderSchemas(
|
||||
await response.json().catch(() => undefined),
|
||||
normalizedProviderType,
|
||||
);
|
||||
return schema ?? { status: PROVIDER_SCHEMA_STATUS.MALFORMED };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getInstalledRegistryProviderOptions,
|
||||
addProvider,
|
||||
getProviders,
|
||||
updateProvider,
|
||||
} = vi.hoisted(() => ({
|
||||
getInstalledRegistryProviderOptions: vi.fn(),
|
||||
addProvider: vi.fn(),
|
||||
getProviders: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/actions/registry/registry", () => ({
|
||||
getInstalledRegistryProviderOptions,
|
||||
}));
|
||||
vi.mock("./providers", () => ({ addProvider, getProviders, updateProvider }));
|
||||
|
||||
import { addRegistryProvider } from "./registry-provider";
|
||||
|
||||
const formData = (alias = "Test") => {
|
||||
const form = new FormData();
|
||||
form.set("providerType", "acme");
|
||||
form.set("providerUid", "account");
|
||||
form.set("providerAlias", alias);
|
||||
return form;
|
||||
};
|
||||
describe("Registry provider account creation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
status: "ready",
|
||||
options: [{ type: "acme", label: "Acme" }],
|
||||
});
|
||||
getProviders.mockResolvedValue({ data: [] });
|
||||
});
|
||||
it("refuses removed artifacts and revoked permission before creating an account", async () => {
|
||||
getInstalledRegistryProviderOptions
|
||||
.mockResolvedValueOnce({ status: "access_denied" })
|
||||
.mockResolvedValueOnce({ status: "ready", options: [] });
|
||||
expect((await addRegistryProvider(formData()))?.errors).toBeDefined();
|
||||
expect((await addRegistryProvider(formData()))?.errors).toBeDefined();
|
||||
expect(addProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reuses a previously created account after a failed credential attempt or lost response", async () => {
|
||||
const existing = {
|
||||
id: "existing",
|
||||
attributes: { provider: "acme", uid: "account", alias: "Test" },
|
||||
};
|
||||
getProviders.mockResolvedValue({ data: [existing] });
|
||||
expect(await addRegistryProvider(formData())).toEqual({ data: existing });
|
||||
expect(addProvider).not.toHaveBeenCalled();
|
||||
expect(updateProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["Test", "", " Edited "])(
|
||||
"saves alias %j before resuming credentials for an existing account",
|
||||
async (alias) => {
|
||||
// Given
|
||||
const existing = {
|
||||
id: "existing",
|
||||
attributes: { provider: "acme", uid: "account", alias: "Original" },
|
||||
};
|
||||
const updated = {
|
||||
...existing,
|
||||
attributes: { ...existing.attributes, alias: alias.trim() },
|
||||
};
|
||||
getProviders.mockResolvedValue({ data: [existing] });
|
||||
updateProvider.mockResolvedValue({ data: updated });
|
||||
|
||||
// When
|
||||
const result = await addRegistryProvider(formData(alias));
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ data: updated });
|
||||
expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toEqual({
|
||||
providerId: "existing",
|
||||
providerAlias: alias.trim(),
|
||||
});
|
||||
expect(addProvider).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("keeps alias update failures visible instead of resuming with stale details", async () => {
|
||||
// Given
|
||||
const failure = {
|
||||
errors: [
|
||||
{
|
||||
detail: "Alias is invalid",
|
||||
source: { pointer: "/data/attributes/alias" },
|
||||
},
|
||||
],
|
||||
};
|
||||
getProviders.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "existing",
|
||||
attributes: { provider: "acme", uid: "account", alias: "Original" },
|
||||
},
|
||||
],
|
||||
});
|
||||
updateProvider.mockResolvedValue(failure);
|
||||
|
||||
// When / Then
|
||||
await expect(addRegistryProvider(formData())).resolves.toEqual(failure);
|
||||
expect(addProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
it("creates a validated installed provider account", async () => {
|
||||
addProvider.mockResolvedValue({ data: { id: "new" } });
|
||||
expect(await addRegistryProvider(formData())).toEqual({
|
||||
data: { id: "new" },
|
||||
});
|
||||
expect(addProvider).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { createAddProviderFormSchema } from "@/types/formSchemas";
|
||||
import { isKnownProviderType } from "@/types/providers";
|
||||
|
||||
import { addProvider, getProviders, updateProvider } from "./providers";
|
||||
|
||||
export async function addRegistryProvider(formData: FormData) {
|
||||
const unavailable = {
|
||||
errors: [
|
||||
{
|
||||
detail:
|
||||
"This Registry provider is no longer available. Check your permissions and installed artifacts, then try again.",
|
||||
source: { pointer: "/data/attributes/provider" },
|
||||
},
|
||||
],
|
||||
};
|
||||
try {
|
||||
const discovery = await getInstalledRegistryProviderOptions();
|
||||
if (discovery.status !== "ready") return unavailable;
|
||||
const values = createAddProviderFormSchema(
|
||||
discovery.options.map((option) => option.type),
|
||||
).safeParse(Object.fromEntries(formData));
|
||||
if (!values.success || isKnownProviderType(values.data.providerType))
|
||||
return unavailable;
|
||||
const { providerType, providerUid } = values.data;
|
||||
const existing = await getProviders({
|
||||
filters: { "filter[provider]": providerType, "filter[uid]": providerUid },
|
||||
pageSize: 100,
|
||||
});
|
||||
// A previous request may have created the account before its response was
|
||||
// lost. Reuse that identity when returning to the credential step.
|
||||
if (!existing?.data) return unavailable;
|
||||
const account = existing.data.find(
|
||||
(provider) =>
|
||||
provider.attributes.provider === providerType &&
|
||||
provider.attributes.uid === providerUid,
|
||||
);
|
||||
if (account) {
|
||||
const alias = values.data.providerAlias.trim();
|
||||
if ((account.attributes.alias ?? "") === alias) return { data: account };
|
||||
const update = new FormData();
|
||||
update.set(ProviderCredentialFields.PROVIDER_ID, account.id);
|
||||
update.set(ProviderCredentialFields.PROVIDER_ALIAS, alias);
|
||||
return await updateProvider(update);
|
||||
}
|
||||
const validated = new FormData();
|
||||
Object.entries(values.data).forEach(([key, value]) => {
|
||||
if (value !== undefined) validated.set(key, value);
|
||||
});
|
||||
return await addProvider(validated);
|
||||
} catch {
|
||||
return unavailable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
REGISTRY_ENDPOINT,
|
||||
REGISTRY_FAILURE,
|
||||
REGISTRY_SUBMISSION,
|
||||
} from "@/types/registry";
|
||||
|
||||
import {
|
||||
adaptRegistryCredentialStatus,
|
||||
adaptRegistryTenantArtifacts,
|
||||
classifyRegistryFailure,
|
||||
collectCompleteRegistryCatalog,
|
||||
parseRegistryArtifactSubmission,
|
||||
} from "./registry.adapter";
|
||||
|
||||
const credentialPayload = {
|
||||
data: {
|
||||
attributes: {
|
||||
configured: true,
|
||||
is_valid: true,
|
||||
scopes: ["catalog:read"],
|
||||
last_validated_at: "2026-03-20T12:00:00Z",
|
||||
validation_status: "valid",
|
||||
validation_pending: false,
|
||||
key: "registry-secret-value",
|
||||
masked_key: "reg_***",
|
||||
pending_key: "queued-secret",
|
||||
arbitrary_backend_detail: "do not expose",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const activeCredential = adaptRegistryCredentialStatus(credentialPayload);
|
||||
const jsonError = (status: number, code: string) =>
|
||||
new Response(
|
||||
JSON.stringify({ errors: [{ code, detail: "private detail" }] }),
|
||||
{
|
||||
status,
|
||||
},
|
||||
);
|
||||
|
||||
describe("Registry adapter", () => {
|
||||
it("reads the resolved installed version separately from the requested spec", () => {
|
||||
// Given / When
|
||||
const artifacts = adaptRegistryTenantArtifacts({
|
||||
data: [
|
||||
{
|
||||
type: "registry-artifacts",
|
||||
id: "template",
|
||||
attributes: {
|
||||
version_spec: "latest",
|
||||
resolved_version: " 1.0.0 ",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// Then
|
||||
expect(artifacts).toEqual([
|
||||
expect.objectContaining({
|
||||
normalizedName: "template",
|
||||
versionSpec: "latest",
|
||||
resolvedVersion: "1.0.0",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", " "])(
|
||||
"accepts an unknown resolved version %j",
|
||||
(resolvedVersion) => {
|
||||
// Given / When
|
||||
const artifacts = adaptRegistryTenantArtifacts({
|
||||
data: [
|
||||
{
|
||||
type: "registry-artifacts",
|
||||
id: "template",
|
||||
attributes: {
|
||||
version_spec: "latest",
|
||||
resolved_version: resolvedVersion,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// Then
|
||||
expect(artifacts).toMatchObject([{ resolvedVersion: undefined }]);
|
||||
},
|
||||
);
|
||||
|
||||
it("maps only documented non-secret credential status fields", () => {
|
||||
// Given
|
||||
const malformedPayload = { data: { attributes: { configured: true } } };
|
||||
|
||||
// When
|
||||
const status = adaptRegistryCredentialStatus(credentialPayload);
|
||||
|
||||
// Then
|
||||
expect(status).toEqual({
|
||||
configured: true,
|
||||
isValid: true,
|
||||
scopes: ["catalog:read"],
|
||||
lastValidatedAt: "2026-03-20T12:00:00Z",
|
||||
validationStatus: "valid",
|
||||
validationPending: false,
|
||||
});
|
||||
expect(adaptRegistryCredentialStatus(malformedPayload)).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes an absent credential status with nullable validation fields", () => {
|
||||
// Given
|
||||
const absentCredentialPayload = {
|
||||
data: {
|
||||
attributes: {
|
||||
configured: false,
|
||||
is_valid: false,
|
||||
scopes: [],
|
||||
last_validated_at: null,
|
||||
validation_status: null,
|
||||
validation_pending: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const status = adaptRegistryCredentialStatus(absentCredentialPayload);
|
||||
|
||||
// Then
|
||||
expect(status).toEqual({
|
||||
configured: false,
|
||||
isValid: false,
|
||||
scopes: [],
|
||||
lastValidatedAt: undefined,
|
||||
validationStatus: undefined,
|
||||
validationPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts only a matching artifact 202 task and fixed Content-Location path", async () => {
|
||||
// Given
|
||||
const response = new Response(
|
||||
JSON.stringify({ data: { type: "tasks", id: "task-123" } }),
|
||||
{
|
||||
status: 202,
|
||||
headers: { "Content-Location": "/api/v1/tasks/task-123" },
|
||||
},
|
||||
);
|
||||
|
||||
// When
|
||||
const result = await parseRegistryArtifactSubmission(response);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
status: REGISTRY_SUBMISSION.PENDING,
|
||||
taskId: "task-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-202 response or a mismatched task location", async () => {
|
||||
// Given
|
||||
const task = JSON.stringify({ data: { type: "tasks", id: "task-123" } });
|
||||
const wrongStatus = new Response(task, { status: 201 });
|
||||
const wrongLocation = new Response(task, {
|
||||
status: 202,
|
||||
headers: { "Content-Location": "/api/v1/tasks/other" },
|
||||
});
|
||||
|
||||
// When
|
||||
const results = await Promise.all([
|
||||
parseRegistryArtifactSubmission(wrongStatus),
|
||||
parseRegistryArtifactSubmission(wrongLocation),
|
||||
]);
|
||||
|
||||
// Then
|
||||
expect(results).toEqual([
|
||||
{ status: REGISTRY_SUBMISSION.ERROR },
|
||||
{ status: REGISTRY_SUBMISSION.ERROR },
|
||||
]);
|
||||
});
|
||||
|
||||
it("classifies every Registry 401 or 403 as access denied first", async () => {
|
||||
// Given
|
||||
const responses = [
|
||||
[401, REGISTRY_ENDPOINT.CREDENTIAL],
|
||||
[403, REGISTRY_ENDPOINT.MUTATION],
|
||||
[403, REGISTRY_ENDPOINT.PROVIDERS],
|
||||
] as const;
|
||||
|
||||
// When
|
||||
const results = await Promise.all(
|
||||
responses.map(([status, endpoint]) =>
|
||||
classifyRegistryFailure(
|
||||
jsonError(status, "registry_key_rejected"),
|
||||
endpoint,
|
||||
activeCredential,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(results).toEqual([
|
||||
{ status: REGISTRY_FAILURE.ACCESS_DENIED },
|
||||
{ status: REGISTRY_FAILURE.ACCESS_DENIED },
|
||||
{ status: REGISTRY_FAILURE.ACCESS_DENIED },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps only a 409 with an authoritative no-active credential to onboarding", async () => {
|
||||
// Given
|
||||
const noCredential = adaptRegistryCredentialStatus({
|
||||
data: {
|
||||
attributes: {
|
||||
configured: false,
|
||||
is_valid: false,
|
||||
scopes: [],
|
||||
validation_pending: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
const results = await Promise.all(
|
||||
[noCredential, null].map((credential) =>
|
||||
classifyRegistryFailure(
|
||||
new Response(null, { status: 409 }),
|
||||
REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
|
||||
credential,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(results).toEqual([
|
||||
{ status: REGISTRY_FAILURE.ONBOARDING },
|
||||
{ status: REGISTRY_FAILURE.ERROR },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps only exact documented 502 and 503 status-code pairs", async () => {
|
||||
// Given
|
||||
const rejected = jsonError(502, "registry_key_rejected");
|
||||
const unavailable = jsonError(503, "registry_unavailable");
|
||||
|
||||
// When
|
||||
const results = await Promise.all([
|
||||
classifyRegistryFailure(
|
||||
rejected,
|
||||
REGISTRY_ENDPOINT.PROVIDERS,
|
||||
activeCredential,
|
||||
),
|
||||
classifyRegistryFailure(
|
||||
unavailable,
|
||||
REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
|
||||
activeCredential,
|
||||
),
|
||||
]);
|
||||
|
||||
// Then
|
||||
expect(results).toEqual([
|
||||
{ status: REGISTRY_FAILURE.RECONNECT },
|
||||
{ status: REGISTRY_FAILURE.UNAVAILABLE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps wrong, malformed, and unrelated failures generic", async () => {
|
||||
// Given
|
||||
const malformed = new Response("<html>key=private</html>", { status: 503 });
|
||||
|
||||
// When
|
||||
const results = await Promise.all([
|
||||
classifyRegistryFailure(
|
||||
jsonError(502, "other_error"),
|
||||
REGISTRY_ENDPOINT.PROVIDERS,
|
||||
activeCredential,
|
||||
),
|
||||
classifyRegistryFailure(
|
||||
jsonError(502, "registry_unavailable"),
|
||||
REGISTRY_ENDPOINT.PROVIDERS,
|
||||
activeCredential,
|
||||
),
|
||||
classifyRegistryFailure(
|
||||
malformed,
|
||||
REGISTRY_ENDPOINT.PROVIDERS,
|
||||
activeCredential,
|
||||
),
|
||||
]);
|
||||
|
||||
// Then
|
||||
expect(results).toEqual([
|
||||
{ status: REGISTRY_FAILURE.ERROR },
|
||||
{ status: REGISTRY_FAILURE.ERROR },
|
||||
{ status: REGISTRY_FAILURE.ERROR },
|
||||
]);
|
||||
});
|
||||
|
||||
it("degrades a non-terminal empty first catalog page", async () => {
|
||||
// Given
|
||||
const document = (page: number) => ({
|
||||
data: [],
|
||||
meta: { pagination: { page, pages: 2, count: 0 } },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await collectCompleteRegistryCatalog(async (page) =>
|
||||
document(page),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
status: "incomplete",
|
||||
reason: "invalid_page",
|
||||
collectedCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a terminal empty first catalog page", async () => {
|
||||
// Given
|
||||
const document = {
|
||||
data: [],
|
||||
meta: { pagination: { page: 1, pages: 1, count: 0 } },
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await collectCompleteRegistryCatalog(async () => document);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ status: "complete", artifacts: [] });
|
||||
});
|
||||
|
||||
it("maps the flat owner attributes tolerantly", async () => {
|
||||
// Given
|
||||
const document = {
|
||||
data: [
|
||||
{
|
||||
type: "registry-artifacts",
|
||||
id: "core",
|
||||
attributes: {
|
||||
owner_name: "Prowler",
|
||||
owner_slug: "prowler",
|
||||
owner_type: "organization",
|
||||
owner_logo_url: "https://cdn.example/prowler.png",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "registry-artifacts",
|
||||
id: "plain-owner",
|
||||
attributes: {
|
||||
owner_name: "Ada",
|
||||
owner_slug: "ada",
|
||||
owner_type: "user",
|
||||
owner_logo_url: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "registry-artifacts",
|
||||
id: "ownerless",
|
||||
attributes: { owner_name: " ", owner_logo_url: " " },
|
||||
},
|
||||
],
|
||||
meta: { pagination: { page: 1, pages: 1, count: 3 } },
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await collectCompleteRegistryCatalog(async () => document);
|
||||
|
||||
// Then
|
||||
expect(result).toMatchObject({
|
||||
status: "complete",
|
||||
artifacts: [
|
||||
{
|
||||
normalizedName: "core",
|
||||
owners: [
|
||||
{
|
||||
type: "organization",
|
||||
name: "Prowler",
|
||||
logoUrl: "https://cdn.example/prowler.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
normalizedName: "ownerless",
|
||||
owners: [],
|
||||
},
|
||||
{
|
||||
normalizedName: "plain-owner",
|
||||
owners: [{ type: "user", name: "Ada", logoUrl: undefined }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults omitted built-in status and maps explicit built-ins", async () => {
|
||||
// Given
|
||||
const document = {
|
||||
data: [
|
||||
{ type: "registry-artifacts", id: "installable", attributes: {} },
|
||||
{
|
||||
type: "registry-artifacts",
|
||||
id: "built-in",
|
||||
attributes: { is_builtin: true },
|
||||
},
|
||||
],
|
||||
meta: { pagination: { page: 1, pages: 1, count: 2 } },
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await collectCompleteRegistryCatalog(async () => document);
|
||||
|
||||
// Then
|
||||
expect(result).toMatchObject({
|
||||
status: "complete",
|
||||
artifacts: [
|
||||
{ normalizedName: "built-in", isBuiltin: true },
|
||||
{ normalizedName: "installable", isBuiltin: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed built-in values and preserves built-in duplicates", async () => {
|
||||
// Given
|
||||
const document = (data: unknown[]) => ({
|
||||
data,
|
||||
meta: { pagination: { page: 1, pages: 1, count: data.length } },
|
||||
});
|
||||
const resource = (id: string, isBuiltin: unknown) => ({
|
||||
type: "registry-artifacts",
|
||||
id,
|
||||
attributes: { is_builtin: isBuiltin },
|
||||
});
|
||||
|
||||
// When
|
||||
const explicitFalse = await collectCompleteRegistryCatalog(async () =>
|
||||
document([resource("installable", false)]),
|
||||
);
|
||||
const malformed = await Promise.all(
|
||||
[null, "true", 1].map((isBuiltin) =>
|
||||
collectCompleteRegistryCatalog(async () =>
|
||||
document([resource("malformed", isBuiltin)]),
|
||||
),
|
||||
),
|
||||
);
|
||||
const duplicate = await collectCompleteRegistryCatalog(async (page) => ({
|
||||
data: [resource("built-in", page === 2)],
|
||||
meta: { pagination: { page, pages: 2, count: 2 } },
|
||||
}));
|
||||
|
||||
// Then
|
||||
expect(explicitFalse).toMatchObject({
|
||||
status: "complete",
|
||||
artifacts: [{ normalizedName: "installable", isBuiltin: false }],
|
||||
});
|
||||
expect(malformed).toEqual([
|
||||
{ status: "incomplete", reason: "invalid_resource", collectedCount: 1 },
|
||||
{ status: "incomplete", reason: "invalid_resource", collectedCount: 1 },
|
||||
{ status: "incomplete", reason: "invalid_resource", collectedCount: 1 },
|
||||
]);
|
||||
expect(duplicate).toMatchObject({
|
||||
status: "complete",
|
||||
artifacts: [{ normalizedName: "built-in", isBuiltin: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves artifact counts, including zero, without inventing missing counts", async () => {
|
||||
// Given
|
||||
const resources = [
|
||||
{ id: "aws", attributes: { check_count: 645, compliance_count: 45 } },
|
||||
{ id: "openai", attributes: { check_count: 2, compliance_count: 0 } },
|
||||
{ id: "missing", attributes: {} },
|
||||
{
|
||||
id: "unknown",
|
||||
attributes: { check_count: null, compliance_count: null },
|
||||
},
|
||||
{ id: "aws", attributes: { check_count: 645 } },
|
||||
].map((resource) => ({
|
||||
type: "registry-available-artifacts",
|
||||
...resource,
|
||||
}));
|
||||
|
||||
// When
|
||||
const result = await collectCompleteRegistryCatalog(async () => ({
|
||||
data: resources,
|
||||
meta: { pagination: { page: 1, pages: 1, count: resources.length } },
|
||||
}));
|
||||
|
||||
// Then
|
||||
expect(result).toMatchObject({
|
||||
status: "complete",
|
||||
artifacts: [
|
||||
{ normalizedName: "aws", checkCount: 645, complianceCount: 45 },
|
||||
{
|
||||
normalizedName: "missing",
|
||||
checkCount: undefined,
|
||||
complianceCount: undefined,
|
||||
},
|
||||
{ normalizedName: "openai", checkCount: 2, complianceCount: 0 },
|
||||
{
|
||||
normalizedName: "unknown",
|
||||
checkCount: undefined,
|
||||
complianceCount: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the declared provider when merging complementary catalog entries", async () => {
|
||||
// Given
|
||||
const fetchPage = async (page: number) => ({
|
||||
data: [
|
||||
{
|
||||
type: "registry-artifacts",
|
||||
id: "external-package",
|
||||
attributes:
|
||||
page === 1
|
||||
? { providers: ["aaa"], has_checks: true }
|
||||
: { providers: ["zzz"], has_provider: true },
|
||||
},
|
||||
],
|
||||
meta: { pagination: { page, pages: 2, count: 2 } },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await collectCompleteRegistryCatalog(fetchPage);
|
||||
|
||||
// Then
|
||||
expect(result).toMatchObject({
|
||||
status: "complete",
|
||||
artifacts: [
|
||||
{ hasProvider: true, providerSlug: "zzz", providers: ["aaa", "zzz"] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate catalog entries with conflicting declared providers", async () => {
|
||||
// Given
|
||||
const fetchPage = async (page: number) => ({
|
||||
data: [
|
||||
{
|
||||
type: "registry-artifacts",
|
||||
id: "external-package",
|
||||
attributes: {
|
||||
has_provider: true,
|
||||
providers: [page === 1 ? "aaa" : "zzz"],
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: { pagination: { page, pages: 2, count: 2 } },
|
||||
});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
collectCompleteRegistryCatalog(fetchPage),
|
||||
).resolves.toMatchObject({
|
||||
status: "incomplete",
|
||||
reason: "conflicting_duplicate",
|
||||
});
|
||||
});
|
||||
|
||||
it("traverses, merges, and degrades unsafe catalog data", async () => {
|
||||
// Given
|
||||
|
||||
const resource = (
|
||||
id: string,
|
||||
attributes: Record<string, unknown> = {},
|
||||
) => ({ type: "registry-artifacts", id, attributes });
|
||||
|
||||
const document = (
|
||||
page: number,
|
||||
pages: number,
|
||||
count: number,
|
||||
data: unknown[],
|
||||
) => ({ data, meta: { pagination: { page, pages, count } } });
|
||||
const requests: Array<[number, string | null, string | null]> = [];
|
||||
|
||||
// When
|
||||
|
||||
const complete = await collectCompleteRegistryCatalog(
|
||||
async (page, query) => {
|
||||
requests.push([
|
||||
page,
|
||||
query.get("page[number]"),
|
||||
query.get("page[size]"),
|
||||
]);
|
||||
return page === 1
|
||||
? document(1, 2, 3, [
|
||||
resource("core", {
|
||||
name: "Core",
|
||||
providers: ["AWS"],
|
||||
is_verified: true,
|
||||
version_count: 1,
|
||||
total_downloads: 2,
|
||||
owner_name: "Prowler",
|
||||
owner_type: "organization",
|
||||
}),
|
||||
resource("zeta"),
|
||||
])
|
||||
: document(2, 2, 3, [
|
||||
resource("core", {
|
||||
description: "Registry core",
|
||||
latest_version: "2.0.0",
|
||||
providers: ["gcp"],
|
||||
is_official: true,
|
||||
has_checks: true,
|
||||
version_count: 3,
|
||||
total_downloads: 8,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
const limits = await Promise.all(
|
||||
[999, 1000, 1001].map(async (pages) => {
|
||||
let requests = 0;
|
||||
const result = await collectCompleteRegistryCatalog(async (page) => {
|
||||
requests += 1;
|
||||
return document(page, pages, pages, [resource(`item-${page}`)]);
|
||||
});
|
||||
return [pages, requests, result] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
const failures = await Promise.all([
|
||||
collectCompleteRegistryCatalog(async () => ({ data: {}, meta: {} })),
|
||||
collectCompleteRegistryCatalog(async () =>
|
||||
document(1, 1, 2, [resource("one")]),
|
||||
),
|
||||
collectCompleteRegistryCatalog(async (page) =>
|
||||
document(page === 1 ? 1 : 1, 2, 2, [resource(`item-${page}`)]),
|
||||
),
|
||||
collectCompleteRegistryCatalog(async (page) =>
|
||||
document(page, page === 1 ? 2 : 3, 2, [resource(`item-${page}`)]),
|
||||
),
|
||||
collectCompleteRegistryCatalog(async () =>
|
||||
document(1, 1, 1, [resource("")]),
|
||||
),
|
||||
collectCompleteRegistryCatalog(async (page) =>
|
||||
document(page, 2, 2, [
|
||||
resource("duplicate", { name: page === 1 ? "One" : "Two" }),
|
||||
]),
|
||||
),
|
||||
collectCompleteRegistryCatalog(async (page) => {
|
||||
if (page === 2) throw new Error("offline");
|
||||
return document(1, 2, 2, [resource("first")]);
|
||||
}),
|
||||
]);
|
||||
|
||||
// Then
|
||||
expect(requests).toEqual([
|
||||
[1, "1", "100"],
|
||||
[2, "2", "100"],
|
||||
]);
|
||||
|
||||
expect(complete).toMatchObject({
|
||||
status: "complete",
|
||||
artifacts: [
|
||||
{
|
||||
normalizedName: "core",
|
||||
name: "Core",
|
||||
description: "Registry core",
|
||||
latestVersion: "2.0.0",
|
||||
providers: ["aws", "gcp"],
|
||||
isVerified: true,
|
||||
isOfficial: true,
|
||||
hasChecks: true,
|
||||
versionCount: 3,
|
||||
totalDownloads: 8,
|
||||
owners: [{ type: "organization", name: "Prowler" }],
|
||||
},
|
||||
{ normalizedName: "zeta" },
|
||||
],
|
||||
});
|
||||
expect(limits.map(([pages, requests]) => [pages, requests])).toEqual([
|
||||
[999, 999],
|
||||
[1000, 1000],
|
||||
[1001, 1],
|
||||
]);
|
||||
expect(limits[2]?.[2]).toEqual({
|
||||
status: "incomplete",
|
||||
reason: "guard_exhausted",
|
||||
collectedCount: 1,
|
||||
});
|
||||
expect(
|
||||
failures.map((result) =>
|
||||
result.status === "incomplete" ? result.reason : undefined,
|
||||
),
|
||||
).toEqual([
|
||||
"invalid_page",
|
||||
"count_mismatch",
|
||||
"invalid_page",
|
||||
"invalid_page",
|
||||
"invalid_resource",
|
||||
"conflicting_duplicate",
|
||||
"page_failed",
|
||||
]);
|
||||
failures.forEach((result) =>
|
||||
expect(result).not.toHaveProperty("artifacts"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { isActiveRegistryCredential } from "@/lib/registry/credential-task";
|
||||
import {
|
||||
REGISTRY_CATALOG,
|
||||
REGISTRY_CATALOG_INCOMPLETE_REASON,
|
||||
REGISTRY_ENDPOINT,
|
||||
REGISTRY_FAILURE,
|
||||
REGISTRY_MUTATION,
|
||||
REGISTRY_SUBMISSION,
|
||||
type RegistryCatalogArtifact,
|
||||
type RegistryCatalogResult,
|
||||
type RegistryCredentialStatus,
|
||||
type RegistryTaskSubmissionResult,
|
||||
type RegistryEndpoint,
|
||||
type RegistryFailureResult,
|
||||
type RegistryMutationResult,
|
||||
type RegistryTenantArtifact,
|
||||
} from "@/types/registry";
|
||||
|
||||
const REGISTRY_TASK_PATH_PREFIX = "/api/v1/tasks/";
|
||||
const REGISTRY_ERROR_CODE = {
|
||||
KEY_REJECTED: "registry_key_rejected",
|
||||
UNAVAILABLE: "registry_unavailable",
|
||||
} as const;
|
||||
const REGISTRY_MUTATION_REFUSAL_COPY = {
|
||||
no_installable_version: "No available version can be added.",
|
||||
registry_artifact_not_found: "This artifact is no longer available.",
|
||||
version_not_found: "This version is not available.",
|
||||
version_not_processed: "This version is not ready to add yet.",
|
||||
version_not_verified: "This version is not verified and cannot be added.",
|
||||
version_yanked: "This version is no longer available.",
|
||||
} as const;
|
||||
const registryDiscoveryEndpoints = new Set<RegistryEndpoint>([
|
||||
REGISTRY_ENDPOINT.PROVIDERS,
|
||||
REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS,
|
||||
]);
|
||||
|
||||
const credentialStatusSchema = z.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
configured: z.boolean(),
|
||||
is_valid: z.boolean(),
|
||||
scopes: z.array(z.string()),
|
||||
last_validated_at: z.string().nullish(),
|
||||
validation_status: z.string().nullish(),
|
||||
validation_pending: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const taskSubmissionSchema = z.object({
|
||||
data: z.object({
|
||||
type: z.literal("tasks"),
|
||||
id: z.string().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
const registryCollectionSchema = z.object({ data: z.array(z.unknown()) });
|
||||
const tenantArtifactsSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
type: z.string().trim().min(1),
|
||||
id: z.string().trim().min(1),
|
||||
attributes: z.object({
|
||||
version_spec: z.string().trim().min(1),
|
||||
resolved_version: z.string().trim().nullish(),
|
||||
inserted_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const errorDocumentSchema = z.object({
|
||||
errors: z.array(z.object({ code: z.string().min(1) })).min(1),
|
||||
});
|
||||
|
||||
export function adaptRegistryCredentialStatus(
|
||||
payload: unknown,
|
||||
): RegistryCredentialStatus | null {
|
||||
const parsed = credentialStatusSchema.safeParse(payload);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { attributes } = parsed.data.data;
|
||||
return {
|
||||
configured: attributes.configured,
|
||||
isValid: attributes.is_valid,
|
||||
scopes: attributes.scopes,
|
||||
lastValidatedAt: attributes.last_validated_at ?? undefined,
|
||||
validationStatus: attributes.validation_status ?? undefined,
|
||||
validationPending: attributes.validation_pending,
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptRegistryTenantArtifacts(
|
||||
payload: unknown,
|
||||
): RegistryTenantArtifact[] | null {
|
||||
const parsed = tenantArtifactsSchema.safeParse(payload);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
return parsed.data.data.map(({ attributes, id }) => ({
|
||||
normalizedName: id,
|
||||
versionSpec: attributes.version_spec,
|
||||
resolvedVersion: attributes.resolved_version || undefined,
|
||||
insertedAt: attributes.inserted_at,
|
||||
updatedAt: attributes.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export function isRegistryCollection(payload: unknown) {
|
||||
return registryCollectionSchema.safeParse(payload).success;
|
||||
}
|
||||
|
||||
export class RegistryCatalogPageError extends Error {
|
||||
constructor(readonly failure: RegistryFailureResult) {
|
||||
super("Registry catalog page request failed");
|
||||
}
|
||||
}
|
||||
|
||||
export const parseRegistryCredentialSubmission = (
|
||||
response: Response,
|
||||
): Promise<RegistryTaskSubmissionResult> =>
|
||||
parseRegistryTaskSubmission(response);
|
||||
|
||||
export const parseRegistryArtifactSubmission = (
|
||||
response: Response,
|
||||
): Promise<RegistryTaskSubmissionResult> =>
|
||||
parseRegistryTaskSubmission(response);
|
||||
|
||||
async function parseRegistryTaskSubmission(
|
||||
response: Response,
|
||||
): Promise<RegistryTaskSubmissionResult> {
|
||||
if (response.status !== 202) return { status: REGISTRY_SUBMISSION.ERROR };
|
||||
|
||||
const parsed = taskSubmissionSchema.safeParse(
|
||||
await response.json().catch(() => undefined),
|
||||
);
|
||||
const taskId = parsed.success ? parsed.data.data.id : undefined;
|
||||
const location = response.headers.get("Content-Location");
|
||||
if (
|
||||
!taskId ||
|
||||
location !== `${REGISTRY_TASK_PATH_PREFIX}${encodeURIComponent(taskId)}`
|
||||
) {
|
||||
return { status: REGISTRY_SUBMISSION.ERROR };
|
||||
}
|
||||
|
||||
return { status: REGISTRY_SUBMISSION.PENDING, taskId };
|
||||
}
|
||||
|
||||
export async function classifyRegistryMutationRefusal(
|
||||
response: Response,
|
||||
): Promise<Extract<RegistryMutationResult, { status: "refused" }> | null> {
|
||||
const code = await getRegistryErrorCode(response);
|
||||
const message = code
|
||||
? REGISTRY_MUTATION_REFUSAL_COPY[
|
||||
code as keyof typeof REGISTRY_MUTATION_REFUSAL_COPY
|
||||
]
|
||||
: undefined;
|
||||
return message ? { status: REGISTRY_MUTATION.REFUSED, message } : null;
|
||||
}
|
||||
|
||||
export async function classifyRegistryFailure(
|
||||
response: Response,
|
||||
endpoint: RegistryEndpoint,
|
||||
credentialStatus: RegistryCredentialStatus | null,
|
||||
): Promise<RegistryFailureResult> {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { status: REGISTRY_FAILURE.ACCESS_DENIED };
|
||||
}
|
||||
|
||||
if (!isRegistryDiscoveryEndpoint(endpoint)) {
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 409 &&
|
||||
credentialStatus !== null &&
|
||||
!isActiveRegistryCredential(credentialStatus)
|
||||
) {
|
||||
return { status: REGISTRY_FAILURE.ONBOARDING };
|
||||
}
|
||||
|
||||
const code = await getRegistryErrorCode(response);
|
||||
if (response.status === 502 && code === REGISTRY_ERROR_CODE.KEY_REJECTED) {
|
||||
return { status: REGISTRY_FAILURE.RECONNECT };
|
||||
}
|
||||
if (response.status === 503 && code === REGISTRY_ERROR_CODE.UNAVAILABLE) {
|
||||
return { status: REGISTRY_FAILURE.UNAVAILABLE };
|
||||
}
|
||||
|
||||
return { status: REGISTRY_FAILURE.ERROR };
|
||||
}
|
||||
|
||||
function isRegistryDiscoveryEndpoint(endpoint: RegistryEndpoint) {
|
||||
return registryDiscoveryEndpoints.has(endpoint);
|
||||
}
|
||||
|
||||
async function getRegistryErrorCode(response: Response) {
|
||||
const parsed = errorDocumentSchema.safeParse(
|
||||
await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => undefined),
|
||||
);
|
||||
return parsed.success ? parsed.data.errors[0]?.code : undefined;
|
||||
}
|
||||
|
||||
const REGISTRY_CATALOG_PAGE_SIZE = 100;
|
||||
const REGISTRY_CATALOG_MAX_PAGES = 1000;
|
||||
const safeInteger = z.number().int().nonnegative().safe();
|
||||
const catalogPageSchema = z.object({
|
||||
data: z.array(z.unknown()),
|
||||
meta: z.object({
|
||||
pagination: z.object({
|
||||
page: safeInteger,
|
||||
pages: safeInteger,
|
||||
count: safeInteger,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const catalogAttributesSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
latest_version: z.string().optional(),
|
||||
providers: z.array(z.string().trim().min(1)).optional(),
|
||||
owner_name: z.string().optional(),
|
||||
owner_type: z.string().optional(),
|
||||
owner_logo_url: z.string().nullable().optional(),
|
||||
is_verified: z.boolean().optional(),
|
||||
is_official: z.boolean().optional(),
|
||||
is_builtin: z.boolean().optional(),
|
||||
is_meta: z.boolean().optional(),
|
||||
has_provider: z.boolean().optional(),
|
||||
has_checks: z.boolean().optional(),
|
||||
has_compliance: z.boolean().optional(),
|
||||
check_count: safeInteger.nullish(),
|
||||
compliance_count: safeInteger.nullish(),
|
||||
version_count: safeInteger.optional(),
|
||||
total_downloads: safeInteger.optional(),
|
||||
});
|
||||
const catalogResourceSchema = z.object({
|
||||
type: z.string().trim().min(1),
|
||||
id: z.string().trim().min(1),
|
||||
attributes: catalogAttributesSchema,
|
||||
});
|
||||
type RegistryCatalogPageFetcher = (
|
||||
page: number,
|
||||
searchParams: URLSearchParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
export async function collectCompleteRegistryCatalog(
|
||||
fetchPage: RegistryCatalogPageFetcher,
|
||||
): Promise<RegistryCatalogResult> {
|
||||
const resources: unknown[] = [];
|
||||
let expectedPages: number | undefined;
|
||||
let expectedCount: number | undefined;
|
||||
for (let page = 1; ; page += 1) {
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await fetchPage(
|
||||
page,
|
||||
new URLSearchParams({
|
||||
"page[number]": String(page),
|
||||
"page[size]": String(REGISTRY_CATALOG_PAGE_SIZE),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryCatalogPageError) throw error;
|
||||
return incomplete("PAGE_FAILED", resources.length);
|
||||
}
|
||||
const parsed = catalogPageSchema.safeParse(payload);
|
||||
if (!parsed.success) return incomplete("INVALID_PAGE", resources.length);
|
||||
const { count, page: responsePage, pages } = parsed.data.meta.pagination;
|
||||
if (
|
||||
responsePage !== page ||
|
||||
(expectedPages !== undefined &&
|
||||
(pages !== expectedPages || count !== expectedCount))
|
||||
)
|
||||
return incomplete("INVALID_PAGE", resources.length);
|
||||
expectedPages ??= pages;
|
||||
expectedCount ??= count;
|
||||
if (page === 1 && pages > 1 && count === 0 && parsed.data.data.length === 0)
|
||||
return incomplete("INVALID_PAGE", resources.length);
|
||||
if (pages === 0)
|
||||
return page === 1 && count === 0 && parsed.data.data.length === 0
|
||||
? { status: REGISTRY_CATALOG.COMPLETE, artifacts: [] }
|
||||
: incomplete("INVALID_PAGE", resources.length);
|
||||
resources.push(...parsed.data.data);
|
||||
if (pages > REGISTRY_CATALOG_MAX_PAGES)
|
||||
return incomplete("GUARD_EXHAUSTED", resources.length);
|
||||
if (page === pages) break;
|
||||
if (page > pages) return incomplete("INVALID_PAGE", resources.length);
|
||||
}
|
||||
const merged = mergeCatalogResources(resources);
|
||||
return merged.status === REGISTRY_CATALOG.INCOMPLETE ||
|
||||
resources.length === expectedCount
|
||||
? merged
|
||||
: incomplete("COUNT_MISMATCH", resources.length);
|
||||
}
|
||||
|
||||
function mergeCatalogResources(resources: unknown[]): RegistryCatalogResult {
|
||||
const artifacts = new Map<string, RegistryCatalogArtifact>();
|
||||
for (const resource of resources) {
|
||||
const artifact = adaptCatalogArtifact(resource);
|
||||
if (!artifact) return incomplete("INVALID_RESOURCE", resources.length);
|
||||
const prior = artifacts.get(artifact.normalizedName);
|
||||
const next = prior ? mergeArtifacts(prior, artifact) : artifact;
|
||||
if (!next) return incomplete("CONFLICTING_DUPLICATE", resources.length);
|
||||
artifacts.set(next.normalizedName, next);
|
||||
}
|
||||
return {
|
||||
status: REGISTRY_CATALOG.COMPLETE,
|
||||
artifacts: Array.from(artifacts.values()).sort((left, right) =>
|
||||
compare(left.normalizedName, right.normalizedName),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptCatalogArtifact(
|
||||
resource: unknown,
|
||||
): RegistryCatalogArtifact | null {
|
||||
const parsed = catalogResourceSchema.safeParse(resource);
|
||||
if (!parsed.success) return null;
|
||||
const { attributes: a, id } = parsed.data;
|
||||
return {
|
||||
normalizedName: id,
|
||||
name: text(a.name),
|
||||
description: text(a.description),
|
||||
latestVersion: text(a.latest_version),
|
||||
providers: unique(
|
||||
a.providers?.map((provider) => provider.toLowerCase()) ?? [],
|
||||
),
|
||||
...(a.has_provider === true && a.providers?.[0]
|
||||
? { providerSlug: a.providers[0].toLowerCase() }
|
||||
: {}),
|
||||
owners: flatOwner(a),
|
||||
isVerified: a.is_verified ?? false,
|
||||
isOfficial: a.is_official ?? false,
|
||||
isBuiltin: a.is_builtin ?? false,
|
||||
isMeta: a.is_meta ?? false,
|
||||
hasProvider: a.has_provider ?? false,
|
||||
hasChecks: a.has_checks ?? false,
|
||||
hasCompliance: a.has_compliance ?? false,
|
||||
checkCount: a.check_count ?? undefined,
|
||||
complianceCount: a.compliance_count ?? undefined,
|
||||
versionCount: a.version_count ?? 0,
|
||||
totalDownloads: a.total_downloads ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeArtifacts(
|
||||
left: RegistryCatalogArtifact,
|
||||
right: RegistryCatalogArtifact,
|
||||
): RegistryCatalogArtifact | null {
|
||||
const [name, description, latestVersion, providerSlug] = [
|
||||
mergeText(left.name, right.name),
|
||||
mergeText(left.description, right.description),
|
||||
mergeText(left.latestVersion, right.latestVersion),
|
||||
mergeText(left.providerSlug, right.providerSlug),
|
||||
];
|
||||
if (
|
||||
[name, description, latestVersion, providerSlug].some(
|
||||
(value) => value === null,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
...left,
|
||||
name: name ?? undefined,
|
||||
description: description ?? undefined,
|
||||
latestVersion: latestVersion ?? undefined,
|
||||
providerSlug: providerSlug ?? undefined,
|
||||
providers: unique([...left.providers, ...right.providers]),
|
||||
owners: uniqueOwners([...left.owners, ...right.owners]),
|
||||
isVerified: left.isVerified || right.isVerified,
|
||||
isOfficial: left.isOfficial || right.isOfficial,
|
||||
isBuiltin: left.isBuiltin || right.isBuiltin,
|
||||
isMeta: left.isMeta || right.isMeta,
|
||||
hasProvider: left.hasProvider || right.hasProvider,
|
||||
hasChecks: left.hasChecks || right.hasChecks,
|
||||
hasCompliance: left.hasCompliance || right.hasCompliance,
|
||||
checkCount: mergeCount(left.checkCount, right.checkCount),
|
||||
complianceCount: mergeCount(left.complianceCount, right.complianceCount),
|
||||
versionCount: Math.max(left.versionCount, right.versionCount),
|
||||
totalDownloads: Math.max(left.totalDownloads, right.totalDownloads),
|
||||
};
|
||||
}
|
||||
|
||||
function incomplete(
|
||||
reason: keyof typeof REGISTRY_CATALOG_INCOMPLETE_REASON,
|
||||
collectedCount: number,
|
||||
): RegistryCatalogResult {
|
||||
return {
|
||||
status: REGISTRY_CATALOG.INCOMPLETE,
|
||||
reason: REGISTRY_CATALOG_INCOMPLETE_REASON[reason],
|
||||
collectedCount,
|
||||
};
|
||||
}
|
||||
function text(value: string | undefined) {
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
function mergeText(left: string | undefined, right: string | undefined) {
|
||||
return left && right && left !== right ? null : (left ?? right);
|
||||
}
|
||||
function mergeCount(left: number | undefined, right: number | undefined) {
|
||||
if (left === undefined) return right;
|
||||
if (right === undefined) return left;
|
||||
return Math.max(left, right);
|
||||
}
|
||||
function unique(values: string[]) {
|
||||
return Array.from(new Set(values)).sort(compare);
|
||||
}
|
||||
function flatOwner(
|
||||
a: z.infer<typeof catalogAttributesSchema>,
|
||||
): RegistryCatalogArtifact["owners"] {
|
||||
const name = text(a.owner_name);
|
||||
if (!name) return [];
|
||||
return [
|
||||
{
|
||||
name,
|
||||
type: text(a.owner_type) ?? "",
|
||||
logoUrl: text(a.owner_logo_url ?? undefined),
|
||||
},
|
||||
];
|
||||
}
|
||||
function uniqueOwners(owners: RegistryCatalogArtifact["owners"]) {
|
||||
return Array.from(
|
||||
new Map(
|
||||
owners.map((owner) => [`${owner.type}\u0000${owner.name}`, owner]),
|
||||
).values(),
|
||||
).sort((left, right) =>
|
||||
compare(
|
||||
`${left.type}\u0000${left.name}`,
|
||||
`${right.type}\u0000${right.name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
function compare(left: string, right: string) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,578 @@
|
||||
"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 { isRegistryArtifactInstallable } from "@/lib/registry/artifacts";
|
||||
import { isActiveRegistryCredential } from "@/lib/registry/credential-task";
|
||||
import {
|
||||
buildRegistryProviderOptions,
|
||||
type RegistryProviderOption,
|
||||
} from "@/lib/registry/provider-options";
|
||||
import {
|
||||
REGISTRY_ARTIFACT_ACTION,
|
||||
REGISTRY_ARTIFACT_REMOVAL,
|
||||
REGISTRY_BOOTSTRAP_STATE,
|
||||
REGISTRY_CATALOG,
|
||||
REGISTRY_CREDENTIAL_ACTION,
|
||||
REGISTRY_CREDENTIAL_READ,
|
||||
REGISTRY_ENDPOINT,
|
||||
REGISTRY_FAILURE,
|
||||
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,
|
||||
collectCompleteRegistryCatalog,
|
||||
isRegistryCollection,
|
||||
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;
|
||||
|
||||
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<
|
||||
| { status: "ready"; options: RegistryProviderOption[] }
|
||||
| { status: "access_denied" | "error" }
|
||||
> {
|
||||
const access = (await auth())?.accessToken;
|
||||
const permission = await evaluateRegistryProviderAccess(access);
|
||||
if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE)
|
||||
return { status: "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: "access_denied" };
|
||||
if (
|
||||
catalog.status !== REGISTRY_CATALOG.COMPLETE ||
|
||||
installed.status !== "ready" ||
|
||||
providers.status !== "ready"
|
||||
)
|
||||
return { status: "error" };
|
||||
return {
|
||||
status: "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 || !isRegistryArtifactInstallable(artifact))
|
||||
return {
|
||||
status: "refused",
|
||||
message: "Only external provider artifacts can be added.",
|
||||
};
|
||||
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 { status: REGISTRY_ARTIFACT_REMOVAL.IN_USE };
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,7 @@ const makeRoleFormData = () => {
|
||||
formData.set("manage_scans", "false");
|
||||
formData.set("manage_alerts", "true");
|
||||
formData.set("manage_lighthouse_ai_configuration", "true");
|
||||
formData.set("manage_registry", "true");
|
||||
formData.set("unlimited_visibility", "false");
|
||||
return formData;
|
||||
};
|
||||
@@ -73,6 +74,36 @@ describe("role actions", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("includes manage_registry when creating and updating a role in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
|
||||
// When
|
||||
await addRole(makeRoleFormData());
|
||||
const createAttributes = lastRequestBody().data.attributes;
|
||||
await updateRole(makeRoleFormData(), "role-1");
|
||||
const updateAttributes = lastRequestBody().data.attributes;
|
||||
|
||||
// Then
|
||||
expect(createAttributes.manage_registry).toBe(true);
|
||||
expect(updateAttributes.manage_registry).toBe(true);
|
||||
});
|
||||
|
||||
it("omits manage_registry when creating and updating a role outside Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
|
||||
// When
|
||||
await addRole(makeRoleFormData());
|
||||
const createAttributes = lastRequestBody().data.attributes;
|
||||
await updateRole(makeRoleFormData(), "role-1");
|
||||
const updateAttributes = lastRequestBody().data.attributes;
|
||||
|
||||
// Then
|
||||
expect(createAttributes).not.toHaveProperty("manage_registry");
|
||||
expect(updateAttributes).not.toHaveProperty("manage_registry");
|
||||
});
|
||||
|
||||
it("includes manage_alerts when creating a role in Prowler Cloud", async () => {
|
||||
// Given
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
|
||||
@@ -116,6 +116,8 @@ export const addRole = async (formData: FormData) => {
|
||||
formData.get("manage_alerts") === "true";
|
||||
payload.data.attributes.manage_lighthouse_ai_configuration =
|
||||
formData.get("manage_lighthouse_ai_configuration") === "true";
|
||||
payload.data.attributes.manage_registry =
|
||||
formData.get("manage_registry") === "true";
|
||||
}
|
||||
|
||||
// Add provider groups relationships only if there are items
|
||||
@@ -175,6 +177,8 @@ export const updateRole = async (formData: FormData, roleId: string) => {
|
||||
formData.get("manage_alerts") === "true";
|
||||
payload.data.attributes.manage_lighthouse_ai_configuration =
|
||||
formData.get("manage_lighthouse_ai_configuration") === "true";
|
||||
payload.data.attributes.manage_registry =
|
||||
formData.get("manage_registry") === "true";
|
||||
}
|
||||
|
||||
// Add provider groups relationships only if there are items
|
||||
|
||||
Reference in New Issue
Block a user