feat(ui): one-step AWS Organizations onboarding + S3 bucket account id

This commit is contained in:
pedrooot authored and alejandrobailo committed 2026-07-16 09:40:16 +02:00
1 parent 0d899b3076
commit 89013c561f
13 files changed
+303 -135

No files matched your search

@@ -0,0 +1 @@
AWS Organizations onboarding now deploys the management account role and the member-account StackSet from a single CloudFormation stack, replacing the manual StackSet console step
@@ -0,0 +1 @@
The AWS S3 integration CloudFormation quick-create link now sets the bucket owner account ID, preventing a stack validation error when S3 integration is enabled
@@ -75,6 +75,7 @@ export const S3IntegrationForm = ({
defaultValues: {
integration_type: "amazon_s3" as const,
bucket_name: integration?.attributes.configuration.bucket_name || "",
bucket_account_id: "",
output_directory:
integration?.attributes.configuration.output_directory || "output",
providers:
@@ -95,6 +96,26 @@ 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 handleNext = async (e: React.FormEvent) => {
e.preventDefault();
@@ -103,10 +124,17 @@ export const S3IntegrationForm = ({
return;
}
// Validate current step fields for creation flow
// Validate current step fields for creation flow. bucket_account_id is
// validated here, while its input is visible, so a malformed value surfaces
// its error instead of silently blocking the step 1 submit.
const stepFields =
currentStep === 0
? (["bucket_name", "output_directory", "providers"] as const)
? ([
"bucket_name",
"output_directory",
"providers",
"bucket_account_id",
] as const)
: // Step 1: No required fields since role_arn and external_id are optional
[];
@@ -259,12 +287,15 @@ export const S3IntegrationForm = ({
// 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,
);
return (
@@ -346,6 +377,24 @@ export const S3IntegrationForm = ({
variant="bordered"
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>
</div>
</>
);
@@ -24,11 +24,7 @@ import { Button } from "@/components/shadcn/button/button";
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
import { Form } from "@/components/shadcn/form";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
getAWSCredentialsTemplateLinks,
PROWLER_CF_TEMPLATE_URL,
STACKSET_CONSOLE_URL,
} from "@/lib";
import { getAWSOrgDeploymentQuickLink } from "@/lib";
import { useOrgSetupStore } from "@/store/organizations/store";
import { ORG_SETUP_PHASE, OrgSetupPhase } from "@/types/organizations";
@@ -52,8 +48,14 @@ const orgSetupSchema = z.object({
/^arn:aws:iam::\d{12}:role\//,
"Must be a valid IAM Role ARN (e.g., arn:aws:iam::123456789012:role/ProwlerScan)",
),
// OU or root id the StackSet deploys to. UI-only: used to build the
// CloudFormation quick-create link, not sent to the backend. Its format is
// validated inline (isOrgUnitIdValid) to gate the deployment button, so a
// malformed value never blocks the Authenticate submit.
organizationalUnitId: z.string().trim().optional(),
deployFromDelegatedAdmin: z.boolean().optional(),
stackSetDeployed: z.boolean().refine((value) => value, {
message: "You must confirm the StackSet deployment before continuing.",
message: "You must confirm the deployment before continuing.",
}),
});
@@ -91,9 +93,7 @@ export function OrgSetupForm({
const { toast } = useToast();
const COPY_RESET_TIMEOUT = 1500;
const [isExternalIdCopied, setIsExternalIdCopied] = useState(false);
const [isTemplateUrlCopied, setIsTemplateUrlCopied] = useState(false);
const externalIdCopyTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const templateUrlCopyTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Copies text and flips the copied flag back after the timeout, without effects
const copyWithFeedback = (
@@ -110,8 +110,6 @@ export function OrgSetupForm({
const copyExternalId = (text: string) =>
copyWithFeedback(text, setIsExternalIdCopied, externalIdCopyTimer);
const copyTemplateUrl = (text: string) =>
copyWithFeedback(text, setIsTemplateUrlCopied, templateUrlCopyTimer);
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(initialPhase);
const [isSaving, setIsSaving] = useState(false);
const formId = "org-wizard-setup-form";
@@ -126,6 +124,8 @@ export function OrgSetupForm({
organizationName: initialValues?.organizationName ?? "",
awsOrgId: initialValues?.awsOrgId ?? "",
roleArn: "",
organizationalUnitId: "",
deployFromDelegatedAdmin: false,
stackSetDeployed: false,
},
});
@@ -139,10 +139,21 @@ export function OrgSetupForm({
const awsOrgId = watch("awsOrgId") || "";
const isOrgIdValid = /^o-[a-z0-9]{10,32}$/.test(awsOrgId.trim());
const templateLinks = stackSetExternalId
? getAWSCredentialsTemplateLinks(stackSetExternalId)
: null;
const orgQuickLink = templateLinks?.cloudformationOrgQuickLink;
const organizationalUnitId = watch("organizationalUnitId") || "";
const deployFromDelegatedAdmin = watch("deployFromDelegatedAdmin") || false;
const isOrgUnitIdValid =
/^(ou-[a-z0-9]{4,32}-[a-z0-9]{8,32}|r-[a-z0-9]{4,32})$/.test(
organizationalUnitId.trim(),
);
const orgQuickLink =
stackSetExternalId && isOrgUnitIdValid
? getAWSOrgDeploymentQuickLink({
externalId: stackSetExternalId,
organizationalUnitId: organizationalUnitId.trim(),
deployFromDelegatedAdmin,
})
: null;
const { apiError, setApiError, submitOrganizationSetup } =
useOrgSetupSubmission({
@@ -381,12 +392,66 @@ export function OrgSetupForm({
</div>
</div>
{/* Step 1: Management account - CloudFormation Stack */}
{/* Step 1: Choose the deployment target */}
<div className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
1) Deploy the ProwlerScan role in your{" "}
<strong>management account</strong> using a CloudFormation
Stack.
1) Choose the AWS <strong>Organizational Unit</strong> (or root)
to deploy to. Prowler creates the role in your management
account and rolls it out to every member account under this
target.
</p>
<WizardInputField
control={control}
name="organizationalUnitId"
label="Organizational Unit or Root ID"
labelPlacement="outside"
placeholder="e.g. r-abcd or ou-abcd-1a2b3c4d"
isRequired={false}
normalizeValue={(value) => value.toLowerCase()}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
<p className="text-text-neutral-tertiary text-xs leading-5">
Find this in the AWS Organizations console. Use your{" "}
<strong>root ID</strong> (starts with <code>r-</code>) to deploy
to the whole organization, or an <strong>OU ID</strong> (starts
with <code>ou-</code>) to target a specific unit.
</p>
<div className="flex items-start gap-4">
<Controller
name="deployFromDelegatedAdmin"
control={control}
render={({ field }) => (
<>
<Checkbox
id="deployFromDelegatedAdmin"
className="mt-0.5"
checked={field.value}
onCheckedChange={(checked) =>
field.onChange(Boolean(checked))
}
/>
<label
htmlFor="deployFromDelegatedAdmin"
className="text-text-neutral-tertiary text-xs leading-5 font-normal"
>
I&apos;m deploying from a delegated administrator
account (not the Organization management account)
</label>
</>
)}
/>
</div>
</div>
{/* Step 2: Single CloudFormation Stack (role + StackSet) */}
<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.
</p>
<Button
variant="outline"
@@ -404,61 +469,19 @@ export function OrgSetupForm({
<span>Create Stack in Management Account</span>
</a>
</Button>
</div>
{/* Step 2: Member accounts - CloudFormation StackSet */}
<div className="flex flex-col gap-4">
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
2) Deploy the ProwlerScan role to{" "}
<strong>member accounts</strong> using a CloudFormation
StackSet.
</p>
<p className="text-text-neutral-tertiary text-xs leading-5">
Open the StackSets console, select{" "}
<strong>Service-managed permissions</strong>, and paste the
template URL below. Set the <strong>ExternalId</strong>{" "}
parameter to the value shown above.
</p>
<div className="bg-bg-neutral-tertiary border-border-input-primary flex items-center gap-3 rounded-lg border px-4 py-2.5">
<span className="text-text-neutral-primary min-w-0 flex-1 truncate font-mono text-xs">
{PROWLER_CF_TEMPLATE_URL}
</span>
<button
type="button"
onClick={() => copyTemplateUrl(PROWLER_CF_TEMPLATE_URL)}
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 transition-colors"
aria-label="Copy template URL"
>
{isTemplateUrlCopied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</button>
</div>
<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={!isExternalIdCopied}
asChild
>
<a
href={STACKSET_CONSOLE_URL}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-5" />
<span>Open StackSets Console</span>
</a>
</Button>
{!isOrgUnitIdValid && (
<p className="text-text-neutral-tertiary text-xs leading-5">
Enter a valid Organizational Unit or Root ID above to enable
deployment.
</p>
)}
</div>
{/* 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 both
deployments are complete.
3) Paste the management account Role ARN and confirm the
deployment is complete.
</p>
</div>
@@ -495,8 +518,7 @@ export function OrgSetupForm({
htmlFor="stackSetDeployed"
className="text-text-neutral-tertiary text-xs leading-5 font-normal"
>
The Stack and StackSet have been successfully deployed in
AWS
The Stack has been successfully deployed in AWS
<span className="text-text-error-primary">*</span>
</label>
</>
+44 -11
View File
@@ -18,8 +18,8 @@ export const DOCS_URLS = {
} as const;
// CloudFormation template URL for the ProwlerScan role.
// Also used (URL-encoded) as the templateURL param in cloudformationQuickLink
// and cloudformationOrgQuickLink below — keep both in sync.
// Also used (URL-encoded) as the templateURL param in the quick-create links
// built by getAWSCredentialsTemplateLinks and getAWSOrgDeploymentQuickLink below.
export const PROWLER_CF_TEMPLATE_URL =
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml";
@@ -32,6 +32,11 @@ export const BILLING_URL = "https://cloud.prowler.com/billing";
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.
const CF_QUICKCREATE_BASE_URL =
"https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate";
export const getProviderHelpText = (provider: string) => {
switch (provider) {
case "aws":
@@ -126,11 +131,11 @@ export const getAWSCredentialsTemplateLinks = (
externalId: string,
bucketName?: string,
integrationType?: IntegrationType,
bucketAccountId?: string,
): {
cloudformation: string;
terraform: string;
cloudformationQuickLink: string;
cloudformationOrgQuickLink: string;
} => {
let links = {};
@@ -153,10 +158,14 @@ export const getAWSCredentialsTemplateLinks = (
}
const encodedTemplateUrl = encodeURIComponent(PROWLER_CF_TEMPLATE_URL);
const cfBaseUrl =
"https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate";
// 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}`
? `&param_EnableS3Integration=true&param_S3IntegrationBucketName=${bucketName}` +
(bucketAccountId
? `&param_S3IntegrationBucketAccountId=${bucketAccountId}`
: "")
: "";
return {
@@ -165,11 +174,35 @@ export const getAWSCredentialsTemplateLinks = (
terraform: string;
}),
cloudformationQuickLink:
`${cfBaseUrl}?templateURL=${encodedTemplateUrl}` +
`${CF_QUICKCREATE_BASE_URL}?templateURL=${encodedTemplateUrl}` +
`&stackName=Prowler&param_ExternalId=${externalId}${s3Params}`,
cloudformationOrgQuickLink:
`${cfBaseUrl}?templateURL=${encodedTemplateUrl}` +
`&stackName=Prowler&param_ExternalId=${externalId}` +
`&param_EnableOrganizations=true${s3Params}`,
};
};
// Builds the CloudFormation quick-create link that onboards an entire AWS
// Organization in a single stack: it creates the ProwlerScan role in the
// management account (DeployLocalRole) and a service-managed StackSet that
// rolls the role out to the member accounts under the given OU/root
// (DeployStackSet). Set deployFromDelegatedAdmin when launching from a
// delegated administrator account instead of the management account.
export const getAWSOrgDeploymentQuickLink = ({
externalId,
organizationalUnitId,
deployFromDelegatedAdmin = false,
}: {
externalId: string;
organizationalUnitId: string;
deployFromDelegatedAdmin?: boolean;
}): string => {
const encodedTemplateUrl = encodeURIComponent(PROWLER_CF_TEMPLATE_URL);
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" : "")
);
};
+8
View File
@@ -202,6 +202,14 @@ const baseS3IntegrationSchema = z.object({
integration_type: z.literal("amazon_s3"),
bucket_name: z.string().min(1, "Bucket name is required"),
output_directory: z.string().min(1, "Output directory is required"),
// UI-only field used to prefill the S3IntegrationBucketAccountId parameter of
// the CloudFormation quick-create link. Not sent to the backend.
bucket_account_id: z
.string()
.optional()
.refine((value) => !value || /^\d{12}$/.test(value), {
message: "Must be a valid 12-digit AWS Account ID",
}),
providers: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
...awsCredentialFields,