feat(ui): complete Registry provider onboarding for Private Cloud (#12494)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscaglia
2026-09-16 12:21:25 +02:00
committed by GitHub
co-authored by alejandrobailo
parent 974f4251dd
commit 2198ba2d84
144 changed files with 16218 additions and 311 deletions
+26 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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" });
});
});
+61
View File
@@ -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();
});
});
+57
View File
@@ -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"),
);
});
});
+441
View File
@@ -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
+578
View File
@@ -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,
};
}
+31
View File
@@ -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");
+4
View File
@@ -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