mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
feat(ui): let Lighthouse use Bedrock without keys where the API allows it (#13101)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53b7b24e2b
commit
814ae54d6a
10 files changed
+328
-19
No files matched your search
@@ -73,6 +73,7 @@ describe("lighthouse-v2.adapter", () => {
|
||||
expect(mapLighthouseV2Provider(provider)).toEqual({
|
||||
id: "openai-compatible",
|
||||
name: "OpenAI Compatible",
|
||||
credentialsOptional: false,
|
||||
});
|
||||
expect(mapLighthouseV2Model(model)).toEqual({
|
||||
id: "gpt-5.5",
|
||||
@@ -182,6 +183,28 @@ describe("lighthouse-v2.adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("when reading provider capabilities", () => {
|
||||
it("should mark Bedrock credentials optional only when the API says so", () => {
|
||||
// Given
|
||||
const resource = (credentials_optional?: boolean) => ({
|
||||
id: "bedrock",
|
||||
type: "lighthouse-supported-providers",
|
||||
attributes: { name: "AWS Bedrock", credentials_optional },
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(mapLighthouseV2Provider(resource(true)).credentialsOptional).toBe(
|
||||
true,
|
||||
);
|
||||
expect(mapLighthouseV2Provider(resource(false)).credentialsOptional).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
mapLighthouseV2Provider(resource(undefined)).credentialsOptional,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when building Cloud payloads", () => {
|
||||
it("should include agent text, display text, and UI context for contextual messages", () => {
|
||||
// Given
|
||||
|
||||
@@ -36,6 +36,7 @@ interface ConfigurationAttributes {
|
||||
|
||||
interface SupportedProviderAttributes {
|
||||
name: string;
|
||||
credentials_optional?: boolean;
|
||||
}
|
||||
|
||||
interface SupportedModelAttributes {
|
||||
@@ -136,6 +137,7 @@ export function mapLighthouseV2Provider(
|
||||
return {
|
||||
id: normalizeLighthouseV2ProviderType(resource.id),
|
||||
name: resource.attributes.name,
|
||||
credentialsOptional: resource.attributes.credentials_optional === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +104,13 @@ const modelsByProvider = {
|
||||
};
|
||||
|
||||
const supportedProviders: LighthouseV2SupportedProvider[] = [
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
{ id: "bedrock", name: "AWS Bedrock" },
|
||||
{ id: "openai-compatible", name: "OpenAI Compatible" },
|
||||
{ id: "openai", name: "OpenAI", credentialsOptional: false },
|
||||
{ id: "bedrock", name: "AWS Bedrock", credentialsOptional: false },
|
||||
{
|
||||
id: "openai-compatible",
|
||||
name: "OpenAI Compatible",
|
||||
credentialsOptional: false,
|
||||
},
|
||||
];
|
||||
|
||||
const OUTCOME_FEEDBACK_SURVEY = {
|
||||
|
||||
@@ -61,9 +61,14 @@ export function LighthouseV2ConfigurationForm({
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const providerType = provider.id;
|
||||
const hasConfiguration = Boolean(configuration);
|
||||
const capabilities = { credentialsOptional: provider.credentialsOptional };
|
||||
const form = useForm<LighthouseV2ConfigFormValues>({
|
||||
resolver: zodResolver(
|
||||
buildLighthouseV2ConfigFormSchema(providerType, hasConfiguration),
|
||||
buildLighthouseV2ConfigFormSchema(
|
||||
providerType,
|
||||
hasConfiguration,
|
||||
capabilities,
|
||||
),
|
||||
),
|
||||
defaultValues: getFormDefaults(configuration),
|
||||
mode: "onSubmit",
|
||||
@@ -107,6 +112,7 @@ export function LighthouseV2ConfigurationForm({
|
||||
providerType,
|
||||
values,
|
||||
hasConfiguration,
|
||||
capabilities,
|
||||
);
|
||||
const baseUrl = trimToNullable(values.baseUrl);
|
||||
const shouldTestAfterSave =
|
||||
@@ -254,12 +260,15 @@ export function LighthouseV2ConfigurationForm({
|
||||
icon={<KeyRound className="size-4" />}
|
||||
title="Credentials"
|
||||
description={
|
||||
configuration
|
||||
? "Leave blank to keep existing credentials."
|
||||
: "Credentials are required for new configurations."
|
||||
provider.credentialsOptional
|
||||
? "Leave the keys blank to use this installation's own AWS role; only the region is needed."
|
||||
: configuration
|
||||
? "Leave blank to keep existing credentials."
|
||||
: "Credentials are required for new configurations."
|
||||
}
|
||||
>
|
||||
<CredentialFields
|
||||
credentialsOptional={provider.credentialsOptional}
|
||||
errors={form.formState.errors}
|
||||
hasStoredCredentials={hasConfiguration}
|
||||
provider={providerType}
|
||||
|
||||
@@ -13,11 +13,14 @@ import { Input } from "@/components/shadcn/input/input";
|
||||
const STORED_SECRET_PLACEHOLDER = "•".repeat(36);
|
||||
|
||||
export function CredentialFields({
|
||||
credentialsOptional = false,
|
||||
errors,
|
||||
hasStoredCredentials = false,
|
||||
provider,
|
||||
register,
|
||||
}: {
|
||||
// Keys may be left blank (Bedrock with the installation's own role).
|
||||
credentialsOptional?: boolean;
|
||||
errors: ReturnType<
|
||||
typeof useForm<LighthouseV2ConfigFormValues>
|
||||
>["formState"]["errors"];
|
||||
@@ -27,9 +30,13 @@ export function CredentialFields({
|
||||
typeof useForm<LighthouseV2ConfigFormValues>
|
||||
>["register"];
|
||||
}) {
|
||||
const secretPlaceholder = hasStoredCredentials
|
||||
? STORED_SECRET_PLACEHOLDER
|
||||
: undefined;
|
||||
// With optional keys a saved configuration may have none, so the masked
|
||||
// placeholder would suggest a key that is not there.
|
||||
const secretPlaceholder =
|
||||
hasStoredCredentials && !credentialsOptional
|
||||
? STORED_SECRET_PLACEHOLDER
|
||||
: undefined;
|
||||
const keyLabelSuffix = credentialsOptional ? " (optional)" : "";
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
|
||||
@@ -69,7 +76,7 @@ export function CredentialFields({
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-access-key">
|
||||
AWS access key ID
|
||||
AWS access key ID{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-access-key"
|
||||
@@ -86,7 +93,7 @@ export function CredentialFields({
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-secret-key">
|
||||
AWS secret access key
|
||||
AWS secret access key{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-secret-key"
|
||||
|
||||
@@ -42,9 +42,13 @@ vi.mock("@/app/(prowler)/lighthouse/_actions", () => ({
|
||||
}));
|
||||
|
||||
const providers: LighthouseV2SupportedProvider[] = [
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
{ id: "bedrock", name: "Amazon Bedrock" },
|
||||
{ id: "openai-compatible", name: "OpenAI-compatible" },
|
||||
{ id: "openai", name: "OpenAI", credentialsOptional: false },
|
||||
{ id: "bedrock", name: "Amazon Bedrock", credentialsOptional: false },
|
||||
{
|
||||
id: "openai-compatible",
|
||||
name: "OpenAI-compatible",
|
||||
credentialsOptional: false,
|
||||
},
|
||||
];
|
||||
|
||||
const configurations: LighthouseV2Configuration[] = [
|
||||
@@ -340,6 +344,62 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("changes a Bedrock region without keys when the installation role is allowed", async () => {
|
||||
// Given: the API reports Bedrock needs no credentials (Private Cloud)
|
||||
const user = userEvent.setup();
|
||||
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
|
||||
testConnectionMock.mockResolvedValue({ data: configurations[1] });
|
||||
renderPage({
|
||||
providers: providers.map((provider) =>
|
||||
provider.id === "bedrock"
|
||||
? { ...provider, credentialsOptional: true }
|
||||
: provider,
|
||||
),
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// When: only the region is entered
|
||||
expect(
|
||||
screen.getByLabelText("AWS access key ID (optional)"),
|
||||
).not.toHaveAttribute("placeholder");
|
||||
await user.type(screen.getByLabelText("AWS region"), "eu-west-1");
|
||||
await user.click(
|
||||
within(
|
||||
screen.getByRole("region", { name: "Lighthouse AI settings" }),
|
||||
).getByRole("button", { name: /^Save$/i }),
|
||||
);
|
||||
|
||||
// Then: the region alone is sent, without empty key strings
|
||||
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
|
||||
expect(updateConfigurationMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
credentials: { aws_region_name: "eu-west-1" },
|
||||
});
|
||||
expect(
|
||||
updateConfigurationMock.mock.calls[0]?.[1].credentials,
|
||||
).not.toHaveProperty("aws_access_key_id");
|
||||
});
|
||||
|
||||
it("still requires Bedrock keys where the installation role is not allowed", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// When
|
||||
await user.type(screen.getByLabelText("AWS region"), "eu-west-1");
|
||||
await user.click(
|
||||
within(
|
||||
screen.getByRole("region", { name: "Lighthouse AI settings" }),
|
||||
).getByRole("button", { name: /^Save$/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("AWS access key ID is required."),
|
||||
).toBeInTheDocument();
|
||||
expect(updateConfigurationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tests the connection and reports the resulting status", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -58,9 +58,13 @@ const modelsByProvider = {
|
||||
};
|
||||
|
||||
const supportedProviders: LighthouseV2SupportedProvider[] = [
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
{ id: "bedrock", name: "AWS Bedrock" },
|
||||
{ id: "openai-compatible", name: "OpenAI Compatible" },
|
||||
{ id: "openai", name: "OpenAI", credentialsOptional: false },
|
||||
{ id: "bedrock", name: "AWS Bedrock", credentialsOptional: false },
|
||||
{
|
||||
id: "openai-compatible",
|
||||
name: "OpenAI Compatible",
|
||||
credentialsOptional: false,
|
||||
},
|
||||
];
|
||||
|
||||
const config = { configurations, modelsByProvider, supportedProviders };
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LIGHTHOUSE_V2_PROVIDER_TYPE } from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
import {
|
||||
buildCredentialPayload,
|
||||
buildLighthouseV2ConfigFormSchema,
|
||||
EMPTY_FORM_VALUES,
|
||||
type LighthouseV2ConfigFormValues,
|
||||
} from "./config";
|
||||
|
||||
const BEDROCK = LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK;
|
||||
const OPTIONAL = { credentialsOptional: true };
|
||||
|
||||
const values = (
|
||||
overrides: Partial<LighthouseV2ConfigFormValues>,
|
||||
): LighthouseV2ConfigFormValues => ({ ...EMPTY_FORM_VALUES, ...overrides });
|
||||
|
||||
const errorPaths = (
|
||||
hasConfiguration: boolean,
|
||||
formValues: LighthouseV2ConfigFormValues,
|
||||
capabilities = {},
|
||||
) => {
|
||||
const result = buildLighthouseV2ConfigFormSchema(
|
||||
BEDROCK,
|
||||
hasConfiguration,
|
||||
capabilities,
|
||||
).safeParse(formValues);
|
||||
return result.success
|
||||
? []
|
||||
: result.error.issues.map((issue) => issue.path.join("."));
|
||||
};
|
||||
|
||||
describe("Bedrock configuration form", () => {
|
||||
describe("when the provider requires credentials", () => {
|
||||
it("should require both keys and the region for a new configuration", () => {
|
||||
// Given / When / Then
|
||||
expect(errorPaths(false, values({ awsRegionName: "us-east-1" }))).toEqual(
|
||||
["awsAccessKeyId", "awsSecretAccessKey"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should still require keys to change only the region", () => {
|
||||
// Given / When / Then
|
||||
expect(errorPaths(true, values({ awsRegionName: "eu-west-1" }))).toEqual([
|
||||
"awsAccessKeyId",
|
||||
"awsSecretAccessKey",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should send all three fields", () => {
|
||||
// Given
|
||||
const formValues = values({
|
||||
awsAccessKeyId: "AKIA",
|
||||
awsSecretAccessKey: "secret",
|
||||
awsRegionName: "us-east-1",
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
|
||||
aws_access_key_id: "AKIA",
|
||||
aws_secret_access_key: "secret",
|
||||
aws_region_name: "us-east-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the installation's own role can be used", () => {
|
||||
it("should accept a new configuration with only a region", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(false, values({ awsRegionName: "us-east-1" }), OPTIONAL),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("should still require the region for a new configuration", () => {
|
||||
// Given / When / Then
|
||||
expect(errorPaths(false, values({}), OPTIONAL)).toEqual([
|
||||
"awsRegionName",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should accept a region change on an existing configuration", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(true, values({ awsRegionName: "eu-west-1" }), OPTIONAL),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("should require keys as a pair when one is given", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(
|
||||
false,
|
||||
values({ awsAccessKeyId: "AKIA", awsRegionName: "us-east-1" }),
|
||||
OPTIONAL,
|
||||
),
|
||||
).toEqual(["awsSecretAccessKey"]);
|
||||
});
|
||||
|
||||
it("should send only the region, without empty key strings", () => {
|
||||
// Given
|
||||
const formValues = values({ awsRegionName: " us-east-1 " });
|
||||
|
||||
// When / Then
|
||||
expect(
|
||||
buildCredentialPayload(BEDROCK, formValues, false, OPTIONAL),
|
||||
).toEqual({ aws_region_name: "us-east-1" });
|
||||
expect(
|
||||
buildCredentialPayload(BEDROCK, formValues, true, OPTIONAL),
|
||||
).toEqual({ aws_region_name: "us-east-1" });
|
||||
});
|
||||
|
||||
it("should send keys when they are given", () => {
|
||||
// Given
|
||||
const formValues = values({
|
||||
awsAccessKeyId: "AKIA",
|
||||
awsSecretAccessKey: "secret",
|
||||
awsRegionName: "us-east-1",
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(
|
||||
buildCredentialPayload(BEDROCK, formValues, false, OPTIONAL),
|
||||
).toEqual({
|
||||
aws_access_key_id: "AKIA",
|
||||
aws_secret_access_key: "secret",
|
||||
aws_region_name: "us-east-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep the stored configuration when nothing is entered", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
buildCredentialPayload(BEDROCK, values({}), true, OPTIONAL),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -66,9 +66,16 @@ export function getFormDefaults(
|
||||
};
|
||||
}
|
||||
|
||||
export interface LighthouseV2ProviderCapabilities {
|
||||
// The API accepts this provider without credentials (see
|
||||
// `LighthouseV2SupportedProvider.credentialsOptional`).
|
||||
credentialsOptional?: boolean;
|
||||
}
|
||||
|
||||
export function buildLighthouseV2ConfigFormSchema(
|
||||
provider: LighthouseV2ProviderType,
|
||||
hasConfiguration: boolean,
|
||||
{ credentialsOptional = false }: LighthouseV2ProviderCapabilities = {},
|
||||
) {
|
||||
return lighthouseV2ConfigFormSchemaBase.superRefine((data, ctx) => {
|
||||
const apiKey = data.apiKey.trim();
|
||||
@@ -110,6 +117,39 @@ export function buildLighthouseV2ConfigFormSchema(
|
||||
|
||||
if (provider !== LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return;
|
||||
|
||||
if (credentialsOptional) {
|
||||
// The installation's own role: a region alone is enough, and keys,
|
||||
// when given, still have to come as a pair.
|
||||
const accessKeyId = data.awsAccessKeyId.trim();
|
||||
const secretAccessKey = data.awsSecretAccessKey.trim();
|
||||
if (accessKeyId && !secretAccessKey) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "AWS secret access key is required with an access key ID.",
|
||||
path: ["awsSecretAccessKey"],
|
||||
});
|
||||
}
|
||||
if (secretAccessKey && !accessKeyId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "AWS access key ID is required with a secret access key.",
|
||||
path: ["awsAccessKeyId"],
|
||||
});
|
||||
}
|
||||
const changesCredentials = Boolean(accessKeyId || secretAccessKey);
|
||||
if (
|
||||
(!hasConfiguration || changesCredentials) &&
|
||||
!data.awsRegionName.trim()
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "AWS region is required.",
|
||||
path: ["awsRegionName"],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAnyBedrockCredential =
|
||||
Boolean(data.awsAccessKeyId.trim()) ||
|
||||
Boolean(data.awsSecretAccessKey.trim()) ||
|
||||
@@ -147,6 +187,7 @@ export function buildCredentialPayload(
|
||||
provider: LighthouseV2ProviderType,
|
||||
values: LighthouseV2ConfigFormValues,
|
||||
hasConfiguration: boolean,
|
||||
{ credentialsOptional = false }: LighthouseV2ProviderCapabilities = {},
|
||||
): LighthouseV2Credentials | undefined {
|
||||
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
|
||||
const hasBedrockCredentials =
|
||||
@@ -156,6 +197,16 @@ export function buildCredentialPayload(
|
||||
|
||||
if (hasConfiguration && !hasBedrockCredentials) return undefined;
|
||||
|
||||
// No keys: the installation's own role, so the region alone. Empty key
|
||||
// strings are left out rather than sent.
|
||||
if (
|
||||
credentialsOptional &&
|
||||
!values.awsAccessKeyId.trim() &&
|
||||
!values.awsSecretAccessKey.trim()
|
||||
) {
|
||||
return { aws_region_name: values.awsRegionName.trim() };
|
||||
}
|
||||
|
||||
return {
|
||||
aws_access_key_id: values.awsAccessKeyId.trim(),
|
||||
aws_secret_access_key: values.awsSecretAccessKey.trim(),
|
||||
|
||||
@@ -26,9 +26,16 @@ export interface LighthouseV2BedrockApiKeyCredentials {
|
||||
aws_region_name: string;
|
||||
}
|
||||
|
||||
// Only where the API allows it (the provider's `credentialsOptional`): Bedrock
|
||||
// is then called with the installation's own AWS role, so only the region.
|
||||
export interface LighthouseV2BedrockInstanceRoleCredentials {
|
||||
aws_region_name: string;
|
||||
}
|
||||
|
||||
export type LighthouseV2BedrockCredentials =
|
||||
| LighthouseV2BedrockAccessKeyCredentials
|
||||
| LighthouseV2BedrockApiKeyCredentials;
|
||||
| LighthouseV2BedrockApiKeyCredentials
|
||||
| LighthouseV2BedrockInstanceRoleCredentials;
|
||||
|
||||
export type LighthouseV2Credentials =
|
||||
| LighthouseV2OpenAICredentials
|
||||
@@ -84,6 +91,9 @@ export interface LighthouseV2ConfigurationUpdateInput {
|
||||
export interface LighthouseV2SupportedProvider {
|
||||
id: LighthouseV2ProviderType;
|
||||
name: string;
|
||||
// True when the API accepts this provider without credentials (Bedrock with
|
||||
// the installation's own role). False whenever the API does not say so.
|
||||
credentialsOptional: boolean;
|
||||
}
|
||||
|
||||
export interface LighthouseV2SupportedModel {
|
||||
|
||||
Reference in new issue
Block a user