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