diff --git a/docs/images/organizations/authentication-details.png b/docs/images/organizations/authentication-details.png index 2b4ae782cf..6aa72b5949 100644 Binary files a/docs/images/organizations/authentication-details.png and b/docs/images/organizations/authentication-details.png differ diff --git a/docs/images/organizations/onboarding-flow.svg b/docs/images/organizations/onboarding-flow.svg index f6e11fc0a3..b5ba7858a0 100644 --- a/docs/images/organizations/onboarding-flow.svg +++ b/docs/images/organizations/onboarding-flow.svg @@ -3,41 +3,37 @@ + + + Onboarding Flow - + 1 - Create Management - Account Role - - Quick Create or Manual - Allows Prowler to - discover your org - structure + Start the Wizard + + In Prowler Cloud + Enter your Org ID + and OU/root target - - - - - 2 - Deploy StackSet - - In AWS Console - Creates ProwlerScan - role in every - member account + Deploy the Roles + + Single CF Stack + Management role + + StackSet to members + in one CF stack @@ -46,11 +42,11 @@ 3 - Run the Wizard - - In Prowler Cloud - Discovers accounts, - tests connections + Discover & Connect + + In Prowler Cloud + Discovers accounts, + tests connections @@ -59,13 +55,13 @@ 4 - Launch Scans - - Automatic - Scans run on all - connected accounts - on your schedule + Launch Scans + + Automatic + Scans run on all + connected accounts + on your schedule - Steps 1 and 2 are done once in AWS | Steps 3 and 4 are done in Prowler Cloud + Step 2 runs once in AWS | Steps 1, 3 and 4 are in Prowler Cloud diff --git a/docs/images/organizations/two-roles-architecture.svg b/docs/images/organizations/two-roles-architecture.svg index c67588b049..f8c40d5b21 100644 --- a/docs/images/organizations/two-roles-architecture.svg +++ b/docs/images/organizations/two-roles-architecture.svg @@ -47,7 +47,7 @@ - Deploy: Quick Create link or Manual + Deploy: single stack or standalone @@ -86,7 +86,7 @@ - Deploy: via CloudFormation StackSet + Deploy: StackSet (single stack) Prowler discovers diff --git a/docs/user-guide/providers/aws/organizations.mdx b/docs/user-guide/providers/aws/organizations.mdx index c0e9ae0e6b..b48df1c442 100644 --- a/docs/user-guide/providers/aws/organizations.mdx +++ b/docs/user-guide/providers/aws/organizations.mdx @@ -2,6 +2,8 @@ title: 'AWS Organizations in Prowler' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + **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 + + 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` | + + +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.) + + + +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). + + + +### Alternative: Deploy with Terraform - **Use StackSets** from the **Management Account** (or a Delegated Admin/Security Account). - **Use Terraform** to orchestrate the deployment. diff --git a/docs/user-guide/providers/aws/role-assumption.mdx b/docs/user-guide/providers/aws/role-assumption.mdx index b714beafbd..54dd0e214a 100644 --- a/docs/user-guide/providers/aws/role-assumption.mdx +++ b/docs/user-guide/providers/aws/role-assumption.mdx @@ -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*`) + +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/` or `user/`). 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). + + + +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. + + + When running Prowler CLI, include the External ID using the `-I/--external-id` flag: ```sh diff --git a/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx b/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx index 2e8b5eb62e..188ac3540d 100644 --- a/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx +++ b/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx @@ -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 */} diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index 8bb6e320ae..717bbdfc0e 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -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 1–2. The onboarding follows this sequence: - Onboarding flow: 1. Create Management Account Role (Quick Create or Manual), 2. Deploy StackSet, 3. Run the Wizard, 4. Launch Scans + Onboarding flow: 1. Start the Wizard, 2. Deploy the Roles (single CloudFormation stack), 3. Discover and Connect, 4. Launch Scans ## 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. | 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. -**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)). @@ -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**. - -**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. - + +**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. + **[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 **2–5 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 1–2, 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: + -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. + + +**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. + - 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 + 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 -### 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:::role/ProwlerScan For example: `arn:aws:iam::123456789012:role/ProwlerScan` - Management Account Role ARN field in the Authentication Details form + Deployment account Role ARN field in the Authentication Details form ### 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: diff --git a/ui/actions/organizations/organizations.adapter.test.ts b/ui/actions/organizations/organizations.adapter.test.ts index 07cff60c0c..41e0b8d89f 100644 --- a/ui/actions/organizations/organizations.adapter.test.ts +++ b/ui/actions/organizations/organizations.adapter.test.ts @@ -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, [ diff --git a/ui/actions/organizations/organizations.adapter.ts b/ui/actions/organizations/organizations.adapter.ts index e120a1a3dc..2b0c7b5a0b 100644 --- a/ui/actions/organizations/organizations.adapter.ts +++ b/ui/actions/organizations/organizations.adapter.ts @@ -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([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(); + + 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. */ diff --git a/ui/changelog.d/aws-org-one-step-stackset-deploy.changed.md b/ui/changelog.d/aws-org-one-step-stackset-deploy.changed.md new file mode 100644 index 0000000000..caffe6c120 --- /dev/null +++ b/ui/changelog.d/aws-org-one-step-stackset-deploy.changed.md @@ -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 diff --git a/ui/changelog.d/s3-integration-bucket-account-id.fixed.md b/ui/changelog.d/s3-integration-bucket-account-id.fixed.md new file mode 100644 index 0000000000..5b2d6310be --- /dev/null +++ b/ui/changelog.d/s3-integration-bucket-account-id.fixed.md @@ -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 diff --git a/ui/components/integrations/s3/s3-integration-form.test.tsx b/ui/components/integrations/s3/s3-integration-form.test.tsx new file mode 100644 index 0000000000..7186cc42ed --- /dev/null +++ b/ui/components/integrations/s3/s3-integration-form.test.tsx @@ -0,0 +1,244 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IntegrationProps } from "@/types/integrations"; +import type { ProviderProps } from "@/types/providers"; + +import { S3IntegrationForm } from "./s3-integration-form"; + +const { createIntegrationMock, toastMock, updateIntegrationMock } = vi.hoisted( + () => ({ + createIntegrationMock: vi.fn(), + toastMock: vi.fn(), + updateIntegrationMock: vi.fn(), + }), +); + +vi.mock("@/actions/integrations", () => ({ + createIntegration: createIntegrationMock, + updateIntegration: updateIntegrationMock, +})); + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ + data: { + tenantId: "tenant-id", + }, + }), +})); + +vi.mock("@/components/shadcn", async (importOriginal) => ({ + ...(await importOriginal>()), + useToast: () => ({ + toast: toastMock, + }), +})); + +interface MockEnhancedMultiSelectProps { + onValueChange: (values: string[]) => void; + options: Array<{ value: string }>; +} + +vi.mock("@/components/shadcn/select/enhanced-multi-select", () => ({ + EnhancedMultiSelect: ({ + onValueChange, + options, + }: MockEnhancedMultiSelectProps) => ( + + ), +})); + +vi.mock( + "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form", + () => ({ + AWSRoleCredentialsForm: ({ + templateLinks, + }: { + templateLinks: { cloudformationQuickLink: string }; + }) => ( + + {templateLinks.cloudformationQuickLink} + + ), + }), +); + +vi.mock("@/lib", () => ({ + getAWSCredentialsTemplateLinks: ( + _externalId: string, + _bucketName: string, + _integrationType: string, + bucketAccountId?: string, + ) => ({ + cloudformation: "https://example.com/cloudformation", + terraform: "https://example.com/terraform", + cloudformationQuickLink: `https://example.com/quick-create?bucketAccountId=${bucketAccountId ?? ""}`, + }), +})); + +function createProvider( + provider: ProviderProps["attributes"]["provider"], + uid: string, +): ProviderProps { + return { + id: `${provider}-provider`, + type: "providers", + attributes: { + provider, + is_dynamic: false, + uid, + alias: `${provider} provider`, + status: "completed", + resources: 0, + connection: { + connected: true, + last_checked_at: "2026-07-16T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-07-16T00:00:00Z", + updated_at: "2026-07-16T00:00:00Z", + created_by: { + object: "users", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, + }; +} + +function renderS3IntegrationForm( + props?: Partial>, +) { + return render( + , + ); +} + +const integration: IntegrationProps = { + type: "integrations", + id: "integration-1", + attributes: { + inserted_at: "2026-07-16T00:00:00Z", + updated_at: "2026-07-16T00:00:00Z", + enabled: true, + connected: true, + connection_last_checked_at: "2026-07-16T00:00:00Z", + integration_type: "amazon_s3", + configuration: { + bucket_name: "prowler-reports", + output_directory: "output", + }, + }, + relationships: { + providers: { + data: [{ type: "providers", id: "aws-provider" }], + }, + }, + links: { + self: "/integrations/integration-1", + }, +}; + +describe("S3IntegrationForm", () => { + beforeEach(() => { + createIntegrationMock.mockReset(); + toastMock.mockReset(); + updateIntegrationMock.mockReset(); + }); + + it("should require the bucket owner account ID when it cannot derive one", async () => { + // Given + const user = userEvent.setup(); + renderS3IntegrationForm({ + providers: [createProvider("azure", "subscription-id")], + }); + + // When + await user.type(screen.getByLabelText(/Bucket name/i), "prowler-reports"); + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + expect( + await screen.findByText( + "Bucket owner account ID is required when no AWS account is selected", + ), + ).toBeVisible(); + expect( + screen.queryByLabelText("CloudFormation quick link"), + ).not.toBeInTheDocument(); + }); + + it("should derive the bucket owner account ID from the selected AWS provider", async () => { + // Given + const user = userEvent.setup(); + renderS3IntegrationForm({ + providers: [createProvider("aws", "123456789012")], + }); + + // When + await user.click( + screen.getByRole("button", { name: "Select first provider" }), + ); + await user.type(screen.getByLabelText(/Bucket name/i), "prowler-reports"); + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + expect( + await screen.findByLabelText("CloudFormation quick link"), + ).toHaveTextContent("bucketAccountId=123456789012"); + }); + + it("should not show a bucket account field that configuration updates cannot persist", () => { + // When + renderS3IntegrationForm({ + integration, + providers: [createProvider("aws", "123456789012")], + editMode: "configuration", + }); + + // Then + expect( + screen.queryByLabelText(/Bucket owner account ID/i), + ).not.toBeInTheDocument(); + }); + + it("should allow changing the bucket owner account for credential updates", () => { + // When + renderS3IntegrationForm({ + integration, + providers: [createProvider("aws", "123456789012")], + editMode: "credentials", + }); + + // Then + expect( + screen.getByLabelText(/Bucket owner account ID/i), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index 670e3c9aae..555e7d0d1f 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { useSession } from "next-auth/react"; import { useState } from "react"; -import { Control, useForm } from "react-hook-form"; +import type { Control } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { createIntegration, updateIntegration } from "@/actions/integrations"; import { @@ -25,13 +26,13 @@ import { import { FormButtons } from "@/components/shadcn/form/form-buttons"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { getAWSCredentialsTemplateLinks } from "@/lib"; -import { AWSCredentialsRole } from "@/types"; +import type { AWSCredentialsRole } from "@/types"; +import type { IntegrationProps } from "@/types/integrations"; import { editS3IntegrationFormSchema, - IntegrationProps, s3IntegrationFormSchema, } from "@/types/integrations"; -import { ProviderProps } from "@/types/providers"; +import type { ProviderProps } from "@/types/providers"; interface S3IntegrationFormProps { integration?: IntegrationProps | null; @@ -41,6 +42,25 @@ interface S3IntegrationFormProps { editMode?: "configuration" | "credentials" | null; // null means creating new } +const getSelectedAWSAccountId = ( + selectedProviderIds: string[], + providers: ProviderProps[], +): string => { + for (const providerId of selectedProviderIds) { + const provider = providers.find(({ id }) => id === providerId); + const uid = provider?.attributes.uid; + if ( + provider?.attributes.provider === "aws" && + uid && + /^\d{12}$/.test(uid) + ) { + return uid; + } + } + + return ""; +}; + export const S3IntegrationForm = ({ integration, providers, @@ -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 = () => ( +
+ +

+ {derivedBucketAccountId + ? `Leave empty to use selected AWS account ${derivedBucketAccountId}, or enter another bucket owner account ID.` + : "Required because the selected provider does not identify the AWS account that owns the bucket."} +

+
+ ); + const renderStepContent = () => { // If editing credentials, show only credentials form if (isEditingCredentials || currentStep === 1) { @@ -265,17 +336,21 @@ export const S3IntegrationForm = ({ externalId, bucketName, "amazon_s3", + resolvedBucketAccountId, ); return ( - } - setValue={form.setValue as any} - externalId={externalId} - templateLinks={templateLinks} - type="integrations" - integrationType="amazon_s3" - /> +
+ {isEditingCredentials && renderBucketAccountIdField()} + } + setValue={form.setValue as any} + externalId={externalId} + templateLinks={templateLinks} + type="integrations" + integrationType="amazon_s3" + /> +
); } @@ -346,6 +421,8 @@ export const S3IntegrationForm = ({ variant="bordered" isRequired /> + + {!isEditingConfig && renderBucketAccountIdField()} ); diff --git a/ui/components/providers/organizations/hooks/use-org-setup-submission.ts b/ui/components/providers/organizations/hooks/use-org-setup-submission.ts index b11903b48f..3d77ceff7a 100644 --- a/ui/components/providers/organizations/hooks/use-org-setup-submission.ts +++ b/ui/components/providers/organizations/hooks/use-org-setup-submission.ts @@ -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); diff --git a/ui/components/providers/organizations/org-setup-form.test.tsx b/ui/components/providers/organizations/org-setup-form.test.tsx new file mode 100644 index 0000000000..329d6fe05c --- /dev/null +++ b/ui/components/providers/organizations/org-setup-form.test.tsx @@ -0,0 +1,121 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useOrgSetupStore } from "@/store/organizations/store"; +import { ORG_SETUP_PHASE } from "@/types/organizations"; + +import { OrgSetupForm } from "./org-setup-form"; + +const { + setApiErrorMock, + submitOrganizationSetupMock, + updateOrganizationNameMock, +} = vi.hoisted(() => ({ + setApiErrorMock: vi.fn(), + submitOrganizationSetupMock: vi.fn(), + updateOrganizationNameMock: vi.fn(), +})); + +vi.mock("@/actions/organizations/organizations", () => ({ + updateOrganizationName: updateOrganizationNameMock, +})); + +vi.mock("@/lib", () => ({ + getAWSOrgDeploymentQuickLink: ({ + deployFromDelegatedAdmin, + }: { + deployFromDelegatedAdmin?: boolean; + }) => { + const params = new URLSearchParams(); + if (deployFromDelegatedAdmin) { + params.set("param_DeployFromDelegatedAdmin", "true"); + } + return `https://console.aws.amazon.com/#/quick-create?${params.toString()}`; + }, +})); + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ + data: { + tenantId: "tenant&id", + }, + }), +})); + +vi.mock("./hooks/use-org-setup-submission", () => ({ + useOrgSetupSubmission: () => ({ + apiError: null, + setApiError: setApiErrorMock, + submitOrganizationSetup: submitOrganizationSetupMock, + }), +})); + +function renderOrgSetupForm() { + return render( + , + ); +} + +describe("OrgSetupForm", () => { + beforeEach(() => { + setApiErrorMock.mockReset(); + submitOrganizationSetupMock.mockReset(); + updateOrganizationNameMock.mockReset(); + useOrgSetupStore.getState().reset(); + }); + + it("should render a real disabled button until the deployment link is valid", () => { + // Given + renderOrgSetupForm(); + + // When + const deploymentLink = screen.queryByRole("link", { + name: /create stack in management account/i, + }); + const deploymentButton = screen.getByRole("button", { + name: /create stack in management account/i, + }); + + // Then + expect(deploymentLink).not.toBeInTheDocument(); + expect(deploymentButton).toBeDisabled(); + }); + + it("should target the delegated administrator account when selected", async () => { + // Given + const user = userEvent.setup(); + renderOrgSetupForm(); + + // When + await user.type( + screen.getByLabelText("Organizational Unit or Root ID"), + "r-abcd", + ); + await user.click( + screen.getByRole("checkbox", { + name: /deploying from a delegated administrator account/i, + }), + ); + + // Then + const deploymentLink = await screen.findByRole("link", { + name: /create stack in delegated administrator account/i, + }); + const hashQuery = new URL( + deploymentLink.getAttribute("href") ?? "", + ).hash.split("?")[1]; + const params = new URLSearchParams(hashQuery); + + expect(params.get("param_DeployFromDelegatedAdmin")).toBe("true"); + expect( + screen.getByLabelText("Delegated Administrator Account Role ARN"), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/organizations/org-setup-form.tsx b/ui/components/providers/organizations/org-setup-form.tsx index a9852fa202..35fb13c938 100644 --- a/ui/components/providers/organizations/org-setup-form.tsx +++ b/ui/components/providers/organizations/org-setup-form.tsx @@ -3,20 +3,17 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Check, Copy, ExternalLink } from "lucide-react"; import { useSession } from "next-auth/react"; -import { FormEvent, useEffect, useRef, useState } from "react"; +import type { FormEvent } from "react"; +import { useEffect, useRef, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { updateOrganizationName } from "@/actions/organizations/organizations"; import { AWSProviderBadge } from "@/components/icons/providers-badge"; -import { - WIZARD_FOOTER_ACTION_TYPE, - WizardFooterConfig, -} from "@/components/providers/wizard/steps/footer-controls"; -import { - ORG_WIZARD_INTENT, - OrgWizardIntent, -} from "@/components/providers/wizard/types"; +import type { WizardFooterConfig } from "@/components/providers/wizard/steps/footer-controls"; +import { WIZARD_FOOTER_ACTION_TYPE } from "@/components/providers/wizard/steps/footer-controls"; +import type { OrgWizardIntent } from "@/components/providers/wizard/types"; +import { ORG_WIZARD_INTENT } from "@/components/providers/wizard/types"; import { WizardInputField } from "@/components/providers/workflow/forms/fields"; import { useToast } from "@/components/shadcn"; import { Alert, AlertDescription } from "@/components/shadcn/alert"; @@ -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>(undefined); - const templateUrlCopyTimer = useRef>(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(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({ - {/* Step 1: Management account - CloudFormation Stack */} + {/* Step 1: Choose the deployment target */}

- 1) Deploy the ProwlerScan role in your{" "} - management account using a CloudFormation - Stack. + 1) Choose the AWS Organizational Unit (or root) + to deploy to. Prowler creates the role in your deployment + account and rolls it out to every member account under this + target.

- + value.toLowerCase()} + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + /> +

+ Find this in the AWS Organizations console. Use your{" "} + root ID (starts with r-) to deploy + to the whole organization, or an OU ID (starts + with ou-) to target a specific unit. +

+
+ ( + <> + + field.onChange(Boolean(checked)) + } + /> + + + )} + /> +
- {/* Step 2: Member accounts - CloudFormation StackSet */} + {/* Step 2: Single CloudFormation Stack (role + StackSet) */}

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

-

- Open the StackSets console, select{" "} - Service-managed permissions, and paste the - template URL below. Set the ExternalId{" "} - parameter to the value shown above. -

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

+ Enter a valid Organizational Unit or Root ID above to enable + deployment. +

+ )}
{/* Step 3: Role ARN + confirm */}

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

- The Stack and StackSet have been successfully deployed in - AWS + The Stack has been successfully deployed in AWS * diff --git a/ui/lib/external-urls.test.ts b/ui/lib/external-urls.test.ts new file mode 100644 index 0000000000..0f82a4e0cd --- /dev/null +++ b/ui/lib/external-urls.test.ts @@ -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¶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_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:"); + }); +}); diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index 28cd9591e6..0ae17384a9 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -1,4 +1,4 @@ -import { IntegrationType } from "../types/integrations"; +import type { IntegrationType } from "../types/integrations"; // Documentation URLs export const DOCS_URLS = { @@ -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 => { + 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 - ? `¶m_EnableS3Integration=true¶m_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 = { + 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¶m_ExternalId=${externalId}${s3Params}`, - cloudformationOrgQuickLink: - `${cfBaseUrl}?templateURL=${encodedTemplateUrl}` + - `&stackName=Prowler¶m_ExternalId=${externalId}` + - `¶m_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 = { + param_ExternalId: externalId, + param_EnableOrganizations: "true", + param_DeployLocalRole: "true", + param_DeployStackSet: "true", + param_AWSOrganizationalUnitId: organizationalUnitId, + }; + + if (deployFromDelegatedAdmin) { + parameters.param_DeployFromDelegatedAdmin = "true"; + } + + return buildCloudFormationQuickCreateLink(parameters); +}; diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index 3ff525964e..b84869af1c 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -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,