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
This commit is contained in:
Hugo P.Brito committed 2026-08-21 09:45:48 +01:00
1 parent c230fdf9ce
commit b1230d7cc9
14 files changed
+521 -157

No files matched your search

@@ -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
@@ -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<AzureCertificateCredentials>();
return (
<FormProvider {...form}>
<AzureCertificateCredentialsForm control={form.control} />
</FormProvider>
);
};
describe("AzureCertificateCredentialsForm browser flow", () => {
it("renders deployment guidance and keeps certificate generation functional", async () => {
// When
const view = await render(<Harness />);
// 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);
});
});
@@ -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<AzureCertificateCredentials>();
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(<Harness />);
// 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(<Harness />);
// 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(<Harness />);
// 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(<Harness />);
// 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");
});
});
@@ -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<AzureCertificateCredentials>;
}) => {
const deployToAzureUrl = getAzureDeploymentQuickLink();
const [isGeneratingCert, setIsGeneratingCert] = useState(false);
const [generatorError, setGeneratorError] = useState<string | null>(null);
const [generatedThumbprint, setGeneratedThumbprint] = useState<string | null>(
@@ -70,15 +73,16 @@ export const AzureCertificateCredentialsForm = ({
Certificate Authentication (Recommended)
</div>
<div className="text-text-neutral-tertiary text-xs">
Requires <strong>Application Administrator</strong> (Entra ID) and{" "}
<strong>Owner</strong> (subscription).{" "}
Requires permission to create App Registrations and grant Microsoft
Graph admin consent, plus <strong>Owner</strong> on the subscription.{" "}
<Link
href={DOCS_CERT_GENERATION_URL}
target="_blank"
rel="noopener noreferrer"
className="text-button-tertiary"
className="text-button-tertiary inline-flex items-center gap-2"
>
Full guide
<ExternalLink className="size-3.5 shrink-0" />
<span>Full guide</span>
</Link>
.
</div>
@@ -87,7 +91,8 @@ export const AzureCertificateCredentialsForm = ({
<ol className="border-content-neutral-tertiary flex flex-col gap-3 rounded-md border p-4 text-sm">
<li className="flex items-center justify-between gap-3">
<span>
<strong>1.</strong> Create the App Registration in Azure.
<strong>1.</strong> Create an App Registration. Copy its Directory
(tenant) ID and Application (client) ID.
</span>
<Button variant="link" size="link-sm" asChild>
<a
@@ -95,7 +100,8 @@ export const AzureCertificateCredentialsForm = ({
target="_blank"
rel="noopener noreferrer"
>
New App Reg ↗
<ExternalLink className="size-3.5 shrink-0" />
<span>New App Registration</span>
</a>
</Button>
</li>
@@ -103,9 +109,10 @@ export const AzureCertificateCredentialsForm = ({
<li className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span>
<strong>2.</strong> Generate a certificate and upload{" "}
<code>prowler-cert.cer</code> to your App Reg (
<em>Certificates &amp; secrets</em>).
<strong>2.</strong> Generate the certificate bundle and upload the
public prowler-cert.cer file under Certificates &amp; secrets. The
private bundle is filled below and is submitted to Prowler only
when you connect.
</span>
<Button
type="button"
@@ -114,7 +121,7 @@ export const AzureCertificateCredentialsForm = ({
onClick={handleGenerateCertificate}
disabled={isGeneratingCert}
>
{isGeneratingCert ? "Generating…" : "Generate cert"}
{isGeneratingCert ? "Generating..." : "Generate certificate"}
</Button>
</div>
{generatorError && (
@@ -122,39 +129,72 @@ export const AzureCertificateCredentialsForm = ({
)}
{generatedThumbprint && (
<p className="text-text-success-primary text-xs">
Downloaded <code>prowler-cert.cer</code> · thumbprint{" "}
Downloaded <code>prowler-cert.cer</code>. Thumbprint{" "}
<code>{generatedThumbprint}</code>
</p>
)}
</li>
<li>
<strong>3.</strong> Add Microsoft Graph application permissions:
AuditLog.Read.All, Directory.Read.All (or Domain.Read.All), and
Policy.Read.All. Then grant admin consent.
</li>
<li className="flex items-center justify-between gap-3">
<span>
<strong>3.</strong> Grant read access (needs the SP Object ID from{" "}
<strong>4.</strong> Copy the Service Principal Object ID from{" "}
<Link
href={AZURE_PORTAL_ENTERPRISE_APPLICATIONS_URL}
target="_blank"
rel="noopener noreferrer"
className="text-button-tertiary"
className="text-button-tertiary inline-flex items-center gap-2"
>
Enterprise applications
<ExternalLink className="size-3.5 shrink-0" />
<span>Enterprise applications</span>
</Link>
).
.
</span>
<Button variant="link" size="link-sm" asChild>
<a
href={deployToAzureUrl}
target="_blank"
rel="noopener noreferrer"
>
Deploy to Azure ↗
</a>
</Button>
</li>
<li className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span>
<strong>5.</strong> Deploy subscription RBAC with that Service
Principal Object ID. The template creates ProwlerRole and assigns
Reader and ProwlerRole; it does not create Entra resources.
</span>
<Button variant="link" size="link-sm" asChild>
<a
href={deployToAzureUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-3.5 shrink-0" />
<span>Deploy to Azure</span>
</a>
</Button>
</div>
<p className="text-text-neutral-tertiary text-xs">
To deploy the ARM JSON manually, use Azure Portal → Build your own
template in the editor.{" "}
<Button variant="link" size="link-sm" asChild>
<a
href={PROWLER_AZURE_ARM_TEMPLATE_URL}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-3.5 shrink-0" />
<span>Open template</span>
</a>
</Button>
</p>
</li>
<li>
<strong>4.</strong> Paste the IDs below (Tenant ID and Application
(client) ID from your App Reg&apos;s Overview).
<strong>6.</strong> Return to Prowler and connect. Paste the Tenant ID
and Application Client ID below; the generated certificate bundle is
already filled.
</li>
</ol>
@@ -181,9 +221,9 @@ export const AzureCertificateCredentialsForm = ({
<WizardTextareaField
control={control}
name="certificate_content"
label="Certificate Private Key (Base64)"
label="Certificate and Private Key Bundle (Base64)"
labelPlacement="inside"
placeholder="Auto-filled by 'Generate cert', or paste your own"
placeholder="Auto-filled by 'Generate certificate', or paste your own"
variant="bordered"
isRequired
minRows={4}
+18 -2
View File
@@ -111,6 +111,14 @@
"strategy": "installed",
"generatedAt": "2026-06-15T07:49:41.143Z"
},
{
"section": "dependencies",
"name": "@peculiar/x509",
"from": "2.0.0",
"to": "2.0.0",
"strategy": "installed",
"generatedAt": "2026-08-19T10:43:07.173Z"
},
{
"section": "dependencies",
"name": "@radix-ui/react-alert-dialog",
@@ -575,6 +583,14 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "reflect-metadata",
"from": "0.2.2",
"to": "0.2.2",
"strategy": "installed",
"generatedAt": "2026-08-19T10:43:07.173Z"
},
{
"section": "dependencies",
"name": "require-in-the-middle",
@@ -595,9 +611,9 @@
"section": "dependencies",
"name": "sharp",
"from": "0.33.5",
"to": "0.33.5",
"to": "0.35.3",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
"generatedAt": "2026-08-11T11:35:35.609Z"
},
{
"section": "dependencies",
+2 -3
View File
@@ -27,9 +27,8 @@ import {
export interface GeneratedProwlerCertificate {
/**
* Base64 of the DER-encoded X.509 public certificate. Feed this into the
* `certificateBase64` parameter of the Prowler Bicep quick-start template
* (or paste directly into the Azure Portal "Certificate Base64" field).
* Base64 of the DER-encoded X.509 public certificate. The download helper
* converts this value into the `.cer` file uploaded to the App Registration.
*/
publicCertificateBase64Der: string;
/**
+11 -46
View File
@@ -13,7 +13,7 @@ import {
getAzureDeploymentQuickLink,
PRECONFIGURED_CREDENTIAL_URLS,
getProviderHelpText,
PROWLER_AZURE_BICEP_TEMPLATE_URL,
PROWLER_AZURE_ARM_TEMPLATE_URL,
PROWLER_CF_TEMPLATE_URL,
} from "./external-urls";
@@ -288,55 +288,20 @@ describe("buildGitHubPersonalAccessTokenOrgUrl", () => {
});
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/<value>` 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/");
});
});
+6 -34
View File
@@ -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/<url-encoded-template-url>` — 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,
)}`;
+47 -1
View File
@@ -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<void> {
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<void> {
@@ -1613,6 +1630,35 @@ export class ProvidersPage extends BasePage {
await expect(this.m365TenantIdInput).toBeVisible();
}
async verifyAzureCertificateCredentialsPageLoaded(): Promise<void> {
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<void> {
// Verify the M365 certificate credentials page is loaded
+45
View File
@@ -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`
+50 -1
View File
@@ -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", () => {
+2
View File
@@ -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;
+73
View File
@@ -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",
+46 -12
View File
@@ -14,6 +14,23 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
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],
});
}