diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts index 2cb1827a62..a8a4758ae1 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.test.ts @@ -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 diff --git a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts index f0f30d88d7..cb10337e46 100644 --- a/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts +++ b/ui/app/(prowler)/lighthouse/_actions/lighthouse-v2.adapter.ts @@ -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, }; } diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx index 047445f01d..4ff4e6bf22 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-page.test.tsx @@ -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 = { diff --git a/ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx b/ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx index 6ce5bab8b2..d2903add09 100644 --- a/ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx +++ b/ui/app/(prowler)/lighthouse/_components/config/configuration-form.tsx @@ -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({ 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={} 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." } > >["formState"]["errors"]; @@ -27,9 +30,13 @@ export function CredentialFields({ typeof useForm >["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 (
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI || @@ -69,7 +76,7 @@ export function CredentialFields({
- AWS access key ID + AWS access key ID{keyLabelSuffix} - AWS secret access key + AWS secret access key{keyLabelSuffix} ({ })); 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(); diff --git a/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts b/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts index c1fac483ea..c5cd5accf1 100644 --- a/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts +++ b/ui/app/(prowler)/lighthouse/_lib/chat-store.test.ts @@ -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 }; diff --git a/ui/app/(prowler)/lighthouse/_lib/config.test.ts b/ui/app/(prowler)/lighthouse/_lib/config.test.ts new file mode 100644 index 0000000000..2d50f64361 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_lib/config.test.ts @@ -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 => ({ ...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(); + }); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_lib/config.ts b/ui/app/(prowler)/lighthouse/_lib/config.ts index 6f79c338dc..95cf4eb97f 100644 --- a/ui/app/(prowler)/lighthouse/_lib/config.ts +++ b/ui/app/(prowler)/lighthouse/_lib/config.ts @@ -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(), diff --git a/ui/app/(prowler)/lighthouse/_types/config.ts b/ui/app/(prowler)/lighthouse/_types/config.ts index c987c1899a..e00cb173f0 100644 --- a/ui/app/(prowler)/lighthouse/_types/config.ts +++ b/ui/app/(prowler)/lighthouse/_types/config.ts @@ -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 {