mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-10 21:44:16 +00:00
fix(ui): harden AWS onboarding deployment flows
This commit is contained in:
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}
|
||||
|
||||
@@ -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¶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:");
|
||||
});
|
||||
});
|
||||
+45
-33
@@ -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
|
||||
? `¶m_EnableS3Integration=true¶m_S3IntegrationBucketName=${bucketName}` +
|
||||
(bucketAccountId
|
||||
? `¶m_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¶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<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¶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);
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user