fix(ui): harden AWS onboarding deployment flows

This commit is contained in:
alejandrobailo committed 2026-07-16 10:04:00 +02:00
1 parent 89013c561f
commit 0df667264b
7 files changed
+644 -119

No files matched your search

@@ -0,0 +1,244 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationProps } from "@/types/integrations";
import type { ProviderProps } from "@/types/providers";
import { S3IntegrationForm } from "./s3-integration-form";
const { createIntegrationMock, toastMock, updateIntegrationMock } = vi.hoisted(
() => ({
createIntegrationMock: vi.fn(),
toastMock: vi.fn(),
updateIntegrationMock: vi.fn(),
}),
);
vi.mock("@/actions/integrations", () => ({
createIntegration: createIntegrationMock,
updateIntegration: updateIntegrationMock,
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: {
tenantId: "tenant-id",
},
}),
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({
toast: toastMock,
}),
}));
interface MockEnhancedMultiSelectProps {
onValueChange: (values: string[]) => void;
options: Array<{ value: string }>;
}
vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({
EnhancedMultiSelect: ({
onValueChange,
options,
}: MockEnhancedMultiSelectProps) => (
<button
type="button"
onClick={() => onValueChange(options[0] ? [options[0].value] : [])}
>
Select first provider
</button>
),
}));
vi.mock(
"@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form",
() => ({
AWSRoleCredentialsForm: ({
templateLinks,
}: {
templateLinks: { cloudformationQuickLink: string };
}) => (
<output aria-label="CloudFormation quick link">
{templateLinks.cloudformationQuickLink}
</output>
),
}),
);
vi.mock("@/lib", () => ({
getAWSCredentialsTemplateLinks: (
_externalId: string,
_bucketName: string,
_integrationType: string,
bucketAccountId?: string,
) => ({
cloudformation: "https://example.com/cloudformation",
terraform: "https://example.com/terraform",
cloudformationQuickLink: `https://example.com/quick-create?bucketAccountId=${bucketAccountId ?? ""}`,
}),
}));
function createProvider(
provider: ProviderProps["attributes"]["provider"],
uid: string,
): ProviderProps {
return {
id: `${provider}-provider`,
type: "providers",
attributes: {
provider,
is_dynamic: false,
uid,
alias: `${provider} provider`,
status: "completed",
resources: 0,
connection: {
connected: true,
last_checked_at: "2026-07-16T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-07-16T00:00:00Z",
updated_at: "2026-07-16T00:00:00Z",
created_by: {
object: "users",
id: "user-1",
},
},
relationships: {
secret: {
data: null,
},
provider_groups: {
meta: {
count: 0,
},
data: [],
},
},
};
}
function renderS3IntegrationForm(
props?: Partial<ComponentProps<typeof S3IntegrationForm>>,
) {
return render(
<S3IntegrationForm
providers={[]}
onSuccess={vi.fn()}
onCancel={vi.fn()}
{...props}
/>,
);
}
const integration: IntegrationProps = {
type: "integrations",
id: "integration-1",
attributes: {
inserted_at: "2026-07-16T00:00:00Z",
updated_at: "2026-07-16T00:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: "2026-07-16T00:00:00Z",
integration_type: "amazon_s3",
configuration: {
bucket_name: "prowler-reports",
output_directory: "output",
},
},
relationships: {
providers: {
data: [{ type: "providers", id: "aws-provider" }],
},
},
links: {
self: "/integrations/integration-1",
},
};
describe("S3IntegrationForm", () => {
beforeEach(() => {
createIntegrationMock.mockReset();
toastMock.mockReset();
updateIntegrationMock.mockReset();
});
it("should require the bucket owner account ID when it cannot derive one", async () => {
// Given
const user = userEvent.setup();
renderS3IntegrationForm({
providers: [createProvider("azure", "subscription-id")],
});
// When
await user.type(screen.getByLabelText(/Bucket name/i), "prowler-reports");
await user.click(screen.getByRole("button", { name: "Next" }));
// Then
expect(
await screen.findByText(
"Bucket owner account ID is required when no AWS account is selected",
),
).toBeVisible();
expect(
screen.queryByLabelText("CloudFormation quick link"),
).not.toBeInTheDocument();
});
it("should derive the bucket owner account ID from the selected AWS provider", async () => {
// Given
const user = userEvent.setup();
renderS3IntegrationForm({
providers: [createProvider("aws", "123456789012")],
});
// When
await user.click(
screen.getByRole("button", { name: "Select first provider" }),
);
await user.type(screen.getByLabelText(/Bucket name/i), "prowler-reports");
await user.click(screen.getByRole("button", { name: "Next" }));
// Then
expect(
await screen.findByLabelText("CloudFormation quick link"),
).toHaveTextContent("bucketAccountId=123456789012");
});
it("should not show a bucket account field that configuration updates cannot persist", () => {
// When
renderS3IntegrationForm({
integration,
providers: [createProvider("aws", "123456789012")],
editMode: "configuration",
});
// Then
expect(
screen.queryByLabelText(/Bucket owner account ID/i),
).not.toBeInTheDocument();
});
it("should allow changing the bucket owner account for credential updates", () => {
// When
renderS3IntegrationForm({
integration,
providers: [createProvider("aws", "123456789012")],
editMode: "credentials",
});
// Then
expect(
screen.getByLabelText(/Bucket owner account ID/i),
).toBeInTheDocument();
});
});
@@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import type { Control } from "react-hook-form";
import { useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import {
@@ -25,13 +26,13 @@ import {
import { FormButtons } from "@/components/shadcn/form/form-buttons";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { getAWSCredentialsTemplateLinks } from "@/lib";
import { AWSCredentialsRole } from "@/types";
import type { AWSCredentialsRole } from "@/types";
import type { IntegrationProps } from "@/types/integrations";
import {
editS3IntegrationFormSchema,
IntegrationProps,
s3IntegrationFormSchema,
} from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
import type { ProviderProps } from "@/types/providers";
interface S3IntegrationFormProps {
integration?: IntegrationProps | null;
@@ -41,6 +42,25 @@ interface S3IntegrationFormProps {
editMode?: "configuration" | "credentials" | null; // null means creating new
}
const getSelectedAWSAccountId = (
selectedProviderIds: string[],
providers: ProviderProps[],
): string => {
for (const providerId of selectedProviderIds) {
const provider = providers.find(({ id }) => id === providerId);
const uid = provider?.attributes.uid;
if (
provider?.attributes.provider === "aws" &&
uid &&
/^\d{12}$/.test(uid)
) {
return uid;
}
}
return "";
};
export const S3IntegrationForm = ({
integration,
providers,
@@ -95,26 +115,14 @@ export const S3IntegrationForm = ({
});
const isLoading = form.formState.isSubmitting;
// Derives the AWS Account ID that owns the S3 bucket from the selected
// provider(s). For AWS providers the uid is the 12-digit account id. This is
// the common case (bucket lives in a scanned account); cross-account buckets
// can still be overridden via the "Bucket owner account ID" field.
const deriveBucketAccountId = (): string => {
const selectedIds = form.getValues("providers") || [];
for (const id of selectedIds) {
const provider = providers.find((p) => p.id === id);
const uid = provider?.attributes.uid;
if (
provider?.attributes.provider === "aws" &&
uid &&
/^\d{12}$/.test(uid)
) {
return uid;
}
}
return "";
};
const selectedProviderIds = form.watch("providers") || [];
const bucketAccountIdOverride = form.watch("bucket_account_id")?.trim() || "";
const derivedBucketAccountId = getSelectedAWSAccountId(
selectedProviderIds,
providers,
);
const resolvedBucketAccountId =
bucketAccountIdOverride || derivedBucketAccountId;
const handleNext = async (e: React.FormEvent) => {
e.preventDefault();
@@ -140,9 +148,20 @@ export const S3IntegrationForm = ({
const isValid = stepFields.length === 0 || (await form.trigger(stepFields));
if (isValid) {
setCurrentStep(1);
if (!isValid) {
return;
}
if (!resolvedBucketAccountId) {
form.setError("bucket_account_id", {
message:
"Bucket owner account ID is required when no AWS account is selected",
});
return;
}
form.clearErrors("bucket_account_id");
setCurrentStep(1);
};
const handleBack = () => {
@@ -283,30 +302,55 @@ export const S3IntegrationForm = ({
}
};
const renderBucketAccountIdField = () => (
<div className="flex flex-col gap-1">
<CustomInput
control={form.control}
name="bucket_account_id"
type="text"
label="Bucket owner account ID"
labelPlacement="inside"
placeholder={
derivedBucketAccountId
? `Defaults to ${derivedBucketAccountId}`
: "12-digit AWS account ID"
}
variant="bordered"
isRequired={false}
/>
<p className="text-text-neutral-tertiary text-xs">
{derivedBucketAccountId
? `Leave empty to use selected AWS account ${derivedBucketAccountId}, or enter another bucket owner account ID.`
: "Required because the selected provider does not identify the AWS account that owns the bucket."}
</p>
</div>
);
const renderStepContent = () => {
// If editing credentials, show only credentials form
if (isEditingCredentials || currentStep === 1) {
const bucketName = form.getValues("bucket_name") || "";
const bucketAccountId =
form.getValues("bucket_account_id") || deriveBucketAccountId();
const externalId =
form.getValues("external_id") || session?.tenantId || "";
const templateLinks = getAWSCredentialsTemplateLinks(
externalId,
bucketName,
"amazon_s3",
bucketAccountId,
resolvedBucketAccountId,
);
return (
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
type="integrations"
integrationType="amazon_s3"
/>
<div className="flex flex-col gap-4">
{isEditingCredentials && renderBucketAccountIdField()}
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={form.setValue as any}
externalId={externalId}
templateLinks={templateLinks}
type="integrations"
integrationType="amazon_s3"
/>
</div>
);
}
@@ -378,23 +422,7 @@ export const S3IntegrationForm = ({
isRequired
/>
<div className="flex flex-col gap-1">
<CustomInput
control={form.control}
name="bucket_account_id"
type="text"
label="Bucket owner account ID (optional)"
labelPlacement="inside"
placeholder="Defaults to the selected account"
variant="bordered"
isRequired={false}
/>
<p className="text-text-neutral-tertiary text-xs">
AWS account ID that owns the bucket. Leave empty to use the
selected account, or set it if the bucket lives in a different
account.
</p>
</div>
{!isEditingConfig && renderBucketAccountIdField()}
</div>
</>
);
@@ -0,0 +1,121 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useOrgSetupStore } from "@/store/organizations/store";
import { ORG_SETUP_PHASE } from "@/types/organizations";
import { OrgSetupForm } from "./org-setup-form";
const {
setApiErrorMock,
submitOrganizationSetupMock,
updateOrganizationNameMock,
} = vi.hoisted(() => ({
setApiErrorMock: vi.fn(),
submitOrganizationSetupMock: vi.fn(),
updateOrganizationNameMock: vi.fn(),
}));
vi.mock("@/actions/organizations/organizations", () => ({
updateOrganizationName: updateOrganizationNameMock,
}));
vi.mock("@/lib", () => ({
getAWSOrgDeploymentQuickLink: ({
deployFromDelegatedAdmin,
}: {
deployFromDelegatedAdmin?: boolean;
}) => {
const params = new URLSearchParams();
if (deployFromDelegatedAdmin) {
params.set("param_DeployFromDelegatedAdmin", "true");
}
return `https://console.aws.amazon.com/#/quick-create?${params.toString()}`;
},
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: {
tenantId: "tenant&id",
},
}),
}));
vi.mock("./hooks/use-org-setup-submission", () => ({
useOrgSetupSubmission: () => ({
apiError: null,
setApiError: setApiErrorMock,
submitOrganizationSetup: submitOrganizationSetupMock,
}),
}));
function renderOrgSetupForm() {
return render(
<OrgSetupForm
onBack={vi.fn()}
onNext={vi.fn()}
onFooterChange={vi.fn()}
onPhaseChange={vi.fn()}
initialPhase={ORG_SETUP_PHASE.ACCESS}
/>,
);
}
describe("OrgSetupForm", () => {
beforeEach(() => {
setApiErrorMock.mockReset();
submitOrganizationSetupMock.mockReset();
updateOrganizationNameMock.mockReset();
useOrgSetupStore.getState().reset();
});
it("should render a real disabled button until the deployment link is valid", () => {
// Given
renderOrgSetupForm();
// When
const deploymentLink = screen.queryByRole("link", {
name: /create stack in management account/i,
});
const deploymentButton = screen.getByRole("button", {
name: /create stack in management account/i,
});
// Then
expect(deploymentLink).not.toBeInTheDocument();
expect(deploymentButton).toBeDisabled();
});
it("should target the delegated administrator account when selected", async () => {
// Given
const user = userEvent.setup();
renderOrgSetupForm();
// When
await user.type(
screen.getByLabelText("Organizational Unit or Root ID"),
"r-abcd",
);
await user.click(
screen.getByRole("checkbox", {
name: /deploying from a delegated administrator account/i,
}),
);
// Then
const deploymentLink = await screen.findByRole("link", {
name: /create stack in delegated administrator account/i,
});
const hashQuery = new URL(
deploymentLink.getAttribute("href") ?? "",
).hash.split("?")[1];
const params = new URLSearchParams(hashQuery);
expect(params.get("param_DeployFromDelegatedAdmin")).toBe("true");
expect(
screen.getByLabelText("Delegated Administrator Account Role ARN"),
).toBeInTheDocument();
});
});
@@ -3,20 +3,17 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, Copy, ExternalLink } from "lucide-react";
import { useSession } from "next-auth/react";
import { FormEvent, useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { updateOrganizationName } from "@/actions/organizations/organizations";
import { AWSProviderBadge } from "@/components/icons/providers-badge";
import {
WIZARD_FOOTER_ACTION_TYPE,
WizardFooterConfig,
} from "@/components/providers/wizard/steps/footer-controls";
import {
ORG_WIZARD_INTENT,
OrgWizardIntent,
} from "@/components/providers/wizard/types";
import type { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls";
import { WIZARD_FOOTER_ACTION_TYPE } from "@/components/providers/wizard/steps/footer-controls";
import type { OrgWizardIntent } from "@/components/providers/wizard/types";
import { ORG_WIZARD_INTENT } from "@/components/providers/wizard/types";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
import { useToast } from "@/components/shadcn";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
@@ -26,7 +23,8 @@ import { Form } from "@/components/shadcn/form";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { getAWSOrgDeploymentQuickLink } from "@/lib";
import { useOrgSetupStore } from "@/store/organizations/store";
import { ORG_SETUP_PHASE, OrgSetupPhase } from "@/types/organizations";
import type { OrgSetupPhase } from "@/types/organizations";
import { ORG_SETUP_PHASE } from "@/types/organizations";
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
@@ -55,7 +53,7 @@ const orgSetupSchema = z.object({
organizationalUnitId: z.string().trim().optional(),
deployFromDelegatedAdmin: z.boolean().optional(),
stackSetDeployed: z.boolean().refine((value) => value, {
message: "You must confirm the deployment before continuing.",
error: "You must confirm the deployment before continuing.",
}),
});
@@ -142,6 +140,12 @@ export function OrgSetupForm({
const organizationalUnitId = watch("organizationalUnitId") || "";
const deployFromDelegatedAdmin = watch("deployFromDelegatedAdmin") || false;
const deploymentAccountName = deployFromDelegatedAdmin
? "delegated administrator account"
: "management account";
const deploymentAccountLabel = deployFromDelegatedAdmin
? "Delegated Administrator Account"
: "Management Account";
const isOrgUnitIdValid =
/^(ou-[a-z0-9]{4,32}-[a-z0-9]{8,32}|r-[a-z0-9]{4,32})$/.test(
organizationalUnitId.trim(),
@@ -396,7 +400,7 @@ export function OrgSetupForm({
<div className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
1) Choose the AWS <strong>Organizational Unit</strong> (or root)
to deploy to. Prowler creates the role in your management
to deploy to. Prowler creates the role in your deployment
account and rolls it out to every member account under this
target.
</p>
@@ -449,26 +453,38 @@ export function OrgSetupForm({
<div className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
2) Create the CloudFormation Stack in your{" "}
<strong>management account</strong>. It deploys the ProwlerScan
role and a service-managed StackSet that rolls the role out to
your member accounts in one step.
<strong>{deploymentAccountName}</strong>. It deploys the
ProwlerScan role and a service-managed StackSet that rolls the
role out to your member accounts in one step.
</p>
<Button
variant="outline"
size="lg"
className="border-border-input-primary bg-bg-input-primary text-button-tertiary hover:bg-bg-input-primary active:bg-bg-input-primary h-12 w-full justify-start"
disabled={!orgQuickLink}
asChild
>
<a
href={orgQuickLink || "#"}
target="_blank"
rel="noopener noreferrer"
{orgQuickLink ? (
<Button
variant="outline"
size="xl"
className="w-full justify-start"
asChild
>
<a
href={orgQuickLink}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-5" />
<span>{`Create Stack in ${deploymentAccountLabel}`}</span>
</a>
</Button>
) : (
<Button
type="button"
variant="outline"
size="xl"
className="w-full justify-start"
disabled
>
<ExternalLink className="size-5" />
<span>Create Stack in Management Account</span>
</a>
</Button>
<span>{`Create Stack in ${deploymentAccountLabel}`}</span>
</Button>
)}
{!isOrgUnitIdValid && (
<p className="text-text-neutral-tertiary text-xs leading-5">
Enter a valid Organizational Unit or Root ID above to enable
@@ -480,7 +496,7 @@ export function OrgSetupForm({
{/* Step 3: Role ARN + confirm */}
<div className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
3) Paste the management account Role ARN and confirm the
3) Paste the {deploymentAccountName} Role ARN and confirm the
deployment is complete.
</p>
</div>
@@ -488,7 +504,7 @@ export function OrgSetupForm({
<WizardInputField
control={control}
name="roleArn"
label="Management Account Role ARN"
label={`${deploymentAccountLabel} Role ARN`}
labelPlacement="outside"
placeholder="e.g. arn:aws:iam::123456789012:role/ProwlerScan"
isRequired={false}
+104
View File
@@ -0,0 +1,104 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
getAWSCredentialsTemplateLinks,
getAWSOrgDeploymentQuickLink,
PROWLER_CF_TEMPLATE_URL,
} from "./external-urls";
function getQuickCreateParams(link: string): URLSearchParams {
const hashQuery = new URL(link).hash.split("?")[1];
return new URLSearchParams(hashQuery);
}
describe("getAWSCredentialsTemplateLinks", () => {
it("should preserve dynamic values as single CloudFormation parameters", () => {
// Given
const externalId = "tenant&id";
const bucketName = "bucket&param_DeployStackSet=false";
// When
const links = getAWSCredentialsTemplateLinks(
externalId,
bucketName,
"amazon_s3",
"123456789012",
);
const params = getQuickCreateParams(links.cloudformationQuickLink);
// Then
expect(params.get("param_ExternalId")).toBe(externalId);
expect(params.get("param_S3IntegrationBucketName")).toBe(bucketName);
expect(params.get("param_DeployStackSet")).toBeNull();
});
});
describe("getAWSOrgDeploymentQuickLink", () => {
it("should include the one-step organization deployment parameters", () => {
// Given
const externalId = "tenant&id";
const organizationalUnitId = "ou-abcd-12345678";
// When
const link = getAWSOrgDeploymentQuickLink({
externalId,
organizationalUnitId,
deployFromDelegatedAdmin: true,
});
const params = getQuickCreateParams(link);
// Then
expect(params.get("templateURL")).toBe(PROWLER_CF_TEMPLATE_URL);
expect(params.get("param_ExternalId")).toBe(externalId);
expect(params.get("param_AWSOrganizationalUnitId")).toBe(
organizationalUnitId,
);
expect(params.get("param_EnableOrganizations")).toBe("true");
expect(params.get("param_DeployLocalRole")).toBe("true");
expect(params.get("param_DeployStackSet")).toBe("true");
expect(params.get("param_DeployFromDelegatedAdmin")).toBe("true");
});
it("should omit delegated administrator mode for management accounts", () => {
// Given
const organizationalUnitId = "r-abcd";
// When
const link = getAWSOrgDeploymentQuickLink({
externalId: "tenant-id",
organizationalUnitId,
});
const params = getQuickCreateParams(link);
// Then
expect(params.get("param_AWSOrganizationalUnitId")).toBe(
organizationalUnitId,
);
expect(params.get("param_DeployFromDelegatedAdmin")).toBeNull();
});
});
describe("Prowler CloudFormation template", () => {
it("should define every parameter used by the UI quick-create links", () => {
// Given
const template = readFileSync(
join(
process.cwd(),
"..",
"permissions/templates/cloudformation/prowler-scan-role.yml",
),
"utf8",
);
// Then
expect(template).toContain(" EnableOrganizations:");
expect(template).toContain(" S3IntegrationBucketAccountId:");
expect(template).toContain(" DeployStackSet:");
expect(template).toContain(" DeployLocalRole:");
expect(template).toContain(" AWSOrganizationalUnitId:");
expect(template).toContain(" DeployFromDelegatedAdmin:");
});
});
+45 -33
View File
@@ -1,4 +1,4 @@
import { IntegrationType } from "../types/integrations";
import type { IntegrationType } from "../types/integrations";
// Documentation URLs
export const DOCS_URLS = {
@@ -26,17 +26,29 @@ export const PROWLER_CF_TEMPLATE_URL =
// Prowler Cloud billing/subscription management page.
export const BILLING_URL = "https://cloud.prowler.com/billing";
// AWS Console URL for creating a new StackSet.
// Hardcoded to us-east-1 — StackSets are typically managed from this region.
// Users in AWS GovCloud or China partitions would need different URLs.
export const STACKSET_CONSOLE_URL =
"https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create";
// Base URL for the CloudFormation "quick create stack" console flow.
// Hardcoded to us-east-1, same rationale as STACKSET_CONSOLE_URL above.
// Hardcoded to us-east-1 because the public template is hosted for that flow.
const CF_QUICKCREATE_BASE_URL =
"https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate";
export interface AWSOrgDeploymentQuickLinkParams {
externalId: string;
organizationalUnitId: string;
deployFromDelegatedAdmin?: boolean;
}
const buildCloudFormationQuickCreateLink = (
parameters: Record<string, string>,
): string => {
const searchParams = new URLSearchParams({
templateURL: PROWLER_CF_TEMPLATE_URL,
stackName: "Prowler",
...parameters,
});
return `${CF_QUICKCREATE_BASE_URL}?${searchParams.toString()}`;
};
export const getProviderHelpText = (provider: string) => {
switch (provider) {
case "aws":
@@ -157,25 +169,27 @@ export const getAWSCredentialsTemplateLinks = (
};
}
const encodedTemplateUrl = encodeURIComponent(PROWLER_CF_TEMPLATE_URL);
// The template requires S3IntegrationBucketAccountId (owner account of the
// bucket) whenever EnableS3Integration is true, so include it alongside the
// bucket name to avoid a stack validation error on the quick-create flow.
const s3Params = bucketName
? `&param_EnableS3Integration=true&param_S3IntegrationBucketName=${bucketName}` +
(bucketAccountId
? `&param_S3IntegrationBucketAccountId=${bucketAccountId}`
: "")
: "";
const parameters: Record<string, string> = {
param_ExternalId: externalId,
};
if (bucketName) {
parameters.param_EnableS3Integration = "true";
parameters.param_S3IntegrationBucketName = bucketName;
if (bucketAccountId) {
parameters.param_S3IntegrationBucketAccountId = bucketAccountId;
}
}
return {
...(links as {
cloudformation: string;
terraform: string;
}),
cloudformationQuickLink:
`${CF_QUICKCREATE_BASE_URL}?templateURL=${encodedTemplateUrl}` +
`&stackName=Prowler&param_ExternalId=${externalId}${s3Params}`,
cloudformationQuickLink: buildCloudFormationQuickCreateLink(parameters),
};
};
@@ -189,20 +203,18 @@ export const getAWSOrgDeploymentQuickLink = ({
externalId,
organizationalUnitId,
deployFromDelegatedAdmin = false,
}: {
externalId: string;
organizationalUnitId: string;
deployFromDelegatedAdmin?: boolean;
}): string => {
const encodedTemplateUrl = encodeURIComponent(PROWLER_CF_TEMPLATE_URL);
}: AWSOrgDeploymentQuickLinkParams): string => {
const parameters: Record<string, string> = {
param_ExternalId: externalId,
param_EnableOrganizations: "true",
param_DeployLocalRole: "true",
param_DeployStackSet: "true",
param_AWSOrganizationalUnitId: organizationalUnitId,
};
return (
`${CF_QUICKCREATE_BASE_URL}?templateURL=${encodedTemplateUrl}` +
`&stackName=Prowler&param_ExternalId=${externalId}` +
"&param_EnableOrganizations=true" +
"&param_DeployLocalRole=true" +
"&param_DeployStackSet=true" +
`&param_AWSOrganizationalUnitId=${organizationalUnitId}` +
(deployFromDelegatedAdmin ? "&param_DeployFromDelegatedAdmin=true" : "")
);
if (deployFromDelegatedAdmin) {
parameters.param_DeployFromDelegatedAdmin = "true";
}
return buildCloudFormationQuickCreateLink(parameters);
};
+1 -1
View File
@@ -208,7 +208,7 @@ const baseS3IntegrationSchema = z.object({
.string()
.optional()
.refine((value) => !value || /^\d{12}$/.test(value), {
message: "Must be a valid 12-digit AWS Account ID",
error: "Must be a valid 12-digit AWS Account ID",
}),
providers: z.array(z.string()).optional(),
enabled: z.boolean().optional(),