From b1230d7cc9a5888048c5639bbe83828e443b56e4 Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Fri, 21 Aug 2026 09:45:48 +0100 Subject: [PATCH] feat(ui): guide Azure certificate onboarding - Keep the six-step provider workflow in the certificate form - Generate certificate bundles in the browser for secure upload - Link deployment actions to the documentation-hosted ARM template --- .../azure-certificate-authentication.added.md | 1 + ...cate-credentials-form.integration.test.tsx | 55 ++++++++ ...zure-certificate-credentials-form.test.tsx | 123 ++++++++++++++---- .../azure-certificate-credentials-form.tsx | 100 +++++++++----- ui/dependency-log.json | 20 ++- ui/lib/azure-cert-generator.ts | 5 +- ui/lib/external-urls.test.ts | 57 ++------ ui/lib/external-urls.ts | 40 +----- ui/tests/providers/providers-page.ts | 48 ++++++- ui/tests/providers/providers.md | 45 +++++++ ui/tests/providers/providers.spec.ts | 51 +++++++- ui/types/components.ts | 2 + ui/types/formSchemas.test.ts | 73 +++++++++++ ui/types/formSchemas.ts | 58 +++++++-- 14 files changed, 521 insertions(+), 157 deletions(-) create mode 100644 ui/changelog.d/azure-certificate-authentication.added.md create mode 100644 ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.integration.test.tsx diff --git a/ui/changelog.d/azure-certificate-authentication.added.md b/ui/changelog.d/azure-certificate-authentication.added.md new file mode 100644 index 0000000000..d49d9e9616 --- /dev/null +++ b/ui/changelog.d/azure-certificate-authentication.added.md @@ -0,0 +1 @@ +Azure certificate authentication in the add-provider wizard with six-step onboarding, in-browser certificate generation, and Mintlify-hosted ARM template deployment links diff --git a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.integration.test.tsx b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.integration.test.tsx new file mode 100644 index 0000000000..37e1594d73 --- /dev/null +++ b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.integration.test.tsx @@ -0,0 +1,55 @@ +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, vi } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { render } from "@/__tests__/render-browser"; +import { AzureCertificateCredentials } from "@/types"; + +import { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form"; + +const Harness = () => { + const form = useForm(); + return ( + + + + ); +}; + +describe("AzureCertificateCredentialsForm browser flow", () => { + it("renders deployment guidance and keeps certificate generation functional", async () => { + // When + const view = await render(); + + // Then + expect(document.querySelectorAll("ol > li")).toHaveLength(6); + expect( + view.getByRole("link", { name: "Deploy to Azure" }).element(), + ).toHaveAttribute( + "href", + "https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json", + ); + expect( + view.getByRole("link", { name: "Open template" }).element(), + ).toHaveAttribute( + "href", + "https://docs.prowler.com/assets/templates/azure/prowler-scan.json", + ); + + await view.getByRole("button", { name: "Generate certificate" }).click(); + await vi.waitFor( + () => { + expect( + view.getByText(/Downloaded prowler-cert\.cer/).element(), + ).toBeVisible(); + }, + { timeout: 20_000 }, + ); + const bundle = view + .getByRole("textbox", { + name: "Certificate and Private Key Bundle (Base64)", + }) + .element() as HTMLTextAreaElement; + expect(bundle.value.length).toBeGreaterThan(100); + }); +}); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.test.tsx b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.test.tsx index 11f4457038..b81c84234d 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.test.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.test.tsx @@ -6,9 +6,6 @@ import { AzureCertificateCredentials } from "@/types"; import { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form"; -// Wraps the form in a react-hook-form context so the WizardInputField and -// WizardTextareaField mount without exploding. We are testing the Deploy to -// Azure link and the field structure, not the input controls themselves. const Harness = () => { const form = useForm(); return ( @@ -18,48 +15,118 @@ const Harness = () => { ); }; +const expectExternalLinkIcon = (link: HTMLElement) => { + const icon = link.querySelector("svg.lucide-external-link"); + + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute("aria-hidden", "true"); + expect(icon).toHaveClass("size-3.5", "shrink-0"); +}; + describe("AzureCertificateCredentialsForm", () => { - it("renders the Deploy to Azure link with the Portal custom-deployment URL and safe target attributes", () => { + it("renders the Deploy to Azure link with the docs-hosted template", () => { // Given render(); // When - const link = screen.getByRole("link", { name: /deploy to azure/i }); + const link = screen.getByRole("link", { name: "Deploy to Azure" }); // Then expect(link).toHaveAttribute("target", "_blank"); expect(link).toHaveAttribute("rel", "noopener noreferrer"); - - const href = link.getAttribute("href") ?? ""; - expect( - href.startsWith( - "https://portal.azure.com/#create/Microsoft.Template/uri/", - ), - ).toBe(true); - // The Bicep template URL must be percent-encoded exactly once into the - // fragment; asserting on the encoded slashes guards against a future - // refactor that swaps `encodeURIComponent` for a helper that leaves them - // raw and breaks the Portal parser. - expect(href).toContain( - "https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json", + expect(link).toHaveAttribute( + "href", + "https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json", ); }); - it("keeps the certificate generation docs link pointing at the Azure authentication doc", () => { + it("links the manual template fallback to the docs-hosted JSON", () => { + // Given / When + render(); + + // Then + const link = screen.getByRole("link", { name: "Open template" }); + expect(link).toHaveAttribute( + "href", + "https://docs.prowler.com/assets/templates/azure/prowler-scan.json", + ); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expectExternalLinkIcon(link); + expect( + screen.getByText(/build your own template in the editor/i), + ).toBeInTheDocument(); + }); + + it("renders every external link with the shared icon and safe attributes", () => { // Given render(); - // When - const docsLink = screen.getByRole("link", { - name: /full guide/i, - }); + // When / Then + const links = [ + { + element: screen.getByRole("link", { name: "Full guide" }), + href: "https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication", + }, + { + element: screen.getByRole("link", { name: "New App Registration" }), + href: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/CreateApplicationBlade", + }, + { + element: screen.getByRole("link", { + name: "Enterprise applications", + }), + href: "https://portal.azure.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview", + }, + { + element: screen.getByRole("link", { name: "Deploy to Azure" }), + }, + { + element: screen.getByRole("link", { name: "Open template" }), + href: "https://docs.prowler.com/assets/templates/azure/prowler-scan.json", + }, + ]; + + for (const { element, href } of links) { + if (href) expect(element).toHaveAttribute("href", href); + expect(element).toHaveAttribute("target", "_blank"); + expect(element).toHaveAttribute("rel", "noopener noreferrer"); + expectExternalLinkIcon(element); + } + }); + + it("renders the approved six-step certificate onboarding guidance", () => { + // Given / When + render(); // Then - expect(docsLink).toHaveAttribute( - "href", - "https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication", + const steps = screen.getAllByRole("listitem"); + expect(steps).toHaveLength(6); + expect(steps[0]).toHaveTextContent( + "1. Create an App Registration. Copy its Directory (tenant) ID and Application (client) ID.", + ); + expect(steps[1]).toHaveTextContent( + "2. Generate the certificate bundle and upload the public prowler-cert.cer file under Certificates & secrets. The private bundle is filled below and is submitted to Prowler only when you connect.", + ); + expect(steps[2]).toHaveTextContent( + "3. Add Microsoft Graph application permissions: AuditLog.Read.All, Directory.Read.All (or Domain.Read.All), and Policy.Read.All. Then grant admin consent.", + ); + expect(steps[3]).toHaveTextContent( + "4. Copy the Service Principal Object ID from Enterprise applications.", + ); + expect(steps[4]).toHaveTextContent( + "5. Deploy subscription RBAC with that Service Principal Object ID. The template creates ProwlerRole and assigns Reader and ProwlerRole; it does not create Entra resources.", + ); + expect(steps[5]).toHaveTextContent( + "6. Return to Prowler and connect. Paste the Tenant ID and Application Client ID below; the generated certificate bundle is already filled.", + ); + expect( + screen.getByRole("textbox", { + name: "Certificate and Private Key Bundle (Base64)", + }), + ).toHaveAttribute( + "placeholder", + "Auto-filled by 'Generate certificate', or paste your own", ); - expect(docsLink).toHaveAttribute("target", "_blank"); - expect(docsLink).toHaveAttribute("rel", "noopener noreferrer"); }); }); diff --git a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx index d822a42a4b..aaecac6d2b 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/azure/credentials-type/azure-certificate-credentials-form.tsx @@ -1,5 +1,6 @@ "use client"; +import { ExternalLink } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { Control, useFormContext } from "react-hook-form"; @@ -13,7 +14,10 @@ import { downloadPublicCertificateFile, generateProwlerCertificate, } from "@/lib/azure-cert-generator"; -import { getAzureDeploymentQuickLink } from "@/lib/external-urls"; +import { + getAzureDeploymentQuickLink, + PROWLER_AZURE_ARM_TEMPLATE_URL, +} from "@/lib/external-urls"; import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; import { AzureCertificateCredentials } from "@/types"; @@ -32,7 +36,6 @@ export const AzureCertificateCredentialsForm = ({ control: Control; }) => { const deployToAzureUrl = getAzureDeploymentQuickLink(); - const [isGeneratingCert, setIsGeneratingCert] = useState(false); const [generatorError, setGeneratorError] = useState(null); const [generatedThumbprint, setGeneratedThumbprint] = useState( @@ -70,15 +73,16 @@ export const AzureCertificateCredentialsForm = ({ Certificate Authentication (Recommended)
- Requires Application Administrator (Entra ID) and{" "} - Owner (subscription).{" "} + Requires permission to create App Registrations and grant Microsoft + Graph admin consent, plus Owner on the subscription.{" "} - Full guide + + Full guide .
@@ -87,7 +91,8 @@ export const AzureCertificateCredentialsForm = ({
  1. - 1. Create the App Registration in Azure. + 1. Create an App Registration. Copy its Directory + (tenant) ID and Application (client) ID.
  2. @@ -103,9 +109,10 @@ export const AzureCertificateCredentialsForm = ({
  3. - 2. Generate a certificate and upload{" "} - prowler-cert.cer to your App Reg ( - Certificates & secrets). + 2. Generate the certificate bundle and upload the + public prowler-cert.cer file under Certificates & secrets. The + private bundle is filled below and is submitted to Prowler only + when you connect.
    {generatorError && ( @@ -122,39 +129,72 @@ export const AzureCertificateCredentialsForm = ({ )} {generatedThumbprint && (

    - Downloaded prowler-cert.cer · thumbprint{" "} + Downloaded prowler-cert.cer. Thumbprint{" "} {generatedThumbprint}

    )}
  4. +
  5. + 3. Add Microsoft Graph application permissions: + AuditLog.Read.All, Directory.Read.All (or Domain.Read.All), and + Policy.Read.All. Then grant admin consent. +
  6. +
  7. - 3. Grant read access (needs the SP Object ID from{" "} + 4. Copy the Service Principal Object ID from{" "} - Enterprise applications + + Enterprise applications - ). + . - +
  8. + +
  9. +
    + + 5. Deploy subscription RBAC with that Service + Principal Object ID. The template creates ProwlerRole and assigns + Reader and ProwlerRole; it does not create Entra resources. + + +
    +

    + To deploy the ARM JSON manually, use Azure Portal → Build your own + template in the editor.{" "} + +

  10. - 4. Paste the IDs below (Tenant ID and Application - (client) ID from your App Reg's Overview). + 6. Return to Prowler and connect. Paste the Tenant ID + and Application Client ID below; the generated certificate bundle is + already filled.
@@ -181,9 +221,9 @@ export const AzureCertificateCredentialsForm = ({ { }); describe("getAzureDeploymentQuickLink", () => { - it("pins the Bicep template to the public S3 bucket that hosts the Prowler templates", () => { - // Snapshot check: fixes the exact template URL so a stray edit to the - // bucket, path, or filename trips a failing test instead of silently - // pointing the Deploy to Azure button at a 404. Kept in sync with the - // Bicep file at `permissions/templates/azure/bicep/prowler-scan.json` - // and its README, plus the actual object hosted in S3. - expect(PROWLER_AZURE_BICEP_TEMPLATE_URL).toBe( - "https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json", - ); - }); - - it("routes through the Azure Portal custom-deployment URL fragment with the template URL percent-encoded exactly once", () => { - // The Portal SPA re-parses the hash fragment and URL-decodes the - // `uri/` segment once, so double-encoding would land the user on - // a broken page. Asserting on the raw substring guards against a future - // refactor that swaps `encodeURIComponent` for `URLSearchParams`, which - // would encode differently (e.g. spaces as `+`). + it("uses the public Mintlify documentation asset", () => { + // Given / When const url = getAzureDeploymentQuickLink(); + // Then + expect(PROWLER_AZURE_ARM_TEMPLATE_URL).toBe( + "https://docs.prowler.com/assets/templates/azure/prowler-scan.json", + ); expect(url).toBe( - `https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent( - PROWLER_AZURE_BICEP_TEMPLATE_URL, - )}`, - ); - expect(url).toContain( - "https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json", - ); - }); - - it("accepts a custom template URL so operators can point the button at a private mirror without editing the constant", () => { - // Self-hosted or air-gapped installs occasionally re-host the template - // on their own bucket for compliance reasons. The helper accepts an - // override so those callers don't have to reimplement the fragment - // encoding themselves. - const customUrl = - "https://example.internal/templates/prowler-scan.json?v=2"; - const url = getAzureDeploymentQuickLink(customUrl); - - expect(url).toBe( - `https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent( - customUrl, - )}`, - ); - // The `?` inside the query-string of the custom URL must be encoded so - // the Portal parser reads the entire value as the template URL instead - // of truncating at the first `?`. - expect(url).toContain( - "uri/https%3A%2F%2Fexample.internal%2Ftemplates%2Fprowler-scan.json%3Fv%3D2", + "https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json", ); + expect(url).not.toContain("localhost"); + expect(url).not.toContain("prowler-cloud-public.s3"); + expect(decodeURIComponent(url)).not.toContain("/uri//templates/azure/"); }); }); diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index fde3d38bc9..268fcbf8c8 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -40,24 +40,8 @@ export const getAttackPathHubUrl = (queryId: string): string => export const PROWLER_CF_TEMPLATE_URL = "https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml"; -// ARM JSON template URL for the Prowler quick-start Azure deployment. The -// template only grants a pre-existing App Registration the read-only -// subscription roles Prowler needs (built-in `Reader` + custom -// `ProwlerRole`); the App Registration itself and its certificate credential -// are created through the Portal UI in earlier wizard steps, because -// `Microsoft.Graph/*` Bicep resources are not deployable via the Portal -// `Deploy to Azure` URL flow (`msgraph-bicep-types` issue #294, closed as a -// documented Microsoft limitation). -// -// Authored in Bicep at `permissions/templates/azure/bicep/prowler-scan.bicep` -// and compiled to ARM JSON at `prowler-scan.json` — the Azure Portal -// `#create/Microsoft.Template` deep link only accepts ARM JSON, not raw -// Bicep source. Regenerate with `make -C permissions/templates/azure/bicep -// build` after editing the .bicep. URL-encoded and appended to the Portal -// deep link built by `getAzureDeploymentQuickLink` below. Points at the same -// public S3 bucket that hosts the AWS templates. -export const PROWLER_AZURE_BICEP_TEMPLATE_URL = - "https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json"; +export const PROWLER_AZURE_ARM_TEMPLATE_URL = + "https://docs.prowler.com/assets/templates/azure/prowler-scan.json"; // Prowler Cloud billing/subscription management page. export const BILLING_URL = "https://cloud.prowler.com/billing"; @@ -421,19 +405,7 @@ export const getAWSOrgDeploymentQuickLink = ({ return buildCloudFormationQuickCreateLink(parameters); }; -// Builds the Azure Portal deep link that opens the "Custom deployment" wizard -// pre-loaded with the Prowler Bicep template. The Portal expects -// `#create/Microsoft.Template/uri/` — the URL -// fragment is re-parsed by the Portal SPA which URL-decodes the segment -// once, so the template URL must be percent-encoded exactly once via -// `encodeURIComponent`. This is the Azure analogue of the AWS CloudFormation -// quick-create link built by `getAWSOrgDeploymentQuickLink` above. -// -// The resulting URL is safe to open in a new tab; the user is expected to be -// signed into the Azure Portal already. If they are not, the Portal will -// bounce them through the standard sign-in flow before landing on the -// deployment form with the template still pre-loaded. -export const getAzureDeploymentQuickLink = ( - templateUrl: string = PROWLER_AZURE_BICEP_TEMPLATE_URL, -): string => - `https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(templateUrl)}`; +export const getAzureDeploymentQuickLink = (): string => + `https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent( + PROWLER_AZURE_ARM_TEMPLATE_URL, + )}`; diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 10806b9b0e..38ce3ab1ea 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -139,7 +139,8 @@ type AZURECredentialType = export interface AZUREProviderCredential { type: AZURECredentialType; clientId: string; - clientSecret: string; + clientSecret?: string; + certificateContent?: string; tenantId: string; } @@ -343,6 +344,7 @@ export class ProvidersPage extends BasePage { readonly azureSubscriptionIdInput: Locator; readonly azureClientIdInput: Locator; readonly azureClientSecretInput: Locator; + readonly azureCertificateContentInput: Locator; readonly azureTenantIdInput: Locator; // M365 provider form elements @@ -465,6 +467,9 @@ export class ProvidersPage extends BasePage { this.azureClientSecretInput = page.getByRole("textbox", { name: "Client Secret", }); + this.azureCertificateContentInput = page.getByRole("textbox", { + name: "Certificate and Private Key Bundle (Base64)", + }); this.azureTenantIdInput = page.getByRole("textbox", { name: "Tenant ID" }); // M365 provider form inputs @@ -1236,6 +1241,18 @@ export class ProvidersPage extends BasePage { } } + async fillAzureCertificateCredentials( + credentials: AZUREProviderCredential, + ): Promise { + await this.azureClientIdInput.fill(credentials.clientId); + await this.azureTenantIdInput.fill(credentials.tenantId); + if (credentials.certificateContent) { + await this.azureCertificateContentInput.fill( + credentials.certificateContent, + ); + } + } + async fillM365Credentials( credentials: M365ProviderCredential, ): Promise { @@ -1613,6 +1630,35 @@ export class ProvidersPage extends BasePage { await expect(this.m365TenantIdInput).toBeVisible(); } + async verifyAzureCertificateCredentialsPageLoaded(): Promise { + await this.verifyPageHasProwlerTitle(); + await expect(this.wizardModal.locator("ol > li")).toHaveCount(6); + await expect( + this.page.getByRole("link", { + name: "Deploy to Azure", + exact: true, + }), + ).toHaveAttribute( + "href", + "https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fdocs.prowler.com%2Fassets%2Ftemplates%2Fazure%2Fprowler-scan.json", + ); + await expect( + this.page.getByRole("link", { + name: "Open template", + exact: true, + }), + ).toHaveAttribute( + "href", + "https://docs.prowler.com/assets/templates/azure/prowler-scan.json", + ); + await expect( + this.page.getByRole("button", { name: "Generate certificate" }), + ).toBeVisible(); + await expect(this.azureClientIdInput).toBeVisible(); + await expect(this.azureTenantIdInput).toBeVisible(); + await expect(this.azureCertificateContentInput).toBeVisible(); + } + async verifyM365CertificateCredentialsPageLoaded(): Promise { // Verify the M365 certificate credentials page is loaded diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index a2914c2cde..7ad3074ac9 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -174,6 +174,51 @@ --- +## Test Case: `PROVIDER-E2E-020` - Add Azure Provider with Certificate Credentials + +**Priority:** `critical` + +**Tags:** + +- type → @e2e, @serial +- feature → @providers +- provider → @azure + +**Description/Objective:** Validates the complete flow of adding an Azure provider with an existing App Registration certificate bundle. + +**Preconditions:** + +- Admin user authentication required +- Environment variables configured: E2E_AZURE_SUBSCRIPTION_ID, E2E_AZURE_CLIENT_ID, E2E_AZURE_TENANT_ID, E2E_AZURE_CERTIFICATE_CONTENT +- The public certificate matching the bundle is uploaded to the App Registration +- Reader and ProwlerRole are assigned on the target subscription + +### Flow Steps + +1. Navigate to the Providers page and open the add-provider wizard +2. Select Azure and enter the subscription details +3. Select Certificate Authentication +4. Verify the six-step onboarding guide, Deploy to Azure link, manual template link, and certificate generation are available +5. Enter the Tenant ID, Client ID, and base64-encoded certificate bundle +6. Confirm the provider connection without launching a scan +7. Verify the provider appears in the Providers table + +### Expected Result + +- Azure provider connects with certificate credentials +- Provider appears in the Providers table with the expected subscription ID + +### Key Verification Points + +- The certificate authentication form displays all six onboarding steps +- Deploy to Azure loads the ARM template from the Mintlify documentation asset +- Open template links directly to the Mintlify-hosted ARM JSON +- Certificate generation remains available in the authentication form +- The certificate and private key bundle is submitted through the certificate field +- Provider connection succeeds without a client secret + +--- + ## Test Case: `PROVIDER-E2E-004` - Add M365 Provider with Static Credentials **Priority:** `critical` diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 6f7ebf5789..d2ffffde12 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -349,12 +349,13 @@ test.describe("Add Provider", () => { const subscriptionId = process.env.E2E_AZURE_SUBSCRIPTION_ID ?? ""; const clientId = process.env.E2E_AZURE_CLIENT_ID ?? ""; const clientSecret = process.env.E2E_AZURE_SECRET_ID ?? ""; + const certificateContent = process.env.E2E_AZURE_CERTIFICATE_CONTENT ?? ""; const tenantId = process.env.E2E_AZURE_TENANT_ID ?? ""; // Setup before each test test.beforeEach(async ({ page }) => { test.skip( - !subscriptionId || !clientId || !clientSecret || !tenantId, + !subscriptionId || !clientId || !tenantId, "Azure E2E env vars are not set", ); providersPage = new ProvidersPage(page); @@ -377,6 +378,8 @@ test.describe("Add Provider", () => { ], }, async ({ page }) => { + test.skip(!clientSecret, "E2E_AZURE_SECRET_ID is not set"); + // Prepare test data for AZURE provider const azureProviderData: AZUREProviderData = { subscriptionId: subscriptionId, @@ -420,6 +423,52 @@ test.describe("Add Provider", () => { ); }, ); + + test( + "should add a new Azure provider with certificate credentials", + { + tag: [ + "@critical", + "@e2e", + "@providers", + "@azure", + "@serial", + "@PROVIDER-E2E-020", + ], + }, + async ({ page }) => { + test.skip( + !certificateContent, + "E2E_AZURE_CERTIFICATE_CONTENT is not set", + ); + + const azureProviderData: AZUREProviderData = { + subscriptionId, + alias: "Test E2E Azure Account - Certificate", + }; + const azureCredentials: AZUREProviderCredential = { + type: AZURE_CREDENTIAL_OPTIONS.AZURE_CERTIFICATE_CREDENTIALS, + clientId, + certificateContent, + tenantId, + }; + + await providersPage.goto(); + await providersPage.verifyPageLoaded(); + await providersPage.clickAddProvider(); + await providersPage.verifyConnectAccountPageLoaded(); + await providersPage.selectAZUREProvider(); + await providersPage.fillAZUREProviderDetails(azureProviderData); + await providersPage.clickNext(); + await providersPage.selectAzureCredentialsType(azureCredentials.type); + await providersPage.verifyAzureCertificateCredentialsPageLoaded(); + await providersPage.fillAzureCertificateCredentials(azureCredentials); + await providersPage.clickNext(); + await providersPage.completeProviderConnectionWithoutLaunchingScan( + subscriptionId, + ); + }, + ); }); test.describe.serial("Add M365 Provider", () => { diff --git a/ui/types/components.ts b/ui/types/components.ts index ea02d727cf..eda6de2eb9 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -434,6 +434,7 @@ export interface InvitationProps { manage_integrations?: boolean; manage_scans?: boolean; manage_alerts?: boolean; + manage_lighthouse_ai_configuration?: boolean; permission_state?: PermissionState; }; }; @@ -459,6 +460,7 @@ export interface Role { manage_integrations: boolean; manage_scans: boolean; manage_alerts?: boolean; + manage_lighthouse_ai_configuration?: boolean; unlimited_visibility: boolean; permission_state: PermissionState; inserted_at: string; diff --git a/ui/types/formSchemas.test.ts b/ui/types/formSchemas.test.ts index c482901d53..8990832e62 100644 --- a/ui/types/formSchemas.test.ts +++ b/ui/types/formSchemas.test.ts @@ -54,6 +54,79 @@ describe("addCredentialsRoleFormSchema", () => { }); }); +describe("addCredentialsFormSchema - azure certificate", () => { + const BASE_AZURE_VALUES = { + [ProviderCredentialFields.PROVIDER_ID]: "provider-azure-1", + [ProviderCredentialFields.PROVIDER_TYPE]: "azure", + [ProviderCredentialFields.TENANT_ID]: + "12345678-1234-1234-1234-123456789012", + [ProviderCredentialFields.CLIENT_ID]: + "87654321-4321-4321-4321-210987654321", + [ProviderCredentialFields.CERTIFICATE_CONTENT]: "Y2VydGlmaWNhdGU=", + } as const; + + it("rejects malformed tenant and client UUIDs", () => { + // Given + const schema = addCredentialsFormSchema("azure", "app_certificate"); + + // When + const result = schema.safeParse({ + ...BASE_AZURE_VALUES, + [ProviderCredentialFields.TENANT_ID]: "not-a-uuid", + [ProviderCredentialFields.CLIENT_ID]: "also-not-a-uuid", + }); + + // Then + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: [ProviderCredentialFields.TENANT_ID], + }), + expect.objectContaining({ + path: [ProviderCredentialFields.CLIENT_ID], + }), + ]), + ); + }); + + it("rejects malformed base64 certificate content", () => { + // Given + const schema = addCredentialsFormSchema("azure", "app_certificate"); + + // When + const result = schema.safeParse({ + ...BASE_AZURE_VALUES, + [ProviderCredentialFields.CERTIFICATE_CONTENT]: "not!base64", + }); + + // Then + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + message: "Certificate and Private Key Bundle must be valid base64", + path: [ProviderCredentialFields.CERTIFICATE_CONTENT], + }), + ); + }); + + it("rejects simultaneous client-secret and certificate credentials", () => { + // Given + const schema = addCredentialsFormSchema("azure", "app_certificate"); + + // When + const result = schema.safeParse({ + ...BASE_AZURE_VALUES, + [ProviderCredentialFields.CLIENT_SECRET]: "fake-client-secret", + }); + + // Then + expect(result.success).toBe(false); + }); +}); + describe("addProviderFormSchema - okta", () => { const validUidFixtures = [ "acme.okta.com", diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index e6ae58ee8c..0c202a2280 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -14,6 +14,23 @@ const isRecord = (value: unknown): value is Record => { return typeof value === "object" && value !== null && !Array.isArray(value); }; +const isValidBase64 = (value: string): boolean => { + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + value, + ) + ) { + return false; + } + + try { + atob(value); + return true; + } catch { + return false; + } +}; + export const kubeconfigContainsUnsupportedCommandAuthentication = ( value: string, ): boolean => { @@ -55,6 +72,7 @@ export const roleFormSchema = z.object({ manage_integrations: z.boolean().default(false), manage_scans: z.boolean().default(false), manage_alerts: z.boolean().default(false), + manage_lighthouse_ai_configuration: z.boolean().default(false), unlimited_visibility: z.boolean().default(false), groups: z.array(z.string()).optional(), }); @@ -217,16 +235,16 @@ export const addCredentialsFormSchema = ( // schema keeps both optional so switching methods without a // page reload does not trigger a stale "required" error on the // field that is not shown. - [ProviderCredentialFields.CLIENT_ID]: z - .string() - .min(1, "Client ID is required"), + [ProviderCredentialFields.CLIENT_ID]: z.guid({ + error: "Client ID must be a valid GUID", + }), [ProviderCredentialFields.CLIENT_SECRET]: z.string().optional(), [ProviderCredentialFields.CERTIFICATE_CONTENT]: z .string() .optional(), - [ProviderCredentialFields.TENANT_ID]: z - .string() - .min(1, "Tenant ID is required"), + [ProviderCredentialFields.TENANT_ID]: z.guid({ + error: "Tenant ID must be a valid GUID", + }), } : providerType === "gcp" ? { @@ -455,10 +473,21 @@ export const addCredentialsFormSchema = ( // form is shown). The visible field for the chosen `via` is what // the user must fill — enforce it here so the client catches empty // submissions before hitting the API, mirroring M365. Error copy is - // aligned with the visible field labels ("Certificate Private Key" - // vs. the technical `certificate_content` field name). + // aligned with the visible field label rather than the technical + // `certificate_content` field name. + const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET]; + const certificateContent = + data[ProviderCredentialFields.CERTIFICATE_CONTENT]; + if (clientSecret?.trim() && certificateContent?.trim()) { + ctx.addIssue({ + code: "custom", + message: + "Use either a Client Secret or Certificate and Private Key Bundle, not both", + path: [ProviderCredentialFields.CERTIFICATE_CONTENT], + }); + } + if (via === "app_client_secret") { - const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET]; if (!clientSecret || clientSecret.trim() === "") { ctx.addIssue({ code: "custom", @@ -467,12 +496,17 @@ export const addCredentialsFormSchema = ( }); } } else if (via === "app_certificate") { - const certificateContent = - data[ProviderCredentialFields.CERTIFICATE_CONTENT]; if (!certificateContent || certificateContent.trim() === "") { ctx.addIssue({ code: "custom", - message: "Certificate Private Key is required", + message: "Certificate and Private Key Bundle is required", + path: [ProviderCredentialFields.CERTIFICATE_CONTENT], + }); + } else if (!isValidBase64(certificateContent)) { + ctx.addIssue({ + code: "custom", + message: + "Certificate and Private Key Bundle must be valid base64", path: [ProviderCredentialFields.CERTIFICATE_CONTENT], }); }