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:
manuelProwlerandClaude Opus 5.5 authored and GitHub committed 2026-10-28 11:57:42 -05:00
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(),
+11 -1
View File
@@ -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 {