feat(ui): one-step AWS Organizations onboarding + S3 bucket acc (#11927)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Pedro Martín
2026-07-16 14:57:17 +02:00
committed by GitHub
parent 641a11e4a0
commit e1c2e9373c
19 changed files with 1048 additions and 184 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 330 KiB

+27 -31
View File
@@ -3,41 +3,37 @@
<filter id="shadow" x="-4%" y="-4%" width="108%" height="108%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.08"/>
</filter>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#9aa0a6"/>
</marker>
</defs>
<!-- Title -->
<text x="550" y="40" text-anchor="middle" font-size="22" font-weight="700" fill="#4285F4">Onboarding Flow</text>
<!-- Step 1 -->
<rect x="30" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#4285F4" stroke-width="2.5" stroke-dasharray="8 4" filter="url(#shadow)"/>
<rect x="30" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#4285F4" stroke-width="2.5" filter="url(#shadow)"/>
<circle cx="140" cy="100" r="22" fill="#4285F4"/>
<text x="140" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">1</text>
<text x="140" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Create Management</text>
<text x="140" y="165" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Account Role</text>
<rect x="60" y="185" width="160" height="24" rx="12" fill="#E8F0FE"/>
<text x="140" y="201" text-anchor="middle" font-size="11" font-weight="600" fill="#4285F4">Quick Create or Manual</text>
<text x="140" y="232" text-anchor="middle" font-size="12" fill="#5f6368">Allows Prowler to</text>
<text x="140" y="248" text-anchor="middle" font-size="12" fill="#5f6368">discover your org</text>
<text x="140" y="264" text-anchor="middle" font-size="12" fill="#5f6368">structure</text>
<text x="140" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Start the Wizard</text>
<rect x="75" y="168" width="130" height="24" rx="12" fill="#E8F0FE"/>
<text x="140" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#4285F4">In Prowler Cloud</text>
<text x="140" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Enter your Org ID</text>
<text x="140" y="232" text-anchor="middle" font-size="12" fill="#5f6368">and OU/root target</text>
<!-- Arrow 1→2 -->
<path d="M260 180 L290 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#9aa0a6"/>
</marker>
</defs>
<!-- Step 2 -->
<rect x="300" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#7B61FF" stroke-width="2.5" filter="url(#shadow)"/>
<circle cx="410" cy="100" r="22" fill="#7B61FF"/>
<text x="410" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">2</text>
<text x="410" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Deploy StackSet</text>
<rect x="340" y="165" width="140" height="24" rx="12" fill="#F3F0FF"/>
<text x="410" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#7B61FF">In AWS Console</text>
<text x="410" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Creates ProwlerScan</text>
<text x="410" y="228" text-anchor="middle" font-size="12" fill="#5f6368">role in every</text>
<text x="410" y="244" text-anchor="middle" font-size="12" fill="#5f6368">member account</text>
<text x="410" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Deploy the Roles</text>
<rect x="350" y="168" width="120" height="24" rx="12" fill="#F3F0FF"/>
<text x="410" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#7B61FF">Single CF Stack</text>
<text x="410" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Management role +</text>
<text x="410" y="232" text-anchor="middle" font-size="12" fill="#5f6368">StackSet to members</text>
<text x="410" y="248" text-anchor="middle" font-size="12" fill="#5f6368">in one CF stack</text>
<!-- Arrow 2→3 -->
<path d="M530 180 L560 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
@@ -46,11 +42,11 @@
<rect x="570" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#00BFA5" stroke-width="2.5" filter="url(#shadow)"/>
<circle cx="680" cy="100" r="22" fill="#00BFA5"/>
<text x="680" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">3</text>
<text x="680" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Run the Wizard</text>
<rect x="615" y="165" width="130" height="24" rx="12" fill="#E0F7F4"/>
<text x="680" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#00BFA5">In Prowler Cloud</text>
<text x="680" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Discovers accounts,</text>
<text x="680" y="228" text-anchor="middle" font-size="12" fill="#5f6368">tests connections</text>
<text x="680" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Discover &amp; Connect</text>
<rect x="615" y="168" width="130" height="24" rx="12" fill="#E0F7F4"/>
<text x="680" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#00BFA5">In Prowler Cloud</text>
<text x="680" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Discovers accounts,</text>
<text x="680" y="232" text-anchor="middle" font-size="12" fill="#5f6368">tests connections</text>
<!-- Arrow 3→4 -->
<path d="M800 180 L830 180" stroke="#9aa0a6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
@@ -59,13 +55,13 @@
<rect x="840" y="70" width="220" height="220" rx="12" fill="#fff" stroke="#F9AB00" stroke-width="2.5" filter="url(#shadow)"/>
<circle cx="950" cy="100" r="22" fill="#F9AB00"/>
<text x="950" y="107" text-anchor="middle" font-size="16" font-weight="700" fill="#fff">4</text>
<text x="950" y="145" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Launch Scans</text>
<rect x="898" y="165" width="104" height="24" rx="12" fill="#FEF7E0"/>
<text x="950" y="181" text-anchor="middle" font-size="11" font-weight="600" fill="#F9AB00">Automatic</text>
<text x="950" y="212" text-anchor="middle" font-size="12" fill="#5f6368">Scans run on all</text>
<text x="950" y="228" text-anchor="middle" font-size="12" fill="#5f6368">connected accounts</text>
<text x="950" y="244" text-anchor="middle" font-size="12" fill="#5f6368">on your schedule</text>
<text x="950" y="150" text-anchor="middle" font-size="15" font-weight="700" fill="#1a1a2e">Launch Scans</text>
<rect x="898" y="168" width="104" height="24" rx="12" fill="#FEF7E0"/>
<text x="950" y="184" text-anchor="middle" font-size="11" font-weight="600" fill="#F9AB00">Automatic</text>
<text x="950" y="216" text-anchor="middle" font-size="12" fill="#5f6368">Scans run on all</text>
<text x="950" y="232" text-anchor="middle" font-size="12" fill="#5f6368">connected accounts</text>
<text x="950" y="248" text-anchor="middle" font-size="12" fill="#5f6368">on your schedule</text>
<!-- Footer -->
<text x="550" y="340" text-anchor="middle" font-size="13" fill="#9aa0a6">Steps 1 and 2 are done once in AWS | Steps 3 and 4 are done in Prowler Cloud</text>
<text x="550" y="340" text-anchor="middle" font-size="13" fill="#9aa0a6">Step 2 runs once in AWS | Steps 1, 3 and 4 are in Prowler Cloud</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -47,7 +47,7 @@
<!-- Deploy badge -->
<rect x="115" y="400" width="270" height="28" rx="14" fill="#FFF3E0" stroke="#F9AB00" stroke-width="1.5"/>
<text x="250" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#E65100">Deploy: Quick Create link or Manual</text>
<text x="250" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#E65100">Deploy: single stack or standalone</text>
<!-- ===== Prowler Cloud connector ===== -->
<rect x="490" y="195" width="120" height="36" rx="8" fill="#F5F5F5" stroke="#E0E0E0" stroke-width="1"/>
@@ -86,7 +86,7 @@
<!-- Deploy badge -->
<rect x="735" y="400" width="230" height="28" rx="14" fill="#E8F5E9" stroke="#66BB6A" stroke-width="1.5"/>
<text x="850" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#2E7D32">Deploy: via CloudFormation StackSet</text>
<text x="850" y="419" text-anchor="middle" font-size="12" font-weight="700" fill="#2E7D32">Deploy: StackSet (single stack)</text>
<!-- Footer labels -->
<text x="250" y="478" text-anchor="middle" font-size="14" font-weight="700" fill="#4285F4">Prowler discovers</text>

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -2,6 +2,8 @@
title: 'AWS Organizations in Prowler'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<Info>
**Using Prowler Cloud?** You can onboard your entire AWS Organization through the UI with automatic account discovery, OU-aware tree selection, and bulk connection testing — no scripts or YAML files required.
@@ -71,11 +73,43 @@ The additional fields in CSV header output are as follows:
## Deploying Prowler IAM Roles Across AWS Organizations
<VersionBadge version="5.35.0" />
When onboarding multiple AWS accounts into Prowler Cloud, it is important to deploy the Prowler Scan IAM Role in each account. The most efficient way to do this across an AWS Organization is by leveraging AWS CloudFormation StackSets, which rolls out infrastructure—like IAM roles—to all accounts centrally from the Management or Delegated Admin account.
When using Infrastructure as Code (IaC), Terraform is recommended to manage this deployment systematically.
### Native CloudFormation StackSet Deployment (Recommended)
### Recommended Approach
The [Prowler Scan IAM Role CloudFormation template](https://github.com/prowler-cloud/prowler/blob/master/permissions/templates/cloudformation/prowler-scan-role.yml) can deploy the role across your entire AWS Organization on its own—no third-party modules required. When launched in the **Management Account** (or a **Delegated Administrator** account) with `DeployStackSet=true` and `EnableOrganizations=true`, it creates a service-managed CloudFormation StackSet that rolls the ProwlerScan role out to every account under the target Organizational Unit (or the organization root), and keeps new accounts covered automatically through auto-deployment.
To deploy from the CloudFormation console: open **CloudFormation → Create stack → With new resources**, choose **Upload a template file** and select `prowler-scan-role.yml` (or paste its S3 URL), then set the parameters below on the **Specify stack details** step. Leave the **Configure stack options** step at its defaults.
Deploy a single CloudFormation Stack in the Management Account with the following parameters:
| Parameter | Description | Default |
| --- | --- | --- |
| `ExternalId` | External ID provided by Prowler Cloud to secure role assumption. | — |
| `DeployLocalRole` | Create the ProwlerScan role in this (Management) account. | `true` |
| `DeployStackSet` | Create a service-managed StackSet that deploys the role to member accounts. | `false` |
| `AWSOrganizationalUnitId` | Target OU (`ou-xxxx-yyyyyyyy`) or organization root (`r-xxxx`) for the StackSet. Required when `DeployStackSet=true`. | `""` |
| `DeployFromDelegatedAdmin` | Set to `true` when deploying from a Delegated Administrator account instead of the Management Account (uses `CallAs: DELEGATED_ADMIN`). | `false` |
| `EnableOrganizations` | Add AWS Organizations permissions to the Management Account role: read-only account discovery plus the StackSet-management permissions the deployment needs. Set to `true` when deploying in the Management Account. | `false` |
| `FailureTolerancePercentage` | Percentage of accounts in which the StackSet operation can fail before CloudFormation stops the operation. | `10` |
| `RetainStacksOnAccountRemoval` | Keep the role in an account after it leaves the Organization or OU. | `false` |
<Warning>
On the review step, select **"I acknowledge that AWS CloudFormation might create IAM resources with custom names"** — the template provisions the named `ProwlerScan` IAM role, so the stack requires the `CAPABILITY_NAMED_IAM` capability and fails without this acknowledgment. (The quick-create link handles this for you.)
</Warning>
<Note>
The service-managed StackSet does **not** deploy to the Management Account itself. Keeping `DeployLocalRole=true` ensures the role also exists there, so a single stack covers both the Management and member accounts.
Trusted access for CloudFormation StackSets must be enabled in the Organization (see the note at the top of this page) before `DeployStackSet` will work.
Deploying for the CLI or a self-hosted Prowler (not Prowler Cloud)? Also set `AccountId` to the account you assume the role from and `IAMPrincipal` to your identity — the defaults target Prowler Cloud. See [Aligning the trust policy with your identity](/user-guide/providers/aws/authentication#trust-policy-align-iamprincipal-with-your-identity).
</Note>
### Alternative: Deploy with Terraform
- **Use StackSets** from the **Management Account** (or a Delegated Admin/Security Account).
- **Use Terraform** to orchestrate the deployment.
@@ -77,6 +77,15 @@ The template requires the following parameters:
- **AccountId:** *(Optional)* AWS Account ID that will assume the role (default: Prowler Cloud account)
- **IAMPrincipal:** *(Optional)* The IAM principal allowed to assume the role (default: `role/prowler*`)
<Warning>
From the CLI you assume the role with **your own** identity, not from Prowler Cloud. The `AccountId` and `IAMPrincipal` defaults target Prowler Cloud, so set **`AccountId`** to the account you run Prowler from and **`IAMPrincipal`** to your identity (for example `role/<name>` or `user/<name>`). Otherwise `sts:AssumeRole` fails with `AccessDenied`. See [Aligning the trust policy with your identity](/user-guide/providers/aws/authentication#trust-policy-align-iamprincipal-with-your-identity).
</Warning>
<Note>
To deploy the role across an entire AWS Organization from a single stack (Management Account role plus a service-managed StackSet for the member accounts), the template also accepts `DeployLocalRole`, `DeployStackSet`, `AWSOrganizationalUnitId`, `DeployFromDelegatedAdmin`, `EnableOrganizations`, `FailureTolerancePercentage`, and `RetainStacksOnAccountRemoval`. See [AWS Organizations in Prowler](/user-guide/providers/aws/organizations#native-cloudformation-stackset-deployment-recommended) for the full parameter reference.
</Note>
When running Prowler CLI, include the External ID using the `-I/--external-id` flag:
```sh
@@ -272,6 +272,8 @@ python aws_org_generator.py \
4. Deploy to all organizational units
5. Use a unique external ID (e.g., `prowler-org-2024-abc123`)
Alternatively, deploy the same template as a **single stack** with `DeployStackSet=true` and `AWSOrganizationalUnitId` set to your root/OU ID — it creates the StackSet for you. See [Native CloudFormation StackSet Deployment](../providers/aws/organizations#native-cloudformation-stackset-deployment-recommended).
{/* TODO: Add screenshot of CloudFormation StackSets deployment */}
</Step>
@@ -25,10 +25,10 @@ For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/us
### How It Works
Before using the AWS Organizations wizard, you need to deploy **two Identity and Access Management (IAM) roles** in your AWS environment. The onboarding follows this sequence:
Onboarding deploys the **ProwlerScan Identity and Access Management (IAM) role** in your management account and in every member account. The wizard can deploy both from a **single CloudFormation stack** ([Step 4](#step-4-authenticate-with-your-management-account)), or you can deploy them yourself beforehand using Steps 12. The onboarding follows this sequence:
<Frame>
<img src="/images/organizations/onboarding-flow.svg" alt="Onboarding flow: 1. Create Management Account Role (Quick Create or Manual), 2. Deploy StackSet, 3. Run the Wizard, 4. Launch Scans" />
<img src="/images/organizations/onboarding-flow.svg" alt="Onboarding flow: 1. Start the Wizard, 2. Deploy the Roles (single CloudFormation stack), 3. Discover and Connect, 4. Launch Scans" />
</Frame>
## Key Concepts
@@ -47,8 +47,8 @@ Prowler requires **two separate IAM roles** deployed in different places, each w
| Role | Where it lives | What it does | How to deploy it |
|------|---------------|--------------|------------------|
| **ProwlerScan** (management account) | Your management (root) account only | Discovers the Organization structure **and** scans the management account. Has additional Organizations discovery permissions. | Via **Quick Create** link or **manually** in the IAM Console ([Step 1](#step-1-create-the-management-account-role)). Cannot be deployed via StackSet. |
| **ProwlerScan** (member accounts) | Every member account | Scans the account for security findings. | Via **CloudFormation StackSet** ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Automated across all accounts. |
| **ProwlerScan** (management account) | Your management (root) account only | Discovers the Organization structure **and** scans the management account. Has additional Organizations discovery permissions. | By the wizard's **single stack** ([Step 4](#step-4-authenticate-with-your-management-account)), or on its own via **Quick Create** link or **manually** in the IAM Console ([Step 1](#step-1-create-the-management-account-role)). Cannot be deployed via StackSet. |
| **ProwlerScan** (member accounts) | Every member account | Scans the account for security findings. | By the wizard's **single stack** ([Step 4](#step-4-authenticate-with-your-management-account)), or on its own via a **CloudFormation StackSet** ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Automated across all accounts. |
<Frame caption="Both roles share the same name `ProwlerScan`. The management account role includes additional Organization discovery permissions.">
<img src="/images/organizations/two-roles-architecture.svg" alt="Two Roles Architecture: ProwlerScan in management account (Quick Create or Manual, discovery + scanning) and ProwlerScan in member accounts (via StackSet, scanning only)" />
@@ -77,7 +77,7 @@ Your AWS environment must have [AWS Organizations](https://docs.aws.amazon.com/o
The first role you need to create is the **management account role**. This role allows Prowler to discover your Organization structure — listing accounts, OUs, and hierarchy.
<Warning>
**StackSets do not deploy to the management account.** Organizational CloudFormation StackSets with service-managed permissions only target member accounts — this is an AWS limitation, not a Prowler one. You must create the management account role separately, either via the Quick Create link ([Option A](#option-a-quick-create-link-fastest)) or manually ([Option B](#option-b-create-the-role-manually)).
**StackSets do not deploy to the management account.** Organizational CloudFormation StackSets with service-managed permissions only target member accounts — this is an AWS limitation, not a Prowler one. The Prowler wizard works around this by having the **same** stack create the management account role (`DeployLocalRole=true`) alongside the StackSet, so a single deployment covers both. You can also create the management account role on its own via the Quick Create link ([Option A](#option-a-quick-create-link-fastest)) or manually ([Option B](#option-b-create-the-role-manually)).
</Warning>
<Note>
@@ -191,7 +191,7 @@ If you just created the role, it may take up to **60 seconds** for AWS to propag
## Step 2: Deploy the CloudFormation StackSet
After creating the management account role, the next step is to deploy the **ProwlerScan** role to your member accounts using a CloudFormation StackSet. This is the recommended method for consistent, scalable deployment across your entire organization.
This step deploys the **ProwlerScan** role to your member accounts using a CloudFormation StackSet. It is the **manual alternative** to letting the wizard's single stack create the StackSet for you ([Step 4](#step-4-authenticate-with-your-management-account)) — use it if you prefer to create and manage the StackSet yourself.
The StackSet uses **service-managed permissions**, which means AWS Organizations handles the cross-account deployment automatically — you don't need to create execution roles manually in each account. The StackSet deploys the ProwlerScan IAM role in every target member account, enabling Prowler to assume that role for cross-account scanning.
@@ -199,9 +199,9 @@ The StackSet uses **service-managed permissions**, which means AWS Organizations
**Trusted access required:** CloudFormation StackSets must have trusted access enabled in your management account. Verify this in the AWS Console under **AWS Organizations > Settings > Trusted access for AWS CloudFormation StackSets**.
</Note>
<Warning>
**The Quick Create link creates a Stack, not a StackSet.** The link in the Prowler wizard creates a CloudFormation **Stack** that deploys the ProwlerScan role in your management account only ([Step 1](#step-1-create-the-management-account-role)). To deploy the role across **member accounts**, you must create a StackSet manually as described below. AWS does not support Quick Create links for StackSets.
</Warning>
<Note>
**The Prowler wizard now deploys this StackSet for you.** The **Create Stack in Management Account** button ([Step 4](#step-4-authenticate-with-your-management-account)) launches a single CloudFormation Stack that creates the management account role **and** a service-managed StackSet for your member accounts (`DeployStackSet=true`). Use the manual console steps below only if you prefer to create the StackSet yourself.
</Note>
<Tip>
**[Open StackSets Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)**
@@ -243,7 +243,7 @@ Deployment typically takes **25 minutes** for medium-sized organizations. Lar
## Step 3: Start the Organization Wizard
Now that both roles are deployed — the management account role (Step 1) and the ProwlerScan role in member accounts (Step 2) — you can start the Prowler wizard.
Start the Prowler wizard. It walks you through deploying both roles — the management account role and the ProwlerScan role in member accounts — from a single CloudFormation stack ([Step 4](#step-4-authenticate-with-your-management-account)). If you already deployed them beforehand via Steps 12, the wizard simply picks up where you left off.
### Open the Wizard
@@ -282,27 +282,46 @@ Click **Next** to proceed to the authentication phase.
## Step 4: Authenticate with Your Management Account
The wizard's **Authentication Details** page guides you through three actions: deploying the roles in AWS, entering the management account Role ARN, and confirming the deployment.
The wizard's **Authentication Details** page guides you through three actions: deploying the roles in AWS, entering the deployment account Role ARN, and confirming the deployment. The deployment account is either the management account or, when delegated administrator mode is selected, the delegated administrator account.
### External ID
The wizard displays a **Prowler External ID** at the top — auto-generated and unique to your tenant. Click the copy icon to copy it. You will need this External ID for both the management account Stack and the member accounts StackSet.
The wizard displays a **Prowler External ID** at the top — auto-generated and unique to your tenant. Click the copy icon to copy it. The External ID is pre-filled into the deployment link, and the single stack applies it to both the management account role and the member-account StackSet.
### Deploy the Roles
The wizard provides two deployment actions:
<VersionBadge version="5.35.0" />
1. **Create Stack in Management Account** — opens a Quick Create link that deploys the ProwlerScan role with `EnableOrganizations=true` in your management account ([Step 1](#step-1-create-the-management-account-role)). The External ID is pre-filled.
The wizard deploys the deployment account role and the member-account StackSet in a **single** CloudFormation Stack:
2. **Open StackSets Console** — links to the CloudFormation StackSets console where you create a StackSet for member accounts ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Copy the template URL shown in the wizard and paste the External ID manually.
1. **Organizational Unit or Root ID** — enter the AWS OU (`ou-xxxx-yyyyyyyy`) or organization root (`r-xxxx`) you want to onboard. Prowler rolls the ProwlerScan role out to every member account under this target. Find it in the [AWS Organizations Console](https://console.aws.amazon.com/organizations/); use the **root ID** (`r-`) to cover the entire organization or an **OU ID** (`ou-`) to target a specific unit.
2. *(Optional)* Check **"I'm deploying from a delegated administrator account"** if you launch the stack from a delegated administrator account instead of the management account.
3. **Create Stack in Management Account** — or **Create Stack in Delegated Administrator Account** when delegated administrator mode is selected — opens a Quick Create link that deploys, in a single stack: the ProwlerScan role in the account where you launch the stack (`DeployLocalRole`, with `EnableOrganizations=true`) **and** a service-managed StackSet (`DeployStackSet`) that rolls the role out to your member accounts. The External ID, OU/Root ID, and deployment options are pre-filled.
<Tip>
**Finding your Organizational Unit or Root ID.** In the [AWS Organizations Console](https://console.aws.amazon.com/organizations/) the root (`r-…`) and OU (`ou-…`) IDs appear in the account tree, or run these from your management account:
```bash
# Root ID — deploys the role to the entire organization
aws organizations list-roots --query 'Roots[0].Id' --output text
# OU IDs under the root — to target a specific unit instead
aws organizations list-organizational-units-for-parent --parent-id r-xxxx \
--query 'OrganizationalUnits[].{Name:Name,Id:Id}' --output table
```
If you deploy the CloudFormation template manually (instead of via the wizard link), set **`DeployStackSet=true`**, **`DeployLocalRole=true`**, and **`EnableOrganizations=true`**, then put the root/OU ID above into **`AWSOrganizationalUnitId`** (required whenever `DeployStackSet=true`). Leave **`DeployFromDelegatedAdmin=false`** unless you launch the stack from a delegated administrator account.
</Tip>
<Frame>
<img src="/images/organizations/authentication-details.png" alt="Authentication Details form showing External ID, two deployment buttons (Create Stack in Management Account and Open StackSets Console), Management Account Role ARN field, and deployment confirmation checkbox" />
<img src="/images/organizations/authentication-details.png" alt="Authentication Details form showing External ID, Organizational Unit or Root ID field, delegated administrator checkbox, deployment account stack button, deployment account Role ARN field, and deployment confirmation checkbox" />
</Frame>
### Enter the Management Account Role ARN
### Enter the Deployment Account Role ARN
Paste the **Role ARN** of the management account role you created in [Step 1](#step-1-create-the-management-account-role) into the **Management Account Role ARN** field.
Paste the **Role ARN** created by the single stack above into the **Management Account Role ARN** field or, when delegated administrator mode is selected, the **Delegated Administrator Account Role ARN** field. If you deployed the management account role beforehand, use the role created in [Step 1](#step-1-create-the-management-account-role).
The ARN follows this format:
```
@@ -312,12 +331,12 @@ arn:aws:iam::<account-id>:role/ProwlerScan
For example: `arn:aws:iam::123456789012:role/ProwlerScan`
<Frame>
<img src="/images/organizations/role-arn-field.png" alt="Management Account Role ARN field in the Authentication Details form" />
<img src="/images/organizations/role-arn-field.png" alt="Deployment account Role ARN field in the Authentication Details form" />
</Frame>
### Confirm and Discover
1. Check the box: **"The Stack and StackSet have been successfully deployed in AWS"**.
1. Check the box: **"The Stack has been successfully deployed in AWS"**.
2. Click **Authenticate**.
Here's what happens behind the scenes:
@@ -11,6 +11,7 @@ import {
buildOrgTreeData,
getOuIdsForSelectedAccounts,
getSelectableAccountIds,
getSelectableAccountIdsForTarget,
} from "./organizations.adapter";
const discoveryFixture: DiscoveryResult = {
@@ -164,6 +165,68 @@ describe("buildAccountLookup", () => {
});
});
describe("getSelectableAccountIdsForTarget", () => {
it("scopes selection to accounts under a target OU, including nested OUs", () => {
// ou-parent contains ou-child (holds 111...) and the blocked 222...
const scoped = getSelectableAccountIdsForTarget(
discoveryFixture,
"ou-parent",
);
// Only the selectable descendant is returned; blocked 222... is excluded,
// and 333... (under the root, outside the OU) is not included.
expect(scoped).toEqual(["111111111111"]);
});
it("scopes selection to a leaf OU", () => {
const scoped = getSelectableAccountIdsForTarget(
discoveryFixture,
"ou-child",
);
expect(scoped).toEqual(["111111111111"]);
});
it("includes the deployment account even when it lives outside the target OU", () => {
// Deployment (management) account 333... sits under the root, but gets the
// role via DeployLocalRole, so it must be pre-selected alongside the OU.
const scoped = getSelectableAccountIdsForTarget(
discoveryFixture,
"ou-child",
"333333333333",
);
expect(scoped).toEqual(["111111111111", "333333333333"]);
});
it("does not include a deployment account that is not selectable", () => {
// 222... is blocked, so even as the deployment account it stays unselected.
const scoped = getSelectableAccountIdsForTarget(
discoveryFixture,
"ou-child",
"222222222222",
);
expect(scoped).toEqual(["111111111111"]);
});
it("returns every selectable account for a root target (whole organization)", () => {
const scoped = getSelectableAccountIdsForTarget(discoveryFixture, "r-root");
expect(scoped).toEqual(["111111111111", "333333333333"]);
});
it("falls back to all selectable accounts for an empty or unknown target", () => {
expect(getSelectableAccountIdsForTarget(discoveryFixture, "")).toEqual([
"111111111111",
"333333333333",
]);
expect(
getSelectableAccountIdsForTarget(discoveryFixture, "ou-does-not-exist"),
).toEqual(["111111111111", "333333333333"]);
});
});
describe("getOuIdsForSelectedAccounts", () => {
it("collects all ancestor OUs for selected accounts without duplicates", () => {
const ouIds = getOuIdsForSelectedAccounts(discoveryFixture, [
@@ -109,6 +109,71 @@ export function buildAccountLookup(
return map;
}
/**
* Returns the selectable account IDs that fall under a deployment target
* (an OU or root ID), optionally including the deployment account itself.
*
* The StackSet only rolls the role out to member accounts beneath the chosen
* target, and the deployment (management or delegated administrator) account
* gets the role via DeployLocalRole even though it usually lives outside that
* target. Pre-selecting exactly those accounts keeps the confirmation step in
* sync with what was actually deployed.
*
* Falls back to every selectable account when the target is empty or is not
* part of this discovery (e.g. a root ID), preserving the whole-organization
* default.
*/
export function getSelectableAccountIdsForTarget(
result: DiscoveryResult,
targetId: string,
deploymentAccountId?: string,
): string[] {
const selectableAccountIds = getSelectableAccountIds(result);
const normalizedTarget = targetId.trim();
if (!normalizedTarget) {
return selectableAccountIds;
}
const isKnownOu = result.organizational_units.some(
(ou) => ou.id === normalizedTarget,
);
// Only a specific OU narrows the selection. A root ID (whole org) or an
// unknown target keeps the whole-organization default.
if (!isKnownOu) {
return selectableAccountIds;
}
// Collect the target OU plus all of its nested descendant OUs.
const scopeIds = new Set<string>([normalizedTarget]);
let addedNewOu = true;
while (addedNewOu) {
addedNewOu = false;
for (const ou of result.organizational_units) {
if (!scopeIds.has(ou.id) && scopeIds.has(ou.parent_id)) {
scopeIds.add(ou.id);
addedNewOu = true;
}
}
}
const selectableSet = new Set(selectableAccountIds);
const scopedIds = new Set<string>();
for (const account of result.accounts) {
if (scopeIds.has(account.parent_id) && selectableSet.has(account.id)) {
scopedIds.add(account.id);
}
}
if (deploymentAccountId && selectableSet.has(deploymentAccountId)) {
scopedIds.add(deploymentAccountId);
}
return selectableAccountIds.filter((id) => scopedIds.has(id));
}
/**
* Given selected account IDs, returns OU IDs that are ancestors of selected accounts.
*/
@@ -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
@@ -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,
@@ -75,6 +95,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:
@@ -94,6 +115,14 @@ export const S3IntegrationForm = ({
});
const isLoading = form.formState.isSubmitting;
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();
@@ -103,18 +132,36 @@ 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
[];
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 = () => {
@@ -255,6 +302,30 @@ 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) {
@@ -265,17 +336,21 @@ export const S3IntegrationForm = ({
externalId,
bucketName,
"amazon_s3",
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>
);
}
@@ -346,6 +421,8 @@ export const S3IntegrationForm = ({
variant="bordered"
isRequired
/>
{!isEditingConfig && renderBucketAccountIdField()}
</div>
</>
);
@@ -11,7 +11,7 @@ import {
triggerDiscovery,
updateOrganizationSecret,
} from "@/actions/organizations/organizations";
import { getSelectableAccountIds } from "@/actions/organizations/organizations.adapter";
import { getSelectableAccountIdsForTarget } from "@/actions/organizations/organizations.adapter";
import { useOrgSetupStore } from "@/store/organizations/store";
import { DISCOVERY_STATUS, DiscoveryResult } from "@/types/organizations";
@@ -43,6 +43,9 @@ interface OrgSetupSubmissionData {
organizationName?: string;
awsOrgId: string;
roleArn: string;
// OU or root ID the StackSet was deployed to. Used to scope the default
// account selection to what was actually rolled out.
organizationalUnitId?: string;
}
interface UseOrgSetupSubmissionProps {
@@ -295,8 +298,15 @@ export function useOrgSetupSubmission({
return;
}
const selectableAccountIds = getSelectableAccountIds(
// The deployment (management/delegated admin) account is where the local
// role is created; its ID is the one embedded in the Role ARN.
const deploymentAccountId = data.roleArn.match(
/^arn:aws:iam::(\d{12}):role\//,
)?.[1];
const selectableAccountIds = getSelectableAccountIdsForTarget(
resolvedDiscoveryResult,
data.organizationalUnitId ?? "",
deploymentAccountId,
);
setDiscovery(discoveryId, resolvedDiscoveryResult);
setSelectedAccountIds(selectableAccountIds);
@@ -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";
@@ -24,13 +21,10 @@ 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";
import type { OrgSetupPhase } from "@/types/organizations";
import { ORG_SETUP_PHASE } from "@/types/organizations";
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
@@ -52,8 +46,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.",
error: "You must confirm the deployment before continuing.",
}),
});
@@ -91,9 +91,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 +108,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 +122,8 @@ export function OrgSetupForm({
organizationName: initialValues?.organizationName ?? "",
awsOrgId: initialValues?.awsOrgId ?? "",
roleArn: "",
organizationalUnitId: "",
deployFromDelegatedAdmin: false,
stackSetDeployed: false,
},
});
@@ -139,10 +137,27 @@ 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 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(),
);
const orgQuickLink =
stackSetExternalId && isOrgUnitIdValid
? getAWSOrgDeploymentQuickLink({
externalId: stackSetExternalId,
organizationalUnitId: organizationalUnitId.trim(),
deployFromDelegatedAdmin,
})
: null;
const { apiError, setApiError, submitOrganizationSetup } =
useOrgSetupSubmission({
@@ -381,91 +396,115 @@ 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 deployment
account and rolls it out to every member account under this
target.
</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"
>
<ExternalLink className="size-5" />
<span>Create Stack in Management Account</span>
</a>
</Button>
<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: Member accounts - CloudFormation StackSet */}
{/* 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) Deploy the ProwlerScan role to{" "}
<strong>member accounts</strong> using a CloudFormation
StackSet.
2) Create the CloudFormation Stack in your{" "}
<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>
<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"
{orgQuickLink ? (
<Button
variant="outline"
size="xl"
className="w-full justify-start"
asChild
>
{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"
<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>Open StackSets Console</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
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 {deploymentAccountName} Role ARN and confirm the
deployment is complete.
</p>
</div>
<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}
@@ -495,8 +534,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>
</>
+130
View File
@@ -0,0 +1,130 @@
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_S3IntegrationBucketAccountId")).toBe(
"123456789012",
);
expect(params.get("param_DeployStackSet")).toBeNull();
});
it("should omit S3 integration parameters when the bucket account id is missing", () => {
// Given - the template requires S3IntegrationBucketAccountId whenever
// EnableS3Integration is true, so an incomplete link would fail CFN
// validation. This is reachable from the edit-credentials flow, where the
// account id can resolve to an empty string.
const externalId = "tenant-id";
const bucketName = "my-findings-bucket";
// When
const links = getAWSCredentialsTemplateLinks(
externalId,
bucketName,
"amazon_s3",
);
const params = getQuickCreateParams(links.cloudformationQuickLink);
// Then
expect(params.get("param_ExternalId")).toBe(externalId);
expect(params.get("param_EnableS3Integration")).toBeNull();
expect(params.get("param_S3IntegrationBucketName")).toBeNull();
expect(params.get("param_S3IntegrationBucketAccountId")).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:");
});
});
+68 -22
View File
@@ -1,4 +1,4 @@
import { IntegrationType } from "../types/integrations";
import type { IntegrationType } from "../types/integrations";
// Documentation URLs
export const DOCS_URLS = {
@@ -18,19 +18,36 @@ 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";
// 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 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) {
@@ -126,11 +143,11 @@ export const getAWSCredentialsTemplateLinks = (
externalId: string,
bucketName?: string,
integrationType?: IntegrationType,
bucketAccountId?: string,
): {
cloudformation: string;
terraform: string;
cloudformationQuickLink: string;
cloudformationOrgQuickLink: string;
} => {
let links = {};
@@ -152,24 +169,53 @@ 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";
const s3Params = bucketName
? `&param_EnableS3Integration=true&param_S3IntegrationBucketName=${bucketName}`
: "";
// The template requires S3IntegrationBucketAccountId (owner account of the
// bucket) whenever EnableS3Integration is true. Only enable S3 when both the
// bucket name and its account id are known, otherwise an incomplete link
// would fail stack validation on the quick-create flow (reachable from the
// edit-credentials flow, where the account id can resolve to an empty value).
const parameters: Record<string, string> = {
param_ExternalId: externalId,
};
if (bucketName && bucketAccountId) {
parameters.param_EnableS3Integration = "true";
parameters.param_S3IntegrationBucketName = bucketName;
parameters.param_S3IntegrationBucketAccountId = bucketAccountId;
}
return {
...(links as {
cloudformation: string;
terraform: string;
}),
cloudformationQuickLink:
`${cfBaseUrl}?templateURL=${encodedTemplateUrl}` +
`&stackName=Prowler&param_ExternalId=${externalId}${s3Params}`,
cloudformationOrgQuickLink:
`${cfBaseUrl}?templateURL=${encodedTemplateUrl}` +
`&stackName=Prowler&param_ExternalId=${externalId}` +
`&param_EnableOrganizations=true${s3Params}`,
cloudformationQuickLink: buildCloudFormationQuickCreateLink(parameters),
};
};
// Builds the CloudFormation quick-create link that onboards an entire AWS
// Organization in a single stack: it creates the ProwlerScan role in the
// account launching the stack (DeployLocalRole) and a service-managed StackSet
// that rolls the role out to the member accounts under the given OU/root
// (DeployStackSet). By default the stack is launched from the management
// account; set deployFromDelegatedAdmin when launching from a delegated
// administrator account instead, where the local role lands in that account.
export const getAWSOrgDeploymentQuickLink = ({
externalId,
organizationalUnitId,
deployFromDelegatedAdmin = false,
}: AWSOrgDeploymentQuickLinkParams): string => {
const parameters: Record<string, string> = {
param_ExternalId: externalId,
param_EnableOrganizations: "true",
param_DeployLocalRole: "true",
param_DeployStackSet: "true",
param_AWSOrganizationalUnitId: organizationalUnitId,
};
if (deployFromDelegatedAdmin) {
parameters.param_DeployFromDelegatedAdmin = "true";
}
return buildCloudFormationQuickCreateLink(parameters);
};
+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), {
error: "Must be a valid 12-digit AWS Account ID",
}),
providers: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
...awsCredentialFields,