From 0df667264bbdf5eba3cb613ee7285f81f87ac1aa Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Thu, 16 Jul 2026 10:04:00 +0200 Subject: [PATCH] fix(ui): harden AWS onboarding deployment flows --- .../s3/s3-integration-form.test.tsx | 244 ++++++++++++++++++ .../integrations/s3/s3-integration-form.tsx | 136 ++++++---- .../organizations/org-setup-form.test.tsx | 121 +++++++++ .../organizations/org-setup-form.tsx | 78 +++--- ui/lib/external-urls.test.ts | 104 ++++++++ ui/lib/external-urls.ts | 78 +++--- ui/types/integrations.ts | 2 +- 7 files changed, 644 insertions(+), 119 deletions(-) create mode 100644 ui/components/integrations/s3/s3-integration-form.test.tsx create mode 100644 ui/components/providers/organizations/org-setup-form.test.tsx create mode 100644 ui/lib/external-urls.test.ts diff --git a/ui/components/integrations/s3/s3-integration-form.test.tsx b/ui/components/integrations/s3/s3-integration-form.test.tsx new file mode 100644 index 0000000000..7186cc42ed --- /dev/null +++ b/ui/components/integrations/s3/s3-integration-form.test.tsx @@ -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>()), + 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) => ( + + ), +})); + +vi.mock( + "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form", + () => ({ + AWSRoleCredentialsForm: ({ + templateLinks, + }: { + templateLinks: { cloudformationQuickLink: string }; + }) => ( + + {templateLinks.cloudformationQuickLink} + + ), + }), +); + +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>, +) { + return render( + , + ); +} + +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(); + }); +}); diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index d7843f996f..555e7d0d1f 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -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 = () => ( +
+ +

+ {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."} +

+
+ ); + 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 ( - } - setValue={form.setValue as any} - externalId={externalId} - templateLinks={templateLinks} - type="integrations" - integrationType="amazon_s3" - /> +
+ {isEditingCredentials && renderBucketAccountIdField()} + } + setValue={form.setValue as any} + externalId={externalId} + templateLinks={templateLinks} + type="integrations" + integrationType="amazon_s3" + /> +
); } @@ -378,23 +422,7 @@ export const S3IntegrationForm = ({ isRequired /> -
- -

- 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. -

-
+ {!isEditingConfig && renderBucketAccountIdField()} ); diff --git a/ui/components/providers/organizations/org-setup-form.test.tsx b/ui/components/providers/organizations/org-setup-form.test.tsx new file mode 100644 index 0000000000..329d6fe05c --- /dev/null +++ b/ui/components/providers/organizations/org-setup-form.test.tsx @@ -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( + , + ); +} + +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(); + }); +}); diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx index 93aa33e213..35fb13c938 100644 --- a/ui/components/providers/organizations/org-setup-form.tsx +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -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({

1) Choose the AWS Organizational Unit (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.

@@ -449,26 +453,38 @@ export function OrgSetupForm({

2) Create the CloudFormation Stack in your{" "} - management account. It deploys the ProwlerScan - role and a service-managed StackSet that rolls the role out to - your member accounts in one step. + {deploymentAccountName}. It deploys the + ProwlerScan role and a service-managed StackSet that rolls the + role out to your member accounts in one step.

- + ) : ( + + {`Create Stack in ${deploymentAccountLabel}`} + + )} {!isOrgUnitIdValid && (

Enter a valid Organizational Unit or Root ID above to enable @@ -480,7 +496,7 @@ export function OrgSetupForm({ {/* Step 3: Role ARN + confirm */}

- 3) Paste the management account Role ARN and confirm the + 3) Paste the {deploymentAccountName} Role ARN and confirm the deployment is complete.

@@ -488,7 +504,7 @@ export function OrgSetupForm({ { + it("should preserve dynamic values as single CloudFormation parameters", () => { + // Given + const externalId = "tenant&id"; + const bucketName = "bucket¶m_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:"); + }); +}); diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 0342a76fce..8558f83536 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -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 => { + 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 - ? `¶m_EnableS3Integration=true¶m_S3IntegrationBucketName=${bucketName}` + - (bucketAccountId - ? `¶m_S3IntegrationBucketAccountId=${bucketAccountId}` - : "") - : ""; + const parameters: Record = { + 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¶m_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 = { + param_ExternalId: externalId, + param_EnableOrganizations: "true", + param_DeployLocalRole: "true", + param_DeployStackSet: "true", + param_AWSOrganizationalUnitId: organizationalUnitId, + }; - return ( - `${CF_QUICKCREATE_BASE_URL}?templateURL=${encodedTemplateUrl}` + - `&stackName=Prowler¶m_ExternalId=${externalId}` + - "¶m_EnableOrganizations=true" + - "¶m_DeployLocalRole=true" + - "¶m_DeployStackSet=true" + - `¶m_AWSOrganizationalUnitId=${organizationalUnitId}` + - (deployFromDelegatedAdmin ? "¶m_DeployFromDelegatedAdmin=true" : "") - ); + if (deployFromDelegatedAdmin) { + parameters.param_DeployFromDelegatedAdmin = "true"; + } + + return buildCloudFormationQuickCreateLink(parameters); }; diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index ba818ea773..b84869af1c 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -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(),