mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): onboard Azure subscriptions from a Management Group (#12386)
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
|
||||
import { ORGANIZATION_TYPE } from "@/types/organizations";
|
||||
import type {
|
||||
AzureDiscoveredSubscription,
|
||||
AzureDiscoveryResult,
|
||||
GcpDiscoveredProject,
|
||||
GcpDiscoveryResult,
|
||||
} from "@/types/organizations";
|
||||
@@ -29,10 +31,14 @@ export const DISCOVERY_STATUS_VALUE = {
|
||||
export type DiscoveryStatusValue =
|
||||
(typeof DISCOVERY_STATUS_VALUE)[keyof typeof DISCOVERY_STATUS_VALUE];
|
||||
|
||||
/** Canonical node kinds (AWS organizational unit, GCP folder). */
|
||||
/**
|
||||
* Canonical node kinds (AWS organizational unit, GCP folder, Azure management
|
||||
* group). All kebab-case: `toNodeKind` rejects any other spelling.
|
||||
*/
|
||||
export const NODE_KIND = {
|
||||
ORGANIZATIONAL_UNIT: "organizational-unit",
|
||||
FOLDER: "folder",
|
||||
MANAGEMENT_GROUP: "management-group",
|
||||
} as const;
|
||||
export type NodeKind = (typeof NODE_KIND)[keyof typeof NODE_KIND];
|
||||
|
||||
@@ -133,9 +139,12 @@ export interface FixtureApplyOutcome {
|
||||
export interface FixtureDiscovery {
|
||||
id: string;
|
||||
status: DiscoveryStatusValue;
|
||||
/** Raw AWS or GCP discovery result served on the discovery poll. */
|
||||
/** Raw AWS, Azure or GCP discovery result served on the discovery poll. */
|
||||
result: unknown;
|
||||
/** Machine error code — never user copy. */
|
||||
error: string | null;
|
||||
/** Sanitized human message the server sends alongside the code, when it has one. */
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface FixtureScheduleBulkOutcome {
|
||||
@@ -156,7 +165,10 @@ export interface OrgFixture {
|
||||
providers: FixtureProvider[];
|
||||
discovery: FixtureDiscovery | null;
|
||||
apply: FixtureApplyOutcome;
|
||||
/** Connection outcomes keyed by provider uid (AWS account id / GCP project). */
|
||||
/**
|
||||
* Connection outcomes keyed by provider uid (AWS account id, GCP project id,
|
||||
* Azure subscription id).
|
||||
*/
|
||||
connectionByUid: Record<string, FixtureConnectionOutcome>;
|
||||
/** POST /organization-secrets returns 409 (duplicate). */
|
||||
duplicateSecret: boolean;
|
||||
@@ -426,6 +438,189 @@ export const buildGcpDiscoveryResult = ({
|
||||
};
|
||||
};
|
||||
|
||||
// --- Azure discovery result ------------------------------------------------
|
||||
|
||||
/** Canonical Management Group resource ID, the only identity Azure parents on. */
|
||||
const azureGroupId = (name: string) =>
|
||||
`/providers/Microsoft.Management/managementGroups/${name}`;
|
||||
|
||||
export const AZURE_TENANT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
/** Tenant root group — the target the setup form defaults to. */
|
||||
export const AZURE_ROOT_GROUP = azureGroupId(AZURE_TENANT_ID);
|
||||
export const AZURE_GROUP_ENGINEERING = azureGroupId("engineering");
|
||||
export const AZURE_GROUP_PLATFORM = azureGroupId("platform");
|
||||
/**
|
||||
* The two kinds of Management Group with nothing selectable in them: no
|
||||
* subscriptions at all, and only blocked ones. Neither changes the selectable
|
||||
* count, and both must still expand.
|
||||
*/
|
||||
export const AZURE_EMPTY_GROUP = azureGroupId("holding");
|
||||
export const AZURE_EMPTY_GROUP_NAME = "Holding";
|
||||
export const AZURE_BLOCKED_GROUP = azureGroupId("archived");
|
||||
export const AZURE_BLOCKED_GROUP_NAME = "Archived";
|
||||
|
||||
export const AZURE_SUBSCRIPTION_PROD_EU =
|
||||
"22222222-2222-4222-8222-222222222222";
|
||||
export const AZURE_SUBSCRIPTION_PROD_US =
|
||||
"33333333-3333-4333-8333-333333333333";
|
||||
/** Blocked, and hanging directly off the root group (`not_applicable` node relation). */
|
||||
export const AZURE_SUBSCRIPTION_LEGACY = "55555555-5555-4555-8555-555555555555";
|
||||
export const AZURE_BLOCKED_GROUP_SUBSCRIPTION =
|
||||
"44444444-4444-4444-8444-444444444444";
|
||||
/**
|
||||
* Blocked purely because it is not enabled — no provider exists and no linkage
|
||||
* conflict applies, the one blocked class Azure can raise on its own.
|
||||
*/
|
||||
export const AZURE_SUBSCRIPTION_DISABLED =
|
||||
"66666666-6666-4666-8666-666666666666";
|
||||
|
||||
/**
|
||||
* Blocked reasons Azure discovery reports: GCP's `*_conflict` vocabulary for the
|
||||
* three linkage/type conflicts, plus its own `subscription_not_enabled`, raised
|
||||
* whenever `state != "Enabled"`.
|
||||
*/
|
||||
export const AZURE_BLOCKED_REASON = {
|
||||
ORGANIZATION: "organization_conflict",
|
||||
ORGANIZATION_NODE: "organization_node_conflict",
|
||||
PROVIDER_TYPE: "provider_type_conflict",
|
||||
NOT_ENABLED: "subscription_not_enabled",
|
||||
} as const;
|
||||
|
||||
interface AzureResultOverrides {
|
||||
/** Subscription ids whose registration reports `will_replace`. */
|
||||
replaceSubscriptionIds?: string[];
|
||||
}
|
||||
|
||||
/** Pinned to the app's wire interfaces, for the same reason `GcpFixtureDiscoveryResult` is. */
|
||||
type AzureFixtureDiscoveryResult = Omit<
|
||||
AzureDiscoveryResult,
|
||||
"subscriptions"
|
||||
> & {
|
||||
subscriptions: (Omit<AzureDiscoveredSubscription, "registration"> & {
|
||||
registration: FixtureRegistration;
|
||||
})[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The Azure discovery result as the API shapes it: groups carry canonical resource
|
||||
* IDs in `id`/`parent_id`, subscriptions are identified by their UUID. Those UUIDs
|
||||
* are long by nature, so the id-column overflow case needs no special candidate.
|
||||
*/
|
||||
export const buildAzureDiscoveryResult = ({
|
||||
replaceSubscriptionIds = [],
|
||||
}: AzureResultOverrides = {}): AzureFixtureDiscoveryResult => {
|
||||
const subscription = (
|
||||
subscriptionId: string,
|
||||
displayName: string,
|
||||
parentId: string,
|
||||
registration: FixtureRegistration,
|
||||
state = "Enabled",
|
||||
) => ({
|
||||
subscription_id: subscriptionId,
|
||||
display_name: displayName,
|
||||
state,
|
||||
parent_id: parentId,
|
||||
registration,
|
||||
});
|
||||
|
||||
const readyRegFor = (subscriptionId: string): FixtureRegistration =>
|
||||
replaceSubscriptionIds.includes(subscriptionId)
|
||||
? readyRegistration({
|
||||
provider_exists: true,
|
||||
provider_id: `provider-existing-${subscriptionId}`,
|
||||
provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE,
|
||||
})
|
||||
: readyRegistration();
|
||||
|
||||
return {
|
||||
root_management_group: {
|
||||
id: AZURE_ROOT_GROUP,
|
||||
name: AZURE_TENANT_ID,
|
||||
display_name: "Tenant Root Group",
|
||||
tenant_id: AZURE_TENANT_ID,
|
||||
},
|
||||
management_groups: [
|
||||
{
|
||||
id: AZURE_GROUP_ENGINEERING,
|
||||
name: "engineering",
|
||||
display_name: "Engineering",
|
||||
parent_id: AZURE_ROOT_GROUP,
|
||||
},
|
||||
{
|
||||
id: AZURE_GROUP_PLATFORM,
|
||||
name: "platform",
|
||||
display_name: "Platform",
|
||||
parent_id: AZURE_GROUP_ENGINEERING,
|
||||
},
|
||||
{
|
||||
id: AZURE_EMPTY_GROUP,
|
||||
name: "holding",
|
||||
display_name: AZURE_EMPTY_GROUP_NAME,
|
||||
parent_id: AZURE_ROOT_GROUP,
|
||||
},
|
||||
{
|
||||
id: AZURE_BLOCKED_GROUP,
|
||||
name: "archived",
|
||||
display_name: AZURE_BLOCKED_GROUP_NAME,
|
||||
parent_id: AZURE_ROOT_GROUP,
|
||||
},
|
||||
],
|
||||
subscriptions: [
|
||||
subscription(
|
||||
AZURE_SUBSCRIPTION_PROD_EU,
|
||||
"Production EU",
|
||||
AZURE_GROUP_ENGINEERING,
|
||||
readyRegFor(AZURE_SUBSCRIPTION_PROD_EU),
|
||||
),
|
||||
subscription(
|
||||
AZURE_SUBSCRIPTION_PROD_US,
|
||||
"Production US",
|
||||
AZURE_GROUP_PLATFORM,
|
||||
readyRegFor(AZURE_SUBSCRIPTION_PROD_US),
|
||||
),
|
||||
subscription(
|
||||
AZURE_SUBSCRIPTION_LEGACY,
|
||||
"Legacy Sandbox",
|
||||
AZURE_ROOT_GROUP,
|
||||
blockedRegistration([AZURE_BLOCKED_REASON.ORGANIZATION], {
|
||||
provider_exists: true,
|
||||
provider_id: `provider-existing-${AZURE_SUBSCRIPTION_LEGACY}`,
|
||||
organization_relation: "linked_to_other_organization",
|
||||
organization_node_relation: "not_applicable",
|
||||
provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE,
|
||||
}),
|
||||
),
|
||||
subscription(
|
||||
AZURE_SUBSCRIPTION_DISABLED,
|
||||
"Dormant Sandbox",
|
||||
AZURE_ROOT_GROUP,
|
||||
blockedRegistration([AZURE_BLOCKED_REASON.NOT_ENABLED], {
|
||||
organization_node_relation: "not_applicable",
|
||||
}),
|
||||
"Disabled",
|
||||
),
|
||||
subscription(
|
||||
AZURE_BLOCKED_GROUP_SUBSCRIPTION,
|
||||
"Archived Legacy",
|
||||
AZURE_BLOCKED_GROUP,
|
||||
blockedRegistration(
|
||||
[
|
||||
AZURE_BLOCKED_REASON.ORGANIZATION,
|
||||
AZURE_BLOCKED_REASON.ORGANIZATION_NODE,
|
||||
],
|
||||
{
|
||||
provider_exists: true,
|
||||
provider_id: `provider-existing-${AZURE_BLOCKED_GROUP_SUBSCRIPTION}`,
|
||||
organization_relation: "linked_to_other_organization",
|
||||
organization_node_relation: "linked_to_other_node",
|
||||
provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE,
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
// --- Fixture builders ------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -441,6 +636,10 @@ export const GCP_CREATED_PROVIDER_IDS = [
|
||||
"bbbbbbb1-1111-4111-8111-111111111111",
|
||||
"bbbbbbb2-2222-4222-8222-222222222222",
|
||||
];
|
||||
export const AZURE_CREATED_PROVIDER_IDS = [
|
||||
"ccccccc1-1111-4111-8111-111111111111",
|
||||
"ccccccc2-2222-4222-8222-222222222222",
|
||||
];
|
||||
|
||||
const emptyApply = (): FixtureApplyOutcome => ({
|
||||
createdProviderIds: [],
|
||||
@@ -535,6 +734,51 @@ export const gcpOnboardingFixture = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A fresh Azure organization onboarding world (management groups +
|
||||
* subscriptions). Selection defaults to the two ready subscriptions; the
|
||||
* remaining three are blocked, one of them inside an otherwise empty Management
|
||||
* Group.
|
||||
*/
|
||||
export const azureOnboardingFixture = (
|
||||
overrides: Partial<OrgFixture> = {},
|
||||
): OrgFixture => {
|
||||
const createdProviderIds = AZURE_CREATED_PROVIDER_IDS;
|
||||
return {
|
||||
...baseFixture(),
|
||||
discovery: {
|
||||
id: "disc-azure-1",
|
||||
status: DISCOVERY_STATUS_VALUE.SUCCEEDED,
|
||||
result: buildAzureDiscoveryResult(),
|
||||
error: null,
|
||||
},
|
||||
apply: {
|
||||
...emptyApply(),
|
||||
createdProviderIds,
|
||||
providersCreatedCount: 2,
|
||||
nodesCreatedCount: 2,
|
||||
candidateProviderIds: [
|
||||
{
|
||||
candidateId: AZURE_SUBSCRIPTION_PROD_EU,
|
||||
providerId: createdProviderIds[0],
|
||||
},
|
||||
{
|
||||
candidateId: AZURE_SUBSCRIPTION_PROD_US,
|
||||
providerId: createdProviderIds[1],
|
||||
},
|
||||
],
|
||||
},
|
||||
connectionByUid: {
|
||||
[AZURE_SUBSCRIPTION_PROD_EU]: { connected: true },
|
||||
[AZURE_SUBSCRIPTION_PROD_US]: { connected: true },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
/** The AWS organization identifier of `awsHierarchyFixture`. */
|
||||
export const AWS_HIERARCHY_ORG_EXTERNAL_ID = "o-aws0abcdef";
|
||||
|
||||
/**
|
||||
* A providers-page hierarchy world with a fully onboarded AWS organization
|
||||
* (two OUs, three providers). Used for the providers-table grouping tests.
|
||||
@@ -593,7 +837,7 @@ export const awsHierarchyFixture = (
|
||||
id: orgId,
|
||||
orgType: ORGANIZATION_TYPE.AWS,
|
||||
name: "My AWS Organization",
|
||||
externalId: "o-aws0abcdef",
|
||||
externalId: AWS_HIERARCHY_ORG_EXTERNAL_ID,
|
||||
rootExternalId: AWS_ROOT_ID,
|
||||
providerIds: [],
|
||||
nodeIds: nodes.map((n) => n.id),
|
||||
@@ -606,11 +850,63 @@ export const awsHierarchyFixture = (
|
||||
};
|
||||
};
|
||||
|
||||
/** AWS + GCP organizations side by side (mixed-hierarchy display test). */
|
||||
/**
|
||||
* Management Groups of an already-onboarded Azure organization. Named apart from
|
||||
* the discovery fixture's groups (and from the AWS/GCP containers) so a row
|
||||
* lookup by label can only resolve to one hierarchy.
|
||||
*/
|
||||
const AZURE_HIERARCHY_GROUP = azureGroupId("landing-zones");
|
||||
export const AZURE_HIERARCHY_GROUP_NAME = "Landing Zones";
|
||||
const AZURE_HIERARCHY_CHILD_GROUP = azureGroupId("decommissioned");
|
||||
export const AZURE_HIERARCHY_CHILD_GROUP_NAME = "Decommissioned";
|
||||
/** The Azure organization of `mixedHierarchyFixture`, and its Management Group node. */
|
||||
export const AZURE_ORG_NAME = "My Azure Organization";
|
||||
export const AZURE_GROUP_NODE_ID = "node-azure-lz";
|
||||
|
||||
/** AWS + Azure + GCP organizations side by side (mixed-hierarchy display test). */
|
||||
export const mixedHierarchyFixture = (
|
||||
overrides: Partial<OrgFixture> = {},
|
||||
): OrgFixture => {
|
||||
const aws = awsHierarchyFixture();
|
||||
const azureOrgId = "org-azure-1";
|
||||
const azureProviders: FixtureProvider[] = [
|
||||
{
|
||||
id: "azp-1",
|
||||
provider: "azure",
|
||||
uid: AZURE_SUBSCRIPTION_PROD_EU,
|
||||
alias: "Contoso EU",
|
||||
connected: true,
|
||||
},
|
||||
{
|
||||
id: "azp-2",
|
||||
provider: "azure",
|
||||
uid: AZURE_SUBSCRIPTION_PROD_US,
|
||||
alias: "Contoso US",
|
||||
connected: true,
|
||||
},
|
||||
];
|
||||
const azureNodes: FixtureNode[] = [
|
||||
{
|
||||
id: AZURE_GROUP_NODE_ID,
|
||||
kind: NODE_KIND.MANAGEMENT_GROUP,
|
||||
name: AZURE_HIERARCHY_GROUP_NAME,
|
||||
externalId: AZURE_HIERARCHY_GROUP,
|
||||
// The tenant-root Management Group is never persisted as a node, so this
|
||||
// parent id deliberately resolves to no node row.
|
||||
parentExternalId: AZURE_ROOT_GROUP,
|
||||
organizationId: azureOrgId,
|
||||
providerIds: ["azp-1"],
|
||||
},
|
||||
{
|
||||
id: "node-azure-decommissioned",
|
||||
kind: NODE_KIND.MANAGEMENT_GROUP,
|
||||
name: AZURE_HIERARCHY_CHILD_GROUP_NAME,
|
||||
externalId: AZURE_HIERARCHY_CHILD_GROUP,
|
||||
parentExternalId: AZURE_HIERARCHY_GROUP,
|
||||
organizationId: azureOrgId,
|
||||
providerIds: ["azp-2"],
|
||||
},
|
||||
];
|
||||
const gcpOrgId = "org-gcp-1";
|
||||
const gcpProviders: FixtureProvider[] = [
|
||||
{
|
||||
@@ -652,6 +948,18 @@ export const mixedHierarchyFixture = (
|
||||
...baseFixture(),
|
||||
organizations: [
|
||||
...aws.organizations,
|
||||
{
|
||||
id: azureOrgId,
|
||||
orgType: ORGANIZATION_TYPE.AZURE,
|
||||
name: AZURE_ORG_NAME,
|
||||
externalId: AZURE_TENANT_ID,
|
||||
// Azure is the one type that writes its own root: the Management Group
|
||||
// the organization is scoped to.
|
||||
rootExternalId: AZURE_ROOT_GROUP,
|
||||
providerIds: [],
|
||||
nodeIds: azureNodes.map((n) => n.id),
|
||||
secretId: "secret-azure-1",
|
||||
},
|
||||
{
|
||||
id: gcpOrgId,
|
||||
orgType: ORGANIZATION_TYPE.GCP,
|
||||
@@ -665,12 +973,22 @@ export const mixedHierarchyFixture = (
|
||||
secretId: "secret-gcp-1",
|
||||
},
|
||||
],
|
||||
nodes: [...aws.nodes, ...gcpNodes],
|
||||
providers: [...aws.providers, ...gcpProviders],
|
||||
nodes: [...aws.nodes, ...azureNodes, ...gcpNodes],
|
||||
providers: [...aws.providers, ...azureProviders, ...gcpProviders],
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* An `org_type` this build has no onboarding flow for. Every `ORGANIZATION_TYPE`
|
||||
* value is onboardable now, so this has to come from outside the enum — which
|
||||
* mirrors the real case, the enum tracking a server-side one. `oraclecloud` is a
|
||||
* real provider type, so its provider rows still render coherently.
|
||||
*/
|
||||
export const DISPLAY_ONLY_ORG_TYPE = "oraclecloud";
|
||||
export const DISPLAY_ONLY_ORG_NAME = "My Oracle Cloud Tenancy";
|
||||
export const DISPLAY_ONLY_PROVIDER_ALIAS = "oci-prod";
|
||||
|
||||
/**
|
||||
* An organization of a type the wizard cannot onboard (display-only): it is still
|
||||
* grouped and labelled from its own `org_type`, but offers no wizard re-entry.
|
||||
@@ -678,17 +996,17 @@ export const mixedHierarchyFixture = (
|
||||
export const displayOnlyOrgHierarchyFixture = (
|
||||
overrides: Partial<OrgFixture> = {},
|
||||
): OrgFixture => {
|
||||
const orgId = "org-azure-1";
|
||||
const orgId = "org-display-only-1";
|
||||
return {
|
||||
...baseFixture(),
|
||||
organizations: [
|
||||
{
|
||||
id: orgId,
|
||||
orgType: ORGANIZATION_TYPE.AZURE,
|
||||
name: "Contoso Tenant",
|
||||
externalId: "11111111-2222-3333-4444-555555555555",
|
||||
orgType: DISPLAY_ONLY_ORG_TYPE,
|
||||
name: DISPLAY_ONLY_ORG_NAME,
|
||||
externalId: "ocid1.tenancy.oc1..aaaa1111",
|
||||
rootExternalId: null,
|
||||
providerIds: ["ap-1"],
|
||||
providerIds: ["op-1"],
|
||||
nodeIds: [],
|
||||
secretId: null,
|
||||
},
|
||||
@@ -696,10 +1014,10 @@ export const displayOnlyOrgHierarchyFixture = (
|
||||
nodes: [],
|
||||
providers: [
|
||||
{
|
||||
id: "ap-1",
|
||||
provider: "azure",
|
||||
uid: "99999999-8888-7777-6666-555555555555",
|
||||
alias: "contoso-prod",
|
||||
id: "op-1",
|
||||
provider: DISPLAY_ONLY_ORG_TYPE,
|
||||
uid: "ocid1.compartment.oc1..bbbb2222",
|
||||
alias: DISPLAY_ONLY_PROVIDER_ALIAS,
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
@@ -709,6 +1027,7 @@ export const displayOnlyOrgHierarchyFixture = (
|
||||
|
||||
export const fixtures = {
|
||||
awsOnboarding: awsOnboardingFixture,
|
||||
azureOnboarding: azureOnboardingFixture,
|
||||
gcpOnboarding: gcpOnboardingFixture,
|
||||
awsHierarchy: awsHierarchyFixture,
|
||||
mixedHierarchy: mixedHierarchyFixture,
|
||||
|
||||
@@ -347,7 +347,11 @@ export const handlersForOrganizations = (
|
||||
orgType: String(attrs.org_type ?? "aws"),
|
||||
name: String(attrs.name ?? ""),
|
||||
externalId: String(attrs.external_id ?? ""),
|
||||
rootExternalId: null,
|
||||
// Writable on POST for Azure (the target Management Group); AWS and GCP
|
||||
// leave it to discovery and send nothing.
|
||||
rootExternalId: attrs.root_external_id
|
||||
? String(attrs.root_external_id)
|
||||
: null,
|
||||
providerIds: [],
|
||||
nodeIds: [],
|
||||
secretId: null,
|
||||
@@ -503,6 +507,8 @@ export const handlersForOrganizations = (
|
||||
result:
|
||||
fx.discovery.status === "succeeded" ? fx.discovery.result : {},
|
||||
error: fx.discovery.error,
|
||||
// Machine code and human message are separate fields on the wire.
|
||||
error_message: fx.discovery.errorMessage ?? null,
|
||||
inserted_at: TS,
|
||||
updated_at: TS,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
ApplyDiscoveryPayload,
|
||||
AwsDiscoveryResult,
|
||||
AwsOrgHierarchy,
|
||||
AzureDiscoveryResult,
|
||||
AzureOrgHierarchy,
|
||||
GcpDiscoveryResult,
|
||||
GcpOrgHierarchy,
|
||||
NODE_KIND,
|
||||
@@ -84,6 +86,43 @@ export function mapGcpDiscovery(result: GcpDiscoveryResult): GcpOrgHierarchy {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingestion mapper: Azure discovery wire result → normalized hierarchy model.
|
||||
*
|
||||
* A management group's identity is its canonical resource ID
|
||||
* (`/providers/Microsoft.Management/managementGroups/{name}`), which is exactly
|
||||
* what its children carry as `parent_id`, so nesting matches on that ref. The
|
||||
* selected root group is collapsed away — it is absent from the node set, so
|
||||
* groups and subscriptions parented by it rebuild as top-level.
|
||||
*/
|
||||
export function mapAzureDiscovery(
|
||||
result: AzureDiscoveryResult,
|
||||
): AzureOrgHierarchy {
|
||||
return {
|
||||
orgType: ORGANIZATION_TYPE.AZURE,
|
||||
organization: {
|
||||
// Tenant id: what the user typed and what the organization stores as
|
||||
// `external_id`.
|
||||
uid: result.root_management_group.tenant_id,
|
||||
name:
|
||||
result.root_management_group.display_name ||
|
||||
result.root_management_group.name,
|
||||
},
|
||||
nodes: result.management_groups.map((group) => ({
|
||||
id: group.id,
|
||||
kind: NODE_KIND.MANAGEMENT_GROUP,
|
||||
name: group.display_name || group.name,
|
||||
parentId: group.parent_id,
|
||||
})),
|
||||
candidates: result.subscriptions.map((subscription) => ({
|
||||
uid: subscription.subscription_id,
|
||||
label: subscription.display_name || subscription.subscription_id,
|
||||
parentId: subscription.parent_id,
|
||||
registration: subscription.registration,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the normalized hierarchy into hierarchical TreeDataItem[] for
|
||||
* TreeView. Container nodes (OUs / folders) nest candidates (accounts /
|
||||
@@ -338,6 +377,14 @@ export function buildApplyPayload(
|
||||
selectedCandidateIds,
|
||||
).map((id) => ({ id })),
|
||||
};
|
||||
case ORGANIZATION_TYPE.AZURE:
|
||||
return {
|
||||
orgType: ORGANIZATION_TYPE.AZURE,
|
||||
subscriptions: selectedCandidateIds.map((id) => ({
|
||||
subscription_id: id,
|
||||
...aliasOf(id),
|
||||
})),
|
||||
};
|
||||
case ORGANIZATION_TYPE.GCP:
|
||||
return {
|
||||
orgType: ORGANIZATION_TYPE.GCP,
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("organizations actions", () => {
|
||||
it("rejects invalid organization secret identifiers", async () => {
|
||||
// When
|
||||
const result = await updateOrganizationSecret("../secret-id", {
|
||||
orgType: ORGANIZATION_TYPE.AWS,
|
||||
secretType: ORG_SECRET_TYPE.ROLE,
|
||||
secret: {
|
||||
role_arn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
|
||||
@@ -85,13 +86,13 @@ describe("organizations actions", () => {
|
||||
});
|
||||
|
||||
it("rejects an organization type with no onboarding flow instead of coercing it", async () => {
|
||||
// Given a form asking for a type this build cannot onboard. `azure` is a
|
||||
// real OrganizationType — display supports it, onboarding does not — so it
|
||||
// is the exact boundary a blind cast would let through.
|
||||
// Given a type this build cannot onboard. `oraclecloud` is real — display
|
||||
// supports it, onboarding does not — the exact boundary a blind cast lets
|
||||
// through.
|
||||
const formData = new FormData();
|
||||
formData.set("name", "Contoso");
|
||||
formData.set("externalId", "o-abc123def4");
|
||||
formData.set("orgType", ORGANIZATION_TYPE.AZURE);
|
||||
formData.set("name", "Tenancy");
|
||||
formData.set("externalId", "ocid1.tenancy.oc1..aaaa1111");
|
||||
formData.set("orgType", "oraclecloud");
|
||||
|
||||
// When
|
||||
const result = await createOrganization(formData);
|
||||
|
||||
@@ -489,11 +489,34 @@ export const getDiscovery = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON:API attributes for an apply request. The `default` arm is the
|
||||
* exhaustiveness guard — `noImplicitReturns` is off.
|
||||
*/
|
||||
function buildApplyAttributes(payload: ApplyDiscoveryPayload) {
|
||||
switch (payload.orgType) {
|
||||
case ORGANIZATION_TYPE.AWS:
|
||||
return {
|
||||
accounts: payload.accounts,
|
||||
organizational_units: payload.organizationalUnits,
|
||||
};
|
||||
case ORGANIZATION_TYPE.AZURE:
|
||||
return { subscriptions: payload.subscriptions };
|
||||
case ORGANIZATION_TYPE.GCP:
|
||||
return { projects: payload.projects };
|
||||
default: {
|
||||
const exhaustivePayload: never = payload;
|
||||
return exhaustivePayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies discovery results — creates providers, links to org/nodes,
|
||||
* auto-generates secrets. The payload is discriminated by organization type:
|
||||
* AWS sends `accounts` + client-side-derived `organizational_units`; GCP sends
|
||||
* `projects` only (folder ancestors are derived server-side).
|
||||
* `projects` and Azure `subscriptions` only (folder / Management Group ancestors
|
||||
* are derived server-side).
|
||||
* POST /api/v1/organizations/{orgId}/discoveries/{discoveryId}/apply
|
||||
*/
|
||||
export const applyDiscovery = async (
|
||||
@@ -525,13 +548,7 @@ export const applyDiscovery = async (
|
||||
// whole request. The created providers' uids are read afterwards instead, with
|
||||
// `getProviderUidsByIds`.
|
||||
|
||||
const attributes =
|
||||
payload.orgType === ORGANIZATION_TYPE.AWS
|
||||
? {
|
||||
accounts: payload.accounts,
|
||||
organizational_units: payload.organizationalUnits,
|
||||
}
|
||||
: { projects: payload.projects };
|
||||
const attributes = buildApplyAttributes(payload);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
|
||||
@@ -38,6 +38,17 @@ interface MountOptions {
|
||||
hierarchyFailure?: HierarchyReadFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attributes a `POST /organizations` carries. `root_external_id` is deliberately
|
||||
* absent: a test asserting the client sends none reads the parsed body, not this
|
||||
* shape.
|
||||
*/
|
||||
export interface OrganizationCreateAttributes {
|
||||
name?: string;
|
||||
org_type?: string;
|
||||
external_id?: string;
|
||||
}
|
||||
|
||||
export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
get applyCallCount(): number {
|
||||
return this.countRequests("POST", "/apply");
|
||||
@@ -81,6 +92,27 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
return this.countRequests("GET", "/organizations");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attributes the organization was created with. Matched on the collection path
|
||||
* itself rather than a substring: the nested `discover` and `apply` POSTs also
|
||||
* live under `/organizations` and carry no body to read.
|
||||
*/
|
||||
async createdOrganizationAttributes(): Promise<OrganizationCreateAttributes | null> {
|
||||
const entry = [...this.requestLog]
|
||||
.reverse()
|
||||
.find(
|
||||
(r) =>
|
||||
r.method === "POST" &&
|
||||
new URL(r.url).pathname.endsWith("/organizations"),
|
||||
);
|
||||
if (!entry) return null;
|
||||
|
||||
const body = (await entry.request.clone().json()) as {
|
||||
data?: { attributes?: OrganizationCreateAttributes };
|
||||
};
|
||||
return body?.data?.attributes ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times the page fetched the organization hierarchy over HTTP —
|
||||
* either route, so this stays a tripwire for "the hierarchy is still
|
||||
@@ -157,7 +189,7 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
await this.user.click(option);
|
||||
}
|
||||
|
||||
/** Click a method card in the AWS/GCP method selector by its title. */
|
||||
/** Click a method card in a provider's method selector by its title. */
|
||||
async chooseMethod(name: RegExp): Promise<void> {
|
||||
const card = await this.waitFor(() => this.byRoleName("radio", name));
|
||||
await this.user.click(card);
|
||||
@@ -182,6 +214,20 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
await this.waitForText(/Organization Details/);
|
||||
}
|
||||
|
||||
/** Select Azure and open the Management Group method card (no advance wait). */
|
||||
async chooseAzureOrganizationsMethod(): Promise<void> {
|
||||
await this.selectProviderType(/Microsoft Azure/);
|
||||
await this.chooseMethod(
|
||||
/Add Multiple Subscriptions With Azure Management Group/,
|
||||
);
|
||||
}
|
||||
|
||||
/** Enter the Azure Management Group onboarding flow from a fresh wizard. */
|
||||
async chooseAzureOrganizations(): Promise<void> {
|
||||
await this.chooseAzureOrganizationsMethod();
|
||||
await this.waitForText(/Organization Details/);
|
||||
}
|
||||
|
||||
/** Wait until the wizard shows the selected provider's method fork. */
|
||||
async waitForMethodStep(): Promise<void> {
|
||||
await this.waitForText(/Select a method to add your/);
|
||||
@@ -219,6 +265,33 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
await this.user.fill(textarea, json);
|
||||
}
|
||||
|
||||
// --- Wizard: Azure setup step ------------------------------------------
|
||||
|
||||
/** Fill the Azure organization-details phase (the tenant is the only id asked for). */
|
||||
async fillAzureOrgDetails(tenantId: string, name?: string): Promise<void> {
|
||||
const tenantInput = await this.waitFor(() => this.inputByName("tenantId"));
|
||||
await this.user.fill(tenantInput, tenantId);
|
||||
if (name !== undefined) {
|
||||
const nameInput = this.inputByName("organizationName");
|
||||
if (nameInput) await this.user.fill(nameInput, name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill the Azure service-principal credentials on the authentication step. */
|
||||
async fillAzureCredentials(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<void> {
|
||||
const clientIdInput = await this.waitFor(() =>
|
||||
this.inputByName("clientId"),
|
||||
);
|
||||
await this.user.fill(clientIdInput, clientId);
|
||||
const secretInput = await this.waitFor(() =>
|
||||
this.inputByName("clientSecret"),
|
||||
);
|
||||
await this.user.fill(secretInput, clientSecret);
|
||||
}
|
||||
|
||||
// --- Wizard: AWS setup step --------------------------------------------
|
||||
|
||||
async fillAwsOrgDetails(orgId: string, name?: string): Promise<void> {
|
||||
@@ -292,18 +365,38 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
|
||||
/**
|
||||
* Whether the pre-apply warning states how many already-onboarded candidates the
|
||||
* apply would overwrite, and names them.
|
||||
* apply would overwrite, and names them — in the noun this organization type uses.
|
||||
*/
|
||||
private hasOverwriteWarningFor(
|
||||
count: number,
|
||||
names: string[],
|
||||
noun: string,
|
||||
): boolean {
|
||||
const states = this.containsText(
|
||||
new RegExp(
|
||||
`overwrite the credentials of ${count} already-onboarded ${noun}`,
|
||||
),
|
||||
);
|
||||
return states && names.every((name) => this.containsText(new RegExp(name)));
|
||||
}
|
||||
|
||||
hasApplyOverwriteWarning(
|
||||
projectCount: number,
|
||||
names: string[] = [],
|
||||
): boolean {
|
||||
const states = this.containsText(
|
||||
new RegExp(
|
||||
`overwrite the credentials of ${projectCount} already-onboarded project`,
|
||||
),
|
||||
return this.hasOverwriteWarningFor(projectCount, names, "project");
|
||||
}
|
||||
|
||||
/** The same warning, in Azure's noun. */
|
||||
hasApplySubscriptionOverwriteWarning(
|
||||
subscriptionCount: number,
|
||||
names: string[] = [],
|
||||
): boolean {
|
||||
return this.hasOverwriteWarningFor(
|
||||
subscriptionCount,
|
||||
names,
|
||||
"subscription",
|
||||
);
|
||||
return states && names.every((name) => this.containsText(new RegExp(name)));
|
||||
}
|
||||
|
||||
/** Confirm the pre-apply credential overwrite and continue into apply. */
|
||||
@@ -316,6 +409,22 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
await this.waitForText(/Authentication failed/, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a failed discovery surfaces the actionable copy for its machine
|
||||
* code, rather than the type's generic authentication-failure fallback.
|
||||
*/
|
||||
async waitForDiscoveryFailureReason(
|
||||
reason: RegExp,
|
||||
timeoutMs = 15000,
|
||||
): Promise<void> {
|
||||
await this.waitForText(reason, timeoutMs);
|
||||
}
|
||||
|
||||
/** Whether that reason is showing — the negative half of the assertion. */
|
||||
hasDiscoveryFailureReason(reason: RegExp): boolean {
|
||||
return this.containsText(reason);
|
||||
}
|
||||
|
||||
/** Retry a failed/timed-out discovery with a fresh one. */
|
||||
async retryDiscovery(): Promise<void> {
|
||||
await this.clickButton(/Retry discovery/);
|
||||
@@ -342,11 +451,12 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
return this.treeItems.find((el) => text.test(el.textContent ?? "")) ?? null;
|
||||
}
|
||||
|
||||
// Noun-agnostic on purpose: the copy says "accounts" for AWS, "projects" for GCP.
|
||||
// Noun-agnostic on purpose: the copy says "accounts" for AWS, "projects" for
|
||||
// GCP, "subscriptions" for Azure.
|
||||
private selectedCountText(): string {
|
||||
return (
|
||||
this.container.textContent?.match(
|
||||
/\d+ of \d+ (?:accounts|projects) selected/,
|
||||
/\d+ of \d+ (?:accounts|projects|subscriptions) selected/,
|
||||
)?.[0] ?? ""
|
||||
);
|
||||
}
|
||||
@@ -365,12 +475,44 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
return this.waitFor(() => this.tree);
|
||||
}
|
||||
|
||||
/** A container row identifies itself by its uid: a GCP folder ref or an AWS OU id. */
|
||||
private static readonly CONTAINER_UID = /folders\/\d+|ou-[\w-]+/;
|
||||
/**
|
||||
* A container row's uid: a GCP folder ref, an AWS OU id, or an Azure Management
|
||||
* Group resource id. Anchored because it is matched against one element's own
|
||||
* value — the adjacent uid and name columns would otherwise run together.
|
||||
*/
|
||||
private static readonly CONTAINER_UID =
|
||||
/^(?:folders\/\d+|ou-[\w-]+|\/providers\/Microsoft\.Management\/managementGroups\/[\w.()-]+)$/;
|
||||
|
||||
/**
|
||||
* The canonical uid an id column carries, which `TruncatedId` puts in its
|
||||
* accessible name; the text fallback covers spans it did not render.
|
||||
*/
|
||||
private static columnUid(column: HTMLElement): string {
|
||||
const uid = column.getAttribute("aria-label") ?? column.textContent ?? "";
|
||||
|
||||
return uid.trim();
|
||||
}
|
||||
|
||||
/** A row's id column, found by the uid it resolves to. */
|
||||
private static idColumn(row: HTMLElement): HTMLElement | null {
|
||||
return (
|
||||
Array.from(row.querySelectorAll<HTMLElement>("span")).find((span) =>
|
||||
ProvidersPageHarness.CONTAINER_UID.test(
|
||||
ProvidersPageHarness.columnUid(span),
|
||||
),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private static containerUid(row: HTMLElement): string | null {
|
||||
const column = ProvidersPageHarness.idColumn(row);
|
||||
|
||||
return column ? ProvidersPageHarness.columnUid(column) : null;
|
||||
}
|
||||
|
||||
private get containerRows(): HTMLElement[] {
|
||||
return this.treeItems.filter((item) =>
|
||||
ProvidersPageHarness.CONTAINER_UID.test(item.textContent ?? ""),
|
||||
return this.treeItems.filter(
|
||||
(item) => ProvidersPageHarness.containerUid(item) !== null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -387,11 +529,19 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
/** Uids rendered by container rows — a row with an unresolved id has none. */
|
||||
containerRowUids(): string[] {
|
||||
return this.containerRows.flatMap(
|
||||
(item) =>
|
||||
item.textContent?.match(ProvidersPageHarness.CONTAINER_UID) ?? [],
|
||||
(item) => ProvidersPageHarness.containerUid(item) ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Visible text of the id column whose accessible name is `uid`. */
|
||||
containerIdLabel(uid: string): string | null {
|
||||
const column = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("[aria-label]"),
|
||||
).find((el) => el.getAttribute("aria-label") === uid);
|
||||
|
||||
return column?.textContent?.trim() ?? null;
|
||||
}
|
||||
|
||||
/** Whether a candidate row is rendered inside the subtree of a container. */
|
||||
isCandidateNestedUnder(
|
||||
candidateUid: string,
|
||||
@@ -414,8 +564,11 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
const row = this.containerRows.find((item) =>
|
||||
(item.textContent ?? "").includes(containerLabel),
|
||||
);
|
||||
// The id column carries `role="img"` too, and comes first in the row, so the
|
||||
// note is the one matched by its svg.
|
||||
return (
|
||||
row?.querySelector('[role="img"]')?.getAttribute("aria-label") ?? null
|
||||
row?.querySelector('[role="img"]:has(svg)')?.getAttribute("aria-label") ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -526,6 +679,28 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
return this.hasSelectionSummary(selected, total, "projects");
|
||||
}
|
||||
|
||||
/** Wait until subscription discovery finishes and the selection summary renders. */
|
||||
async waitForSubscriptionSelection(timeoutMs = 15000): Promise<void> {
|
||||
await this.waitForText(/of \d+ subscriptions selected/, timeoutMs);
|
||||
}
|
||||
|
||||
/** Wait until the summary reads "<selected> of <total> subscriptions selected". */
|
||||
async waitForSelectedSubscriptionCount(
|
||||
selected: number,
|
||||
total: number,
|
||||
timeoutMs = 15000,
|
||||
): Promise<void> {
|
||||
await this.waitForText(
|
||||
new RegExp(`${selected} of ${total} subscriptions selected`),
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the summary reads "<selected> of <total> subscriptions selected". */
|
||||
hasSelectedSubscriptionCount(selected: number, total: number): boolean {
|
||||
return this.hasSelectionSummary(selected, total, "subscriptions");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any visible copy uses the AWS candidate noun, which a GCP flow must
|
||||
* never say — the negative half of the terminology assertions.
|
||||
@@ -628,6 +803,11 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
await this.waitForText(/Projects Connected!/, timeoutMs);
|
||||
}
|
||||
|
||||
/** Wait until every selected subscription has connected successfully. */
|
||||
async waitForSubscriptionsConnected(timeoutMs = 20000): Promise<void> {
|
||||
await this.waitForText(/Subscriptions Connected!/, timeoutMs);
|
||||
}
|
||||
|
||||
/** Wait until the flow reaches the connected / ready-to-scan state. */
|
||||
async waitForReadyToScan(timeoutMs = 20000): Promise<void> {
|
||||
await this.waitForText(/Accounts Connected!|ready to Scan/, timeoutMs);
|
||||
@@ -844,6 +1024,12 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
await this.clickMenuItem(/Delete Folder/);
|
||||
}
|
||||
|
||||
/** Open the delete flow for an Azure Management Group node. */
|
||||
async openDeleteManagementGroupFor(name: string): Promise<void> {
|
||||
await this.openActionsFor(name);
|
||||
await this.clickMenuItem(/Delete Management Group/);
|
||||
}
|
||||
|
||||
/** Wait until the wizard re-opens on the AWS authentication step. */
|
||||
async waitForAuthenticationStep(): Promise<void> {
|
||||
await this.waitForText(
|
||||
@@ -957,9 +1143,7 @@ export class ProvidersPageHarness extends BrowserHarness<OrgFixture> {
|
||||
|
||||
/** Wait until the inline edit-name modal is open. */
|
||||
async waitForEditNameModal(): Promise<void> {
|
||||
await this.waitForText(
|
||||
/If left blank, Prowler will use the name stored in AWS/,
|
||||
);
|
||||
await this.waitForText(/Edit Organization Name/);
|
||||
}
|
||||
|
||||
async fillEditName(value: string): Promise<void> {
|
||||
|
||||
@@ -6,9 +6,32 @@ import { describe, expect } from "vitest";
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import { HIERARCHY_READ_FAILURE } from "@/__tests__/msw/handlers/organizations";
|
||||
import {
|
||||
AWS_HIERARCHY_ORG_EXTERNAL_ID,
|
||||
awsHierarchyFixture,
|
||||
awsOnboardingFixture,
|
||||
AZURE_BLOCKED_GROUP,
|
||||
AZURE_BLOCKED_GROUP_NAME,
|
||||
AZURE_BLOCKED_GROUP_SUBSCRIPTION,
|
||||
AZURE_CREATED_PROVIDER_IDS,
|
||||
AZURE_EMPTY_GROUP,
|
||||
AZURE_EMPTY_GROUP_NAME,
|
||||
AZURE_GROUP_ENGINEERING,
|
||||
AZURE_GROUP_NODE_ID,
|
||||
AZURE_GROUP_PLATFORM,
|
||||
AZURE_HIERARCHY_CHILD_GROUP_NAME,
|
||||
AZURE_HIERARCHY_GROUP_NAME,
|
||||
AZURE_ORG_NAME,
|
||||
AZURE_ROOT_GROUP,
|
||||
AZURE_SUBSCRIPTION_DISABLED,
|
||||
AZURE_SUBSCRIPTION_LEGACY,
|
||||
AZURE_SUBSCRIPTION_PROD_EU,
|
||||
AZURE_SUBSCRIPTION_PROD_US,
|
||||
AZURE_TENANT_ID,
|
||||
azureOnboardingFixture,
|
||||
buildAzureDiscoveryResult,
|
||||
buildGcpDiscoveryResult,
|
||||
DISPLAY_ONLY_ORG_NAME,
|
||||
DISPLAY_ONLY_PROVIDER_ALIAS,
|
||||
displayOnlyOrgHierarchyFixture,
|
||||
DISCOVERY_STATUS_VALUE,
|
||||
GCP_BLOCKED_FOLDER,
|
||||
@@ -97,8 +120,14 @@ interface ApplyProjectRequest {
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
interface ApplySubscriptionRequest {
|
||||
subscription_id: string;
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
interface ApplyRequestAttributes {
|
||||
projects?: ApplyProjectRequest[];
|
||||
subscriptions?: ApplySubscriptionRequest[];
|
||||
accounts?: unknown;
|
||||
organizational_units?: unknown;
|
||||
}
|
||||
@@ -111,6 +140,57 @@ interface ApplyRequestBody {
|
||||
data: ApplyRequestData;
|
||||
}
|
||||
|
||||
interface RenameRequestAttributes {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface RenameRequestData {
|
||||
attributes: RenameRequestAttributes;
|
||||
}
|
||||
|
||||
interface RenameRequestBody {
|
||||
data: RenameRequestData;
|
||||
}
|
||||
|
||||
interface OrganizationSecretAttributes {
|
||||
secret_type?: string;
|
||||
secret?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface OrganizationSecretRequestData {
|
||||
attributes: OrganizationSecretAttributes;
|
||||
}
|
||||
|
||||
interface OrganizationSecretRequestBody {
|
||||
data: OrganizationSecretRequestData;
|
||||
}
|
||||
|
||||
const AZURE_CLIENT_ID = "99999999-9999-4999-8999-999999999999";
|
||||
const AZURE_CLIENT_SECRET = "azure-client-secret";
|
||||
const AZURE_ORG_DOCS = "prowler-cloud-azure-management-groups";
|
||||
|
||||
/** Drive a fresh Azure org onboarding up to the authentication submit. */
|
||||
async function authenticateAzureOrg(
|
||||
harness: ProvidersPageHarness,
|
||||
{ name }: { name?: string } = {},
|
||||
): Promise<void> {
|
||||
await harness.mount();
|
||||
await harness.chooseAzureOrganizations();
|
||||
await harness.fillAzureOrgDetails(AZURE_TENANT_ID, name);
|
||||
await harness.submitOrganizationDetails();
|
||||
await harness.fillAzureCredentials(AZURE_CLIENT_ID, AZURE_CLIENT_SECRET);
|
||||
await harness.authenticate();
|
||||
}
|
||||
|
||||
/** Drive a fresh Azure org onboarding up to the populated selection tree. */
|
||||
async function onboardAzureToSelection(
|
||||
harness: ProvidersPageHarness,
|
||||
): Promise<void> {
|
||||
await authenticateAzureOrg(harness, { name: "My Azure Org" });
|
||||
await harness.waitForSelectionTree();
|
||||
await harness.waitForSubscriptionSelection();
|
||||
}
|
||||
|
||||
describe("Organization onboarding wizard", () => {
|
||||
describe("AWS Organizations", () => {
|
||||
describe("Full onboarding run", () => {
|
||||
@@ -271,6 +351,14 @@ describe("Organization onboarding wizard", () => {
|
||||
GCP_BLOCKED_FOLDER,
|
||||
]);
|
||||
|
||||
// A folder ref is short, so visible text and accessible name coincide.
|
||||
expect(harness.containerIdLabel("folders/1000000001")).toBe(
|
||||
"folders/1000000001",
|
||||
);
|
||||
expect(harness.containerIdLabel(GCP_EMPTY_FOLDER)).toBe(
|
||||
GCP_EMPTY_FOLDER,
|
||||
);
|
||||
|
||||
expect(
|
||||
harness.isCandidateNestedUnder("prod-analytics", "Engineering"),
|
||||
).toBe(true);
|
||||
@@ -589,6 +677,468 @@ describe("Organization onboarding wizard", () => {
|
||||
}, 40000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Azure Organizations", () => {
|
||||
describe("Full onboarding run", () => {
|
||||
it("completes the happy path: setup → discovery → selection → apply → connect → launch", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
// Terminology: the selection step counts "subscriptions", never "accounts".
|
||||
expect(harness.hasSelectedSubscriptionCount(2, 2)).toBe(true);
|
||||
expect(harness.usesAccountWording()).toBe(false);
|
||||
|
||||
// The tenant is the whole identity on the wire: the API derives the root
|
||||
// Management Group itself, so the client must not send one.
|
||||
const created = await harness.createdOrganizationAttributes();
|
||||
expect(created?.org_type).toBe(ORGANIZATION_TYPE.AZURE);
|
||||
expect(created?.external_id).toBe(AZURE_TENANT_ID);
|
||||
expect(created).not.toHaveProperty("root_external_id");
|
||||
|
||||
await harness.testConnections();
|
||||
await harness.waitForSubscriptionsConnected();
|
||||
|
||||
expect(harness.usesAccountWording()).toBe(false);
|
||||
expect(harness.applyCallCount).toBe(1);
|
||||
|
||||
await harness.enableInitialScan();
|
||||
await harness.saveScheduleAndLaunch();
|
||||
await harness.waitForLaunchComplete();
|
||||
|
||||
expect(harness.scheduleBulkCallCount).toBe(1);
|
||||
expect(harness.organizationBulkScanCallCount).toBe(1);
|
||||
}, 60000);
|
||||
|
||||
it("sends only the service-principal credentials as the organization secret", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
const secret =
|
||||
await harness.lastRequestBody<OrganizationSecretRequestBody>(
|
||||
"POST",
|
||||
"/organization-secrets",
|
||||
);
|
||||
// The tenant lives on the organization; repeating it inside the secret
|
||||
// would be a second source of truth.
|
||||
expect(secret?.data.attributes.secret).toEqual({
|
||||
client_id: AZURE_CLIENT_ID,
|
||||
client_secret: AZURE_CLIENT_SECRET,
|
||||
});
|
||||
expect(secret?.data.attributes.secret).not.toHaveProperty("tenant_id");
|
||||
}, 40000);
|
||||
});
|
||||
|
||||
describe("Wizard entry", () => {
|
||||
it("links the wizard docs to the Azure Management Groups tutorial", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await harness.mount();
|
||||
await harness.chooseAzureOrganizations();
|
||||
|
||||
expect(harness.hasDocsLinkTo(AZURE_ORG_DOCS)).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("gates the Azure Management Group method behind the cloud upgrade in OSS builds", async ({
|
||||
seedRuntimeConfig,
|
||||
}) => {
|
||||
seedRuntimeConfig({ cloudEnabled: false });
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await harness.mount();
|
||||
|
||||
await harness.selectProviderType(/Microsoft Azure/);
|
||||
await harness.waitForMethodStep();
|
||||
|
||||
await harness.chooseMethod(
|
||||
/Add Multiple Subscriptions With Azure Management Group/,
|
||||
);
|
||||
await harness.waitForMethodStep();
|
||||
expect(harness.hasOrganizationSetupStep()).toBe(false);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("Subscription selection", () => {
|
||||
it("nests each subscription under its management group and renders every group once", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
// Management Group identity is the canonical resource id, which is what
|
||||
// children carry as `parent_id`; reading the bare name flattens the tree.
|
||||
expect(harness.countContainerRows("Engineering")).toBe(1);
|
||||
expect(harness.countContainerRows("Platform")).toBe(1);
|
||||
expect(harness.containerRowUids().sort()).toEqual([
|
||||
AZURE_BLOCKED_GROUP,
|
||||
AZURE_GROUP_ENGINEERING,
|
||||
AZURE_EMPTY_GROUP,
|
||||
AZURE_GROUP_PLATFORM,
|
||||
]);
|
||||
|
||||
// Every group's canonical id repeats the same ARM prefix, so the id column
|
||||
// shows the trailing name and keeps the canonical id as its accessible name.
|
||||
expect(harness.containerIdLabel(AZURE_GROUP_ENGINEERING)).toBe(
|
||||
"engineering",
|
||||
);
|
||||
expect(harness.containerIdLabel(AZURE_GROUP_PLATFORM)).toBe("platform");
|
||||
expect(harness.containerIdLabel(AZURE_EMPTY_GROUP)).toBe("holding");
|
||||
expect(harness.containerIdLabel(AZURE_BLOCKED_GROUP)).toBe("archived");
|
||||
|
||||
expect(
|
||||
harness.isCandidateNestedUnder(
|
||||
AZURE_SUBSCRIPTION_PROD_EU,
|
||||
"Engineering",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
harness.isCandidateNestedUnder(
|
||||
AZURE_SUBSCRIPTION_PROD_US,
|
||||
"Platform",
|
||||
),
|
||||
).toBe(true);
|
||||
}, 40000);
|
||||
|
||||
it("prefills a subscription alias with its display name, never its subscription id", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
const alias = harness.candidateAliasValue(
|
||||
new RegExp(AZURE_SUBSCRIPTION_PROD_EU),
|
||||
);
|
||||
expect(alias).toBe("Production EU");
|
||||
expect(alias).not.toBe(AZURE_SUBSCRIPTION_PROD_EU);
|
||||
}, 40000);
|
||||
|
||||
it("marks a management group with nothing selectable as inert, in subscription wording", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
expect(harness.isContainerInert(AZURE_EMPTY_GROUP_NAME)).toBe(true);
|
||||
expect(harness.inertContainerNote(AZURE_EMPTY_GROUP_NAME)).toBe(
|
||||
"No subscriptions available to select in this management group.",
|
||||
);
|
||||
expect(harness.isContainerInert(AZURE_BLOCKED_GROUP_NAME)).toBe(true);
|
||||
|
||||
expect(harness.isContainerInert("Engineering")).toBe(false);
|
||||
expect(harness.inertContainerNote("Engineering")).toBeNull();
|
||||
expect(harness.usesAccountWording()).toBe(false);
|
||||
}, 40000);
|
||||
|
||||
it("still opens an inert management group so its blocked subscriptions are visible", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
expect(
|
||||
harness.isCandidateNestedUnder(
|
||||
AZURE_BLOCKED_GROUP_SUBSCRIPTION,
|
||||
AZURE_BLOCKED_GROUP_NAME,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// The row collapses and re-expands rather than selecting.
|
||||
await harness.clickContainerRow(AZURE_BLOCKED_GROUP_NAME);
|
||||
await harness.waitForTransition();
|
||||
expect(
|
||||
harness.isCandidateVisible(AZURE_BLOCKED_GROUP_SUBSCRIPTION),
|
||||
).toBe(false);
|
||||
|
||||
await harness.clickContainerRow(AZURE_BLOCKED_GROUP_NAME);
|
||||
await harness.waitForTransition();
|
||||
expect(
|
||||
harness.isCandidateVisible(AZURE_BLOCKED_GROUP_SUBSCRIPTION),
|
||||
).toBe(true);
|
||||
expect(harness.hasSelectedSubscriptionCount(2, 2)).toBe(true);
|
||||
}, 40000);
|
||||
|
||||
it("keeps a long subscription id inside its column instead of over the alias input", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
// Subscription ids are UUIDs, so every row is the long-id case — no opt-in
|
||||
// candidate needed. Real layout boxes, not class names.
|
||||
expect(harness.candidateRowOverflows(AZURE_SUBSCRIPTION_PROD_EU)).toBe(
|
||||
false,
|
||||
);
|
||||
}, 40000);
|
||||
|
||||
it("disables blocked subscriptions and excludes them from the selectable count", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
expect(
|
||||
await harness.isCandidateBlocked(
|
||||
new RegExp(AZURE_SUBSCRIPTION_LEGACY),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Blocked on `state != "Enabled"` alone — no provider and no linkage
|
||||
// conflict, the one blocked class the conflict cases cannot reach.
|
||||
expect(
|
||||
await harness.isCandidateBlocked(
|
||||
new RegExp(AZURE_SUBSCRIPTION_DISABLED),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(harness.hasSelectedSubscriptionCount(2, 2)).toBe(true);
|
||||
expect(harness.hasSelectedSubscriptionCount(4, 4)).toBe(false);
|
||||
}, 40000);
|
||||
|
||||
it("renders a management group as indeterminate when only some descendant subscriptions are selected", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
expect(harness.candidateCheckboxState(/Engineering/)).toBe("true");
|
||||
|
||||
await harness.toggleCandidate(new RegExp(AZURE_SUBSCRIPTION_PROD_US));
|
||||
await harness.waitForSelectedSubscriptionCount(1, 2);
|
||||
expect(harness.candidateCheckboxState(/Engineering/)).toBe("mixed");
|
||||
}, 40000);
|
||||
});
|
||||
|
||||
describe("Apply payload", () => {
|
||||
it("sends a subscriptions-only apply payload (no accounts, no projects)", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
await harness.testConnections();
|
||||
await harness.waitForSubscriptionsConnected();
|
||||
|
||||
const body = await harness.lastRequestBody<ApplyRequestBody>(
|
||||
"POST",
|
||||
"/apply",
|
||||
);
|
||||
const attributes = body?.data.attributes;
|
||||
expect(
|
||||
attributes?.subscriptions?.map((s) => s.subscription_id).sort(),
|
||||
).toEqual([AZURE_SUBSCRIPTION_PROD_EU, AZURE_SUBSCRIPTION_PROD_US]);
|
||||
// Azure derives Management Group ancestors server-side, so nothing else is
|
||||
// sent — and never another provider's noun.
|
||||
expect(attributes?.accounts).toBeUndefined();
|
||||
expect(attributes?.organizational_units).toBeUndefined();
|
||||
expect(attributes?.projects).toBeUndefined();
|
||||
expect(
|
||||
attributes?.subscriptions?.every((s) => s.alias === undefined),
|
||||
).toBe(true);
|
||||
}, 40000);
|
||||
|
||||
it("includes an alias only for subscriptions the user renamed", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
await harness.setCandidateAlias(
|
||||
new RegExp(AZURE_SUBSCRIPTION_PROD_EU),
|
||||
"Contoso EU",
|
||||
);
|
||||
await harness.testConnections();
|
||||
await harness.waitForSubscriptionsConnected();
|
||||
|
||||
const body = await harness.lastRequestBody<ApplyRequestBody>(
|
||||
"POST",
|
||||
"/apply",
|
||||
);
|
||||
const subscriptions = body?.data.attributes.subscriptions ?? [];
|
||||
const renamed = subscriptions.find(
|
||||
(s) => s.subscription_id === AZURE_SUBSCRIPTION_PROD_EU,
|
||||
);
|
||||
const untouched = subscriptions.find(
|
||||
(s) => s.subscription_id === AZURE_SUBSCRIPTION_PROD_US,
|
||||
);
|
||||
expect(renamed?.alias).toBe("Contoso EU");
|
||||
expect(untouched?.alias).toBeUndefined();
|
||||
}, 40000);
|
||||
|
||||
it("resolves the created providers' uids with one filtered list, and no include", async () => {
|
||||
const harness = new ProvidersPageHarness(azureOnboardingFixture());
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
await harness.testConnections();
|
||||
await harness.waitForSubscriptionsConnected();
|
||||
|
||||
expect(harness.applySentIncludeParam()).toBe(false);
|
||||
expect(harness.providerUidLookupCount).toBe(1);
|
||||
expect(harness.singleProviderFetchCount).toBe(0);
|
||||
}, 40000);
|
||||
});
|
||||
|
||||
describe("Credential conflicts", () => {
|
||||
it("warns before replacing an existing organization credential, then proceeds on confirm", async () => {
|
||||
const fixture: OrgFixture = azureOnboardingFixture({
|
||||
organizations: [
|
||||
{
|
||||
id: "org-azure-existing",
|
||||
orgType: ORGANIZATION_TYPE.AZURE,
|
||||
name: "Existing Azure Org",
|
||||
externalId: AZURE_TENANT_ID,
|
||||
rootExternalId: AZURE_ROOT_GROUP,
|
||||
providerIds: ["azp-existing-1", "azp-existing-2"],
|
||||
nodeIds: [],
|
||||
secretId: "secret-azure-existing",
|
||||
},
|
||||
],
|
||||
});
|
||||
const harness = new ProvidersPageHarness(fixture);
|
||||
await authenticateAzureOrg(harness);
|
||||
|
||||
await harness.waitForCredentialReplaceWarning();
|
||||
expect(harness.hasCredentialReplaceProviderCount(2)).toBe(true);
|
||||
|
||||
await harness.confirmCredentialReplace();
|
||||
|
||||
await harness.waitForSelectionTree();
|
||||
await harness.waitForSubscriptionSelection();
|
||||
await harness.waitForSecretReplace();
|
||||
}, 40000);
|
||||
|
||||
it("warns before an apply that overwrites already-onboarded subscription credentials", async () => {
|
||||
const fixture = azureOnboardingFixture({
|
||||
discovery: {
|
||||
id: "disc-azure-1",
|
||||
status: DISCOVERY_STATUS_VALUE.SUCCEEDED,
|
||||
result: buildAzureDiscoveryResult({
|
||||
replaceSubscriptionIds: [AZURE_SUBSCRIPTION_PROD_EU],
|
||||
}),
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
const harness = new ProvidersPageHarness(fixture);
|
||||
await onboardAzureToSelection(harness);
|
||||
|
||||
await harness.testConnections();
|
||||
|
||||
await harness.waitForCredentialReplaceWarning();
|
||||
expect(
|
||||
harness.hasApplySubscriptionOverwriteWarning(1, ["Production EU"]),
|
||||
).toBe(true);
|
||||
expect(harness.applyCallCount).toBe(0);
|
||||
|
||||
await harness.confirmApplyOverwrite();
|
||||
await harness.waitForSubscriptionsConnected();
|
||||
expect(harness.applyCallCount).toBe(1);
|
||||
}, 40000);
|
||||
});
|
||||
|
||||
describe("Discovery failures", () => {
|
||||
it("surfaces a failed discovery and retries with a fresh discovery", async () => {
|
||||
const fixture = azureOnboardingFixture({
|
||||
discovery: {
|
||||
id: "disc-azure-1",
|
||||
status: DISCOVERY_STATUS_VALUE.FAILED,
|
||||
result: {},
|
||||
error: "azure_insufficient_permissions",
|
||||
errorMessage:
|
||||
"The Azure credential cannot read the complete hierarchy.",
|
||||
},
|
||||
});
|
||||
const harness = new ProvidersPageHarness(fixture);
|
||||
await authenticateAzureOrg(harness);
|
||||
|
||||
// Curated copy for a known code outranks the server's own message: ours
|
||||
// names the fix, and the credentials are not the problem here.
|
||||
await harness.waitForDiscoveryFailureReason(
|
||||
/Grant it the Reader role at the Management Group level/,
|
||||
);
|
||||
expect(
|
||||
harness.hasDiscoveryFailureReason(
|
||||
/cannot read the complete hierarchy\./,
|
||||
),
|
||||
).toBe(false);
|
||||
await harness.waitForDiscoveryCount(1);
|
||||
|
||||
await harness.retryDiscovery();
|
||||
await harness.waitForDiscoveryCount(2);
|
||||
}, 40000);
|
||||
|
||||
it("falls back to the server's message for a failure code it has no copy for", async () => {
|
||||
const fixture = azureOnboardingFixture({
|
||||
discovery: {
|
||||
id: "disc-azure-1",
|
||||
status: DISCOVERY_STATUS_VALUE.FAILED,
|
||||
result: {},
|
||||
error: "azure_quota_exceeded",
|
||||
errorMessage:
|
||||
"Azure throttled the Management Group read for this tenant.",
|
||||
},
|
||||
});
|
||||
const harness = new ProvidersPageHarness(fixture);
|
||||
await authenticateAzureOrg(harness);
|
||||
|
||||
// An unmapped code must still be specific: the sanitized server message,
|
||||
// never the raw code and never "authentication failed".
|
||||
await harness.waitForDiscoveryFailureReason(
|
||||
/Azure throttled the Management Group read for this tenant\./,
|
||||
);
|
||||
expect(harness.hasDiscoveryFailureReason(/azure_quota_exceeded/)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(harness.hasDiscoveryFailureReason(/Authentication failed/)).toBe(
|
||||
false,
|
||||
);
|
||||
}, 40000);
|
||||
});
|
||||
|
||||
describe("Launch and scheduling", () => {
|
||||
it("launches the organization after a partial schedule save", async () => {
|
||||
const harness = new ProvidersPageHarness(
|
||||
azureOnboardingFixture({
|
||||
scheduleBulk: {
|
||||
updated: [AZURE_CREATED_PROVIDER_IDS[0]],
|
||||
failed: [{ id: AZURE_CREATED_PROVIDER_IDS[1], error: "Denied" }],
|
||||
shape: "flat",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await onboardAzureToSelection(harness);
|
||||
await harness.testConnections();
|
||||
await harness.waitForSubscriptionsConnected();
|
||||
|
||||
await harness.enableInitialScan();
|
||||
await harness.saveScheduleAndLaunch();
|
||||
await harness.waitForPartialScheduleSave(1, 1);
|
||||
|
||||
expect(harness.hasScheduleFailureReason("Denied")).toBe(true);
|
||||
expect(harness.organizationBulkScanCallCount).toBe(1);
|
||||
}, 40000);
|
||||
|
||||
it("keeps the user on the launch step when no schedule could be saved", async () => {
|
||||
const harness = new ProvidersPageHarness(
|
||||
azureOnboardingFixture({
|
||||
scheduleBulk: {
|
||||
updated: [],
|
||||
failed: AZURE_CREATED_PROVIDER_IDS.map((id) => ({
|
||||
id,
|
||||
error: "Denied",
|
||||
})),
|
||||
shape: "flat",
|
||||
},
|
||||
}),
|
||||
);
|
||||
await onboardAzureToSelection(harness);
|
||||
await harness.testConnections();
|
||||
await harness.waitForSubscriptionsConnected();
|
||||
|
||||
await harness.enableInitialScan();
|
||||
await harness.saveScheduleAndLaunch();
|
||||
await harness.waitForScheduleSaveFailure();
|
||||
|
||||
expect(harness.hasScheduleFailureReason("Denied")).toBe(true);
|
||||
expect(harness.isStillOnLaunchStep()).toBe(true);
|
||||
expect(harness.organizationBulkScanCallCount).toBe(0);
|
||||
}, 40000);
|
||||
|
||||
it("proceeds when the schedule response carries no result lists", async () => {
|
||||
const harness = new ProvidersPageHarness(
|
||||
azureOnboardingFixture({
|
||||
scheduleBulk: { updated: null, failed: [], shape: "bare" },
|
||||
}),
|
||||
);
|
||||
await onboardAzureToSelection(harness);
|
||||
await harness.testConnections();
|
||||
await harness.waitForSubscriptionsConnected();
|
||||
|
||||
await harness.enableInitialScan();
|
||||
await harness.saveScheduleAndLaunch();
|
||||
await harness.waitForLaunchComplete();
|
||||
|
||||
expect(harness.organizationBulkScanCallCount).toBe(1);
|
||||
}, 40000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Providers page", () => {
|
||||
@@ -616,21 +1166,27 @@ describe("Providers page", () => {
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("Mixed AWS + GCP", () => {
|
||||
it("groups both organizations, labelling nodes by kind (Organizational Unit vs Folder)", async () => {
|
||||
describe("Mixed AWS + Azure + GCP", () => {
|
||||
it("groups every organization, labelling nodes by kind (Organizational Unit vs Management Group vs Folder)", async () => {
|
||||
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
|
||||
await harness.mount({ openWizard: false });
|
||||
|
||||
await harness.waitForOrganizationRow("My AWS Organization");
|
||||
await harness.waitForOrganizationRow(AZURE_ORG_NAME);
|
||||
await harness.waitForOrganizationRow("My GCP Organization");
|
||||
|
||||
await harness.waitForNodeGroup("Production");
|
||||
await harness.waitForNodeGroup("Sandbox");
|
||||
await harness.waitForNodeGroup(AZURE_HIERARCHY_GROUP_NAME);
|
||||
// Nested Management Groups keep their own row rather than collapsing into
|
||||
// their parent.
|
||||
await harness.waitForNodeGroup(AZURE_HIERARCHY_CHILD_GROUP_NAME);
|
||||
await harness.waitForNodeGroup("Engineering");
|
||||
await harness.waitForNodeGroup("Platform");
|
||||
|
||||
// Labels are kind-driven, never ID-prefix-driven.
|
||||
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(true);
|
||||
expect(harness.hasNodeKindLabel("Management Group")).toBe(true);
|
||||
expect(harness.hasNodeKindLabel("Folder")).toBe(true);
|
||||
|
||||
expect(harness.hasProviderCount(3)).toBe(true);
|
||||
@@ -638,6 +1194,8 @@ describe("Providers page", () => {
|
||||
|
||||
expect(harness.hasProviderRow("prod-web")).toBe(true);
|
||||
expect(harness.hasProviderRow("sandbox-1")).toBe(true);
|
||||
expect(harness.hasProviderRow("Contoso EU")).toBe(true);
|
||||
expect(harness.hasProviderRow("Contoso US")).toBe(true);
|
||||
expect(harness.hasProviderRow("Prod Analytics")).toBe(true);
|
||||
expect(harness.hasProviderRow("Prod Platform")).toBe(true);
|
||||
|
||||
@@ -654,13 +1212,16 @@ describe("Providers page", () => {
|
||||
await harness.mount({ openWizard: false });
|
||||
|
||||
// Grouping is display-driven, so the organization still renders as a group.
|
||||
await harness.waitForOrganizationRow("Contoso Tenant");
|
||||
expect(harness.hasProviderRow("contoso-prod")).toBe(true);
|
||||
await harness.waitForOrganizationRow(DISPLAY_ONLY_ORG_NAME);
|
||||
expect(harness.hasProviderRow(DISPLAY_ONLY_PROVIDER_ALIAS)).toBe(true);
|
||||
|
||||
// Neither another provider's container wording nor a crash: the type has no
|
||||
// vocabulary of its own in this build.
|
||||
expect(harness.hasNodeKindLabel("Organizational Unit")).toBe(false);
|
||||
expect(harness.hasNodeKindLabel("Management Group")).toBe(false);
|
||||
|
||||
// The wizard only exists for onboardable types; renaming is a plain PATCH.
|
||||
const actions = await harness.actionLabelsFor("Contoso Tenant");
|
||||
const actions = await harness.actionLabelsFor(DISPLAY_ONLY_ORG_NAME);
|
||||
expect(actions).toContain("Edit Organization Name");
|
||||
expect(actions).not.toContain("Update Credentials");
|
||||
}, 30000);
|
||||
@@ -743,6 +1304,26 @@ describe("Providers page", () => {
|
||||
await harness.waitForOrganizationRename(AWS_HIERARCHY_ORG_ID);
|
||||
}, 30000);
|
||||
|
||||
it("names the organization after its identifier when the rename is blank", async () => {
|
||||
const harness = new ProvidersPageHarness(awsHierarchyFixture());
|
||||
await harness.mount({ openWizard: false });
|
||||
await harness.waitForOrganizationRow(AWS_ORG_NAME);
|
||||
|
||||
await harness.openEditNameFor(AWS_ORG_NAME);
|
||||
await harness.waitForEditNameModal();
|
||||
await harness.fillEditName("");
|
||||
await harness.saveName();
|
||||
|
||||
await harness.waitForOrganizationRename(AWS_HIERARCHY_ORG_ID);
|
||||
const body = await harness.lastRequestBody<RenameRequestBody>(
|
||||
"PATCH",
|
||||
`/organizations/${AWS_HIERARCHY_ORG_ID}`,
|
||||
);
|
||||
// Same rule as creation: the action rejects an empty name, so the client
|
||||
// substitutes the identifier.
|
||||
expect(body?.data.attributes.name).toBe(AWS_HIERARCHY_ORG_EXTERNAL_ID);
|
||||
}, 30000);
|
||||
|
||||
it("re-enters the wizard at the authentication step to update credentials", async () => {
|
||||
const harness = new ProvidersPageHarness(awsHierarchyFixture());
|
||||
await harness.mount({ openWizard: false });
|
||||
@@ -815,6 +1396,46 @@ describe("Providers page", () => {
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("Azure Organizations", () => {
|
||||
it("deletes a management group with kind-aware copy and deletion-task polling", async () => {
|
||||
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
|
||||
await harness.mount({ openWizard: false });
|
||||
await harness.waitForNodeGroup(AZURE_HIERARCHY_GROUP_NAME);
|
||||
|
||||
// Azure container nodes are "management groups", not OUs or folders.
|
||||
await harness.openDeleteManagementGroupFor(AZURE_HIERARCHY_GROUP_NAME);
|
||||
|
||||
await harness.waitForDeleteConfirmation();
|
||||
expect(harness.hasDeleteWarningFor("management group")).toBe(true);
|
||||
|
||||
await harness.confirmDelete();
|
||||
|
||||
await harness.waitForNodeDelete(AZURE_GROUP_NODE_ID);
|
||||
// The polled task completes when the per-provider deletions are dispatched, so
|
||||
// the copy may report acceptance and never that the group is gone.
|
||||
await harness.waitForTaskPoll("del-task-");
|
||||
await harness.waitForDeletionAccepted();
|
||||
expect(harness.claimsDeletionFinished()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("reports a failed deletion task instead of a false success", async () => {
|
||||
const harness = new ProvidersPageHarness(
|
||||
mixedHierarchyFixture({ deletionTaskState: "failed" }),
|
||||
);
|
||||
await harness.mount({ openWizard: false });
|
||||
await harness.waitForOrganizationRow(AZURE_ORG_NAME);
|
||||
|
||||
await harness.openDeleteFor(AZURE_ORG_NAME);
|
||||
|
||||
await harness.waitForDeleteConfirmation();
|
||||
expect(harness.hasCascadeWarning(2)).toBe(true);
|
||||
|
||||
await harness.confirmDelete();
|
||||
|
||||
await harness.waitForDeletionFailure();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("Across organization types", () => {
|
||||
it("offers wizard re-entry to every organization type with a setup form", async () => {
|
||||
const harness = new ProvidersPageHarness(mixedHierarchyFixture());
|
||||
@@ -827,6 +1448,9 @@ describe("Providers page", () => {
|
||||
expect(actions).toContain("Edit Organization Name");
|
||||
expect(actions).toContain("Update Credentials");
|
||||
|
||||
const azureActions = await harness.actionLabelsFor(AZURE_ORG_NAME);
|
||||
expect(azureActions).toContain("Update Credentials");
|
||||
|
||||
const awsActions = await harness.actionLabelsFor("My AWS Organization");
|
||||
expect(awsActions).toContain("Update Credentials");
|
||||
}, 30000);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Azure Management Group onboarding: add every subscription in a tenant at once (Prowler Cloud only)
|
||||
@@ -0,0 +1 @@
|
||||
Organization discovery describes a too-deep hierarchy in each provider's own vocabulary: AWS organizational units, Azure Management Groups, Google Cloud folders
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Boxes } from "lucide-react";
|
||||
|
||||
import { RadioCard } from "@/components/providers/radio-card";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
interface AzureMethodSelectorProps {
|
||||
onSelectSingle: () => void;
|
||||
onSelectOrganizations: () => void;
|
||||
}
|
||||
|
||||
export function AzureMethodSelector({
|
||||
onSelectSingle,
|
||||
onSelectOrganizations,
|
||||
}: AzureMethodSelectorProps) {
|
||||
const isCloudEnv = isCloud();
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Select a method to add your subscriptions to Prowler.
|
||||
</p>
|
||||
|
||||
<RadioCard
|
||||
icon={Box}
|
||||
title="Add A Single Azure Subscription"
|
||||
onClick={onSelectSingle}
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
icon={Boxes}
|
||||
title="Add Multiple Subscriptions With Azure Management Group"
|
||||
onClick={() =>
|
||||
isCloudEnv
|
||||
? onSelectOrganizations()
|
||||
: openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AZURE_ORGANIZATIONS)
|
||||
}
|
||||
>
|
||||
{!isCloudEnv && <Badge variant="cloud">Cloud</Badge>}
|
||||
</RadioCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AzureProviderBadge } from "@/components/icons/providers-badge";
|
||||
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 { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { organizationNameFallbackHint } from "@/lib/organizations";
|
||||
import type { OrgSetupPhase } from "@/types/organizations";
|
||||
import { ORG_SETUP_PHASE, ORGANIZATION_TYPE } from "@/types/organizations";
|
||||
|
||||
import { DiscoveryTimeoutNotice } from "./discovery-timeout-notice";
|
||||
import { useOrgSetupSubmission } from "./hooks/use-org-setup-submission";
|
||||
import { SecretReplaceWarningModal } from "./secret-replace-warning-modal";
|
||||
|
||||
const TENANT_ID_INVALID =
|
||||
"Must be a valid Microsoft Entra tenant ID (e.g., 11111111-1111-4111-8111-111111111111)";
|
||||
|
||||
// `z.guid()`, not `z.uuid()`: Azure issues GUIDs, and `z.uuid()` enforces the
|
||||
// RFC-9562 version and variant nibbles — it rejects real Microsoft identifiers
|
||||
// such as `00000003-0000-0000-c000-000000000000` while accepting the nil UUID.
|
||||
const azureOrgSetupSchema = z.object({
|
||||
organizationName: z.string().trim().optional(),
|
||||
// Onboarding always covers the tenant root Management Group, which the API
|
||||
// derives from the tenant ID — so the tenant is the only identifier collected.
|
||||
tenantId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Tenant ID is required")
|
||||
.pipe(z.guid(TENANT_ID_INVALID)),
|
||||
clientId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Client ID is required")
|
||||
.pipe(z.guid("Must be a valid service principal client ID (GUID)")),
|
||||
clientSecret: z.string().trim().min(1, "Client Secret is required"),
|
||||
});
|
||||
|
||||
type AzureOrgSetupFormData = z.infer<typeof azureOrgSetupSchema>;
|
||||
|
||||
interface AzureOrgSetupFormInitialValues {
|
||||
organizationName: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
interface AzureOrgSetupFormProps {
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
onFooterChange: (config: WizardFooterConfig) => void;
|
||||
onPhaseChange: (phase: OrgSetupPhase) => void;
|
||||
initialPhase?: OrgSetupPhase;
|
||||
initialValues?: AzureOrgSetupFormInitialValues;
|
||||
intent?: OrgWizardIntent;
|
||||
}
|
||||
|
||||
export function AzureOrgSetupForm({
|
||||
onBack,
|
||||
onNext,
|
||||
onFooterChange,
|
||||
onPhaseChange,
|
||||
initialPhase = ORG_SETUP_PHASE.DETAILS,
|
||||
initialValues,
|
||||
intent = ORG_WIZARD_INTENT.FULL,
|
||||
}: AzureOrgSetupFormProps) {
|
||||
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(initialPhase);
|
||||
const formId = "azure-org-wizard-setup-form";
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const isReadOnlyTenantId = Boolean(initialValues?.tenantId);
|
||||
|
||||
const form = useForm<AzureOrgSetupFormData>({
|
||||
resolver: zodResolver(azureOrgSetupSchema),
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
defaultValues: {
|
||||
organizationName: initialValues?.organizationName ?? "",
|
||||
tenantId: initialValues?.tenantId ?? "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
});
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, isValid },
|
||||
setError,
|
||||
watch,
|
||||
} = form;
|
||||
|
||||
const tenantId = watch("tenantId") || "";
|
||||
// Must stay the same check the schema runs, or the footer disagrees with submit.
|
||||
const isTenantIdValid = z.guid().safeParse(tenantId.trim()).success;
|
||||
|
||||
const {
|
||||
apiError,
|
||||
setApiError,
|
||||
submitOrganizationSetup,
|
||||
replaceSecretWarning,
|
||||
confirmSecretReplace,
|
||||
cancelSecretReplace,
|
||||
discoveryTimedOut,
|
||||
discoveryFailed,
|
||||
isSubmissionPending,
|
||||
keepWaitingForDiscovery,
|
||||
retryDiscovery,
|
||||
} = useOrgSetupSubmission({
|
||||
// Unlike an AWS role secret, a service principal secret echoes no external id.
|
||||
stackSetExternalId: "",
|
||||
onNext,
|
||||
// Only the fields this form renders: a `setError` on an unregistered field
|
||||
// would render nowhere, so anything else goes back for the hook to banner.
|
||||
setFieldError: (field, message) => {
|
||||
switch (field) {
|
||||
case "organizationName":
|
||||
case "tenantId":
|
||||
case "clientId":
|
||||
case "clientSecret":
|
||||
setError(field, { message });
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// `isSubmitting` only covers a submit react-hook-form started itself, not the
|
||||
// chain re-entered by confirming a replacement, keeping waiting or retrying.
|
||||
const isBusy = isSubmitting || isSubmissionPending;
|
||||
|
||||
useEffect(() => {
|
||||
onPhaseChange(setupPhase);
|
||||
}, [onPhaseChange, setupPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
|
||||
onFooterChange({
|
||||
showBack: true,
|
||||
backLabel: "Back",
|
||||
onBack,
|
||||
showAction: true,
|
||||
actionLabel: "Next",
|
||||
actionDisabled: !isTenantIdValid,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
||||
actionFormId: formId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isEditCredentials = intent === ORG_WIZARD_INTENT.EDIT_CREDENTIALS;
|
||||
onFooterChange({
|
||||
showBack: !isEditCredentials,
|
||||
backLabel: "Back",
|
||||
backDisabled: isBusy,
|
||||
onBack: () => setSetupPhase(ORG_SETUP_PHASE.DETAILS),
|
||||
showAction: true,
|
||||
actionLabel: "Authenticate",
|
||||
actionDisabled: isBusy || !isValid,
|
||||
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
||||
actionFormId: formId,
|
||||
});
|
||||
}, [
|
||||
formId,
|
||||
intent,
|
||||
isBusy,
|
||||
isTenantIdValid,
|
||||
isValid,
|
||||
onBack,
|
||||
onFooterChange,
|
||||
setupPhase,
|
||||
]);
|
||||
|
||||
const handleContinueToAccess = () => {
|
||||
setApiError(null);
|
||||
|
||||
if (!isTenantIdValid) {
|
||||
setError("tenantId", {
|
||||
message: tenantId.trim() ? TENANT_ID_INVALID : "Tenant ID is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSetupPhase(ORG_SETUP_PHASE.ACCESS);
|
||||
};
|
||||
|
||||
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
if (setupPhase === ORG_SETUP_PHASE.DETAILS) {
|
||||
event.preventDefault();
|
||||
handleContinueToAccess();
|
||||
return;
|
||||
}
|
||||
|
||||
void handleSubmit((data) =>
|
||||
submitOrganizationSetup({ ...data, orgType: ORGANIZATION_TYPE.AZURE }),
|
||||
)(event);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiError) return;
|
||||
formRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}, [apiError]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<SecretReplaceWarningModal
|
||||
warning={replaceSecretWarning}
|
||||
onConfirm={confirmSecretReplace}
|
||||
onCancel={cancelSecretReplace}
|
||||
/>
|
||||
<form
|
||||
id={formId}
|
||||
ref={formRef}
|
||||
onSubmit={handleFormSubmit}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
{setupPhase === ORG_SETUP_PHASE.DETAILS && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<AzureProviderBadge size={32} />
|
||||
<h3 className="text-base font-semibold">
|
||||
Microsoft Azure / Organization Details
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Enter the Microsoft Entra tenant ID for the subscriptions you want
|
||||
to add to Prowler.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupPhase === ORG_SETUP_PHASE.ACCESS && (
|
||||
<div className="flex items-center gap-4">
|
||||
<AzureProviderBadge size={32} />
|
||||
<h3 className="text-base font-semibold">
|
||||
Microsoft Azure / Authentication Details
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupPhase === ORG_SETUP_PHASE.ACCESS && isBusy && (
|
||||
<div className="flex min-h-[220px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-sm font-medium">
|
||||
Gathering Azure Subscriptions...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiError && (
|
||||
<Alert variant="error">
|
||||
<AlertDescription className="text-text-error-primary">
|
||||
{apiError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{setupPhase === ORG_SETUP_PHASE.ACCESS &&
|
||||
discoveryTimedOut &&
|
||||
!isBusy && (
|
||||
<DiscoveryTimeoutNotice
|
||||
onKeepWaiting={() => void keepWaitingForDiscovery()}
|
||||
onRetry={() => void retryDiscovery()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{setupPhase === ORG_SETUP_PHASE.ACCESS &&
|
||||
discoveryFailed &&
|
||||
!isBusy && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => void retryDiscovery()}
|
||||
>
|
||||
Retry discovery
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{setupPhase === ORG_SETUP_PHASE.DETAILS && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="tenantId"
|
||||
label="Tenant ID"
|
||||
labelPlacement="outside"
|
||||
placeholder="e.g. 11111111-1111-4111-8111-111111111111"
|
||||
isRequired
|
||||
isReadOnly={isReadOnlyTenantId}
|
||||
isDisabled={isReadOnlyTenantId}
|
||||
/>
|
||||
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="organizationName"
|
||||
label="Name (optional)"
|
||||
labelPlacement="outside"
|
||||
placeholder=""
|
||||
isRequired={false}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{organizationNameFallbackHint(ORGANIZATION_TYPE.AZURE)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupPhase === ORG_SETUP_PHASE.ACCESS && !isBusy && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-text-neutral-primary text-sm leading-7 font-normal">
|
||||
Enter the service principal Prowler authenticates with. It needs
|
||||
the Reader role on the tenant root Management Group.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="clientId"
|
||||
label="Client ID"
|
||||
labelPlacement="outside"
|
||||
isRequired
|
||||
/>
|
||||
<WizardInputField
|
||||
control={control}
|
||||
name="clientSecret"
|
||||
label="Client Secret"
|
||||
labelPlacement="outside"
|
||||
type="password"
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { organizationNameFallbackHint } from "@/lib/organizations";
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import type { OrgSetupPhase } from "@/types/organizations";
|
||||
import {
|
||||
@@ -127,6 +128,7 @@ export function GcpOrgSetupForm({
|
||||
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(initialPhase);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const formId = "gcp-org-wizard-setup-form";
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const isReadOnlyOrgId = Boolean(initialValues?.gcpOrgId);
|
||||
|
||||
@@ -301,10 +303,8 @@ export function GcpOrgSetupForm({
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiError) return;
|
||||
document
|
||||
.getElementById(formId)
|
||||
?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}, [apiError, formId]);
|
||||
formRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}, [apiError]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -315,6 +315,7 @@ export function GcpOrgSetupForm({
|
||||
/>
|
||||
<form
|
||||
id={formId}
|
||||
ref={formRef}
|
||||
onSubmit={handleFormSubmit}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
@@ -406,8 +407,7 @@ export function GcpOrgSetupForm({
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If left blank, Prowler will use the organization name stored in
|
||||
Google Cloud.
|
||||
{organizationNameFallbackHint(ORGANIZATION_TYPE.GCP)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ORG_FLOW_TYPES,
|
||||
ORG_SECRET_TYPE,
|
||||
ORGANIZATION_TYPE,
|
||||
OrgFlowType,
|
||||
} from "@/types/organizations";
|
||||
|
||||
import {
|
||||
bindOrgSetupStrategy,
|
||||
OrgSetupSubmissionData,
|
||||
} from "./org-setup-strategy";
|
||||
|
||||
/**
|
||||
* One submission per onboarding flow, only as filled in as binding a strategy
|
||||
* needs. `satisfies Record<OrgFlowType, …>` keeps a new flow from slipping past
|
||||
* the table below.
|
||||
*/
|
||||
const SETUP_DATA = {
|
||||
[ORGANIZATION_TYPE.AWS]: {
|
||||
orgType: ORGANIZATION_TYPE.AWS,
|
||||
awsOrgId: "o-abc123def4",
|
||||
roleArn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
|
||||
},
|
||||
[ORGANIZATION_TYPE.AZURE]: {
|
||||
orgType: ORGANIZATION_TYPE.AZURE,
|
||||
tenantId: "11111111-1111-4111-8111-111111111111",
|
||||
clientId: "client-id",
|
||||
clientSecret: "client-secret",
|
||||
},
|
||||
[ORGANIZATION_TYPE.GCP]: {
|
||||
orgType: ORGANIZATION_TYPE.GCP,
|
||||
gcpOrgId: "123456789012",
|
||||
credentialMethod: ORG_SECRET_TYPE.STATIC,
|
||||
clientId: "client-id",
|
||||
clientSecret: "client-secret",
|
||||
refreshToken: "refresh-token",
|
||||
},
|
||||
} as const satisfies Record<OrgFlowType, OrgSetupSubmissionData>;
|
||||
|
||||
/** The structure each flow's hierarchy is made of — a tenant has no folders. */
|
||||
const HIERARCHY_WORDING = {
|
||||
[ORGANIZATION_TYPE.AWS]: "organizational unit hierarchy",
|
||||
[ORGANIZATION_TYPE.AZURE]: "Management Group hierarchy",
|
||||
[ORGANIZATION_TYPE.GCP]: "folder hierarchy",
|
||||
} as const satisfies Record<OrgFlowType, string>;
|
||||
|
||||
describe("bindOrgSetupStrategy", () => {
|
||||
// `hierarchy_depth_exceeded` is one API code for every provider. The precedence
|
||||
// chain is covered by the submission hook's suite; only an exhaustive per-flow
|
||||
// check catches one flow being told about another cloud's structure.
|
||||
it.each(ORG_FLOW_TYPES)(
|
||||
"describes %s's own hierarchy for the shared hierarchy_depth_exceeded code",
|
||||
(orgType) => {
|
||||
const message = bindOrgSetupStrategy(
|
||||
SETUP_DATA[orgType],
|
||||
).discoveryFailureMessage("hierarchy_depth_exceeded");
|
||||
|
||||
expect(message).toContain(HIERARCHY_WORDING[orgType]);
|
||||
|
||||
const otherClouds = ORG_FLOW_TYPES.filter((other) => other !== orgType);
|
||||
for (const other of otherClouds) {
|
||||
expect(message).not.toContain(HIERARCHY_WORDING[other]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ORG_FLOW_TYPES)(
|
||||
"prefers %s's shared-code copy over the server's own message",
|
||||
(orgType) => {
|
||||
// Curated copy stays ahead of `error_message`: it is the actionable one.
|
||||
const message = bindOrgSetupStrategy(
|
||||
SETUP_DATA[orgType],
|
||||
).discoveryFailureMessage(
|
||||
"hierarchy_depth_exceeded",
|
||||
"Hierarchy too deep.",
|
||||
);
|
||||
|
||||
expect(message).toContain(HIERARCHY_WORDING[orgType]);
|
||||
},
|
||||
);
|
||||
|
||||
// `filter[external_id]` is an exact lookup, so an uppercase-typed tenant would
|
||||
// miss its own organization on a second run and then collide on the POST.
|
||||
it("folds an uppercase Azure tenant ID to the API's canonical form", () => {
|
||||
const bound = bindOrgSetupStrategy({
|
||||
...SETUP_DATA[ORGANIZATION_TYPE.AZURE],
|
||||
tenantId: " AAAAAAAA-1111-4111-8111-BBBBBBBBBBBB ",
|
||||
});
|
||||
|
||||
expect(bound.externalId).toBe("aaaaaaaa-1111-4111-8111-bbbbbbbbbbbb");
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
getSelectableCandidateIds,
|
||||
getSelectableCandidateIdsForTarget,
|
||||
mapAwsDiscovery,
|
||||
mapAzureDiscovery,
|
||||
mapGcpDiscovery,
|
||||
} from "@/actions/organizations/organizations.adapter";
|
||||
import {
|
||||
AwsDiscoveryResult,
|
||||
AzureDiscoveryResult,
|
||||
GcpDiscoveryResult,
|
||||
OrgFlowType,
|
||||
OrgHierarchy,
|
||||
@@ -43,18 +45,33 @@ export interface GcpOrgSetupData extends BaseOrgSetupData {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AzureOrgSetupData extends BaseOrgSetupData {
|
||||
orgType: typeof ORGANIZATION_TYPE.AZURE;
|
||||
/**
|
||||
* Microsoft Entra tenant ID (UUID) — the external id matched on. The API derives
|
||||
* the root Management Group from it, so there is no container to collect.
|
||||
*/
|
||||
tenantId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Values collected by an organization setup form, tagged with the organization
|
||||
* type that produced them: each form fills its own arm, and the strategy is
|
||||
* picked from the tag, so the fields and the credentials built from them cannot
|
||||
* belong to different types.
|
||||
*/
|
||||
export type OrgSetupSubmissionData = AwsOrgSetupData | GcpOrgSetupData;
|
||||
export type OrgSetupSubmissionData =
|
||||
| AwsOrgSetupData
|
||||
| AzureOrgSetupData
|
||||
| GcpOrgSetupData;
|
||||
|
||||
export type OrgSetupErrorField =
|
||||
| "organizationName"
|
||||
| "awsOrgId"
|
||||
| "gcpOrgId"
|
||||
| "tenantId"
|
||||
| "serviceAccountKey"
|
||||
| "clientId"
|
||||
| "clientSecret"
|
||||
@@ -96,14 +113,54 @@ interface OrgSetupStrategy<D extends OrgSetupSubmissionData> {
|
||||
) => { hierarchy: OrgHierarchy; defaultSelection: string[] };
|
||||
/** Copy shown when discovery reports/looks like an auth failure. */
|
||||
authFailureMessage: (detail?: string) => string;
|
||||
/**
|
||||
* Copy for the discovery codes more than one organization type reports, in this
|
||||
* type's own hierarchy vocabulary.
|
||||
*/
|
||||
sharedErrorCopy: SharedDiscoveryErrorCopy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes reported by more than one organization type whose copy has to name the
|
||||
* hierarchy the user actually has — an Azure tenant has no folders. Their wording
|
||||
* lives on each strategy (`sharedErrorCopy`), never in the table below, so a new
|
||||
* type cannot inherit another's vocabulary.
|
||||
*/
|
||||
const SHARED_DISCOVERY_ERROR_CODES = ["hierarchy_depth_exceeded"] as const;
|
||||
|
||||
type SharedDiscoveryErrorCode = (typeof SHARED_DISCOVERY_ERROR_CODES)[number];
|
||||
|
||||
type SharedDiscoveryErrorCopy = Record<SharedDiscoveryErrorCode, string>;
|
||||
|
||||
function toSharedErrorCode(code: string): SharedDiscoveryErrorCode | undefined {
|
||||
return SHARED_DISCOVERY_ERROR_CODES.find((shared) => shared === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human copy for the machine codes a failed discovery reports in
|
||||
* `attributes.error`. The code decides the framing too: only some of them are
|
||||
* credential problems, so "Authentication failed…" is wrong for the rest.
|
||||
* Per-type wording lives on the strategies — see `SHARED_DISCOVERY_ERROR_CODES`.
|
||||
*/
|
||||
const DISCOVERY_ERROR_COPY: Record<string, string> = {
|
||||
azure_invalid_credentials:
|
||||
"Those service principal credentials were rejected. Check the client ID and client secret, then try again.",
|
||||
azure_insufficient_permissions:
|
||||
"The service principal cannot read the complete Management Group hierarchy. Grant it the Reader role at the Management Group level, then try again.",
|
||||
azure_root_management_group_not_found:
|
||||
"The tenant root Management Group could not be found. Check the tenant ID, and that the service principal has been granted access at the tenant root.",
|
||||
azure_tenant_mismatch:
|
||||
"Those credentials belong to a different Microsoft Entra tenant. Use a service principal from the tenant you entered.",
|
||||
azure_service_unavailable:
|
||||
"Azure did not respond while reading the Management Group hierarchy. Nothing is wrong with your credentials — try again in a few minutes.",
|
||||
azure_incomplete_hierarchy:
|
||||
"Azure returned an incomplete Management Group hierarchy. This usually clears on a retry; if it does not, check that the service principal can read every Management Group in the tenant.",
|
||||
azure_rate_limited:
|
||||
"Azure rate limited the hierarchy read. Nothing is wrong with your credentials — try again in a few minutes.",
|
||||
azure_discovery_failed:
|
||||
"Azure rejected the hierarchy read. Try again, and contact support if it keeps failing.",
|
||||
organization_discovery_failed:
|
||||
"Discovery could not be completed. Try again, and contact support if it keeps failing.",
|
||||
gcp_invalid_organization_id:
|
||||
"That organization ID is not valid. Copy the numeric ID from the Google Cloud console and try again.",
|
||||
gcp_organization_not_found:
|
||||
@@ -112,24 +169,37 @@ const DISCOVERY_ERROR_COPY: Record<string, string> = {
|
||||
"The service account cannot list this organization's folders and projects. Grant it the Folder Viewer and Project Viewer roles at the organization level, then try again.",
|
||||
gcp_service_unavailable:
|
||||
"Google Cloud did not respond while reading the organization. Nothing is wrong with your credentials — try again in a few minutes.",
|
||||
hierarchy_depth_exceeded:
|
||||
"This organization's folder hierarchy is deeper than Prowler can read. Contact support so we can help you onboard it.",
|
||||
};
|
||||
|
||||
/** Curated copy for a code: from the strategy when shared, else from the table. */
|
||||
function curatedDiscoveryCopy(
|
||||
code: string,
|
||||
sharedCopy: SharedDiscoveryErrorCopy,
|
||||
): string | undefined {
|
||||
const sharedCode = toSharedErrorCode(code);
|
||||
|
||||
return sharedCode ? sharedCopy[sharedCode] : DISCOVERY_ERROR_COPY[code];
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy for a failed discovery. An unknown code falls back to the type's
|
||||
* auth-failure copy without the raw token, which is a support detail.
|
||||
* Copy for a failed discovery, most actionable first: curated wording for a known
|
||||
* code, then the server's own message (already display-safe) so a code added after
|
||||
* this build still says something, then the type's auth-failure copy. Never the
|
||||
* raw machine code.
|
||||
*/
|
||||
function describeDiscoveryFailure(
|
||||
code: string | undefined,
|
||||
authFailure: string,
|
||||
sharedCopy: SharedDiscoveryErrorCopy,
|
||||
serverMessage?: string | null,
|
||||
): string {
|
||||
const trimmedCode = code?.trim();
|
||||
if (!trimmedCode) {
|
||||
return authFailure;
|
||||
}
|
||||
const curatedCopy = trimmedCode
|
||||
? curatedDiscoveryCopy(trimmedCode, sharedCopy)
|
||||
: undefined;
|
||||
|
||||
return DISCOVERY_ERROR_COPY[trimmedCode] ?? authFailure;
|
||||
// `||`, not `??`: a blank server message is as good as absent.
|
||||
return curatedCopy ?? (serverMessage?.trim() || authFailure);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,8 +219,14 @@ export interface BoundOrgSetupStrategy {
|
||||
defaultSelection: string[];
|
||||
};
|
||||
authFailureMessage: (detail?: string) => string;
|
||||
/** Copy for a discovery that failed with a machine error code. */
|
||||
discoveryFailureMessage: (code?: string) => string;
|
||||
/**
|
||||
* Copy for a discovery that failed, from its machine error code and the
|
||||
* server's human message.
|
||||
*/
|
||||
discoveryFailureMessage: (
|
||||
code?: string,
|
||||
serverMessage?: string | null,
|
||||
) => string;
|
||||
}
|
||||
|
||||
const AWS_AUTH_FAILURE =
|
||||
@@ -162,6 +238,7 @@ const awsOrgSetupStrategy: OrgSetupStrategy<AwsOrgSetupData> = {
|
||||
getExternalId: (data) => data.awsOrgId,
|
||||
getResolvedName: (data) => data.organizationName?.trim() || data.awsOrgId,
|
||||
buildSecretPayload: (data, stackSetExternalId) => ({
|
||||
orgType: ORGANIZATION_TYPE.AWS,
|
||||
secretType: ORG_SECRET_TYPE.ROLE,
|
||||
secret: {
|
||||
role_arn: data.roleArn,
|
||||
@@ -189,6 +266,55 @@ const awsOrgSetupStrategy: OrgSetupStrategy<AwsOrgSetupData> = {
|
||||
},
|
||||
authFailureMessage: (detail) =>
|
||||
detail ? `${AWS_AUTH_FAILURE} ${detail}` : AWS_AUTH_FAILURE,
|
||||
sharedErrorCopy: {
|
||||
hierarchy_depth_exceeded:
|
||||
"This organization's organizational unit hierarchy is deeper than Prowler can read. Contact support so we can help you onboard it.",
|
||||
},
|
||||
};
|
||||
|
||||
const AZURE_AUTH_FAILURE =
|
||||
"Authentication failed. Please verify the service principal permissions or credentials, then try again.";
|
||||
|
||||
const azureOrgSetupStrategy: OrgSetupStrategy<AzureOrgSetupData> = {
|
||||
orgType: ORGANIZATION_TYPE.AZURE,
|
||||
externalIdField: "tenantId",
|
||||
// The API stores the tenant lowercased and `filter[external_id]` is an exact
|
||||
// match, so an uppercase-typed UUID would miss its own organization on a second
|
||||
// run and then collide on the POST.
|
||||
getExternalId: (data) => data.tenantId.trim().toLowerCase(),
|
||||
getResolvedName: (data) =>
|
||||
data.organizationName?.trim() || data.tenantId.trim(),
|
||||
// The tenant comes from the organization, so the secret carries the service
|
||||
// principal only.
|
||||
buildSecretPayload: (data) => ({
|
||||
orgType: ORGANIZATION_TYPE.AZURE,
|
||||
secretType: ORG_SECRET_TYPE.STATIC,
|
||||
secret: {
|
||||
client_id: data.clientId.trim(),
|
||||
client_secret: data.clientSecret.trim(),
|
||||
},
|
||||
}),
|
||||
mapSecretErrorField: (fieldNames) => {
|
||||
if (fieldNames.includes("client_id")) return "clientId";
|
||||
if (fieldNames.includes("client_secret")) return "clientSecret";
|
||||
return null;
|
||||
},
|
||||
ingestDiscovery: (rawResult) => {
|
||||
const hierarchy = mapAzureDiscovery(rawResult as AzureDiscoveryResult);
|
||||
|
||||
// No StackSet-style target scoping, so the default is every ready
|
||||
// subscription; Management Group ancestors are derived server-side.
|
||||
return {
|
||||
hierarchy,
|
||||
defaultSelection: getSelectableCandidateIds(hierarchy),
|
||||
};
|
||||
},
|
||||
authFailureMessage: (detail) =>
|
||||
detail ? `${AZURE_AUTH_FAILURE} ${detail}` : AZURE_AUTH_FAILURE,
|
||||
sharedErrorCopy: {
|
||||
hierarchy_depth_exceeded:
|
||||
"This tenant's Management Group hierarchy is deeper than Prowler can read. Contact support so we can help you onboard it.",
|
||||
},
|
||||
};
|
||||
|
||||
const GCP_AUTH_FAILURE =
|
||||
@@ -203,6 +329,7 @@ const gcpOrgSetupStrategy: OrgSetupStrategy<GcpOrgSetupData> = {
|
||||
buildSecretPayload: (data) => {
|
||||
if (data.credentialMethod === ORG_SECRET_TYPE.STATIC) {
|
||||
return {
|
||||
orgType: ORGANIZATION_TYPE.GCP,
|
||||
secretType: ORG_SECRET_TYPE.STATIC,
|
||||
secret: {
|
||||
client_id: data.clientId?.trim() ?? "",
|
||||
@@ -213,6 +340,7 @@ const gcpOrgSetupStrategy: OrgSetupStrategy<GcpOrgSetupData> = {
|
||||
}
|
||||
// The form validates this JSON before submit, so the parse cannot throw here.
|
||||
return {
|
||||
orgType: ORGANIZATION_TYPE.GCP,
|
||||
secretType: ORG_SECRET_TYPE.SERVICE_ACCOUNT,
|
||||
secret: {
|
||||
service_account_key: JSON.parse(data.serviceAccountKey ?? "{}"),
|
||||
@@ -238,6 +366,10 @@ const gcpOrgSetupStrategy: OrgSetupStrategy<GcpOrgSetupData> = {
|
||||
},
|
||||
authFailureMessage: (detail) =>
|
||||
detail ? `${GCP_AUTH_FAILURE} ${detail}` : GCP_AUTH_FAILURE,
|
||||
sharedErrorCopy: {
|
||||
hierarchy_depth_exceeded:
|
||||
"This organization's folder hierarchy is deeper than Prowler can read. Contact support so we can help you onboard it.",
|
||||
},
|
||||
};
|
||||
|
||||
function bind<D extends OrgSetupSubmissionData>(
|
||||
@@ -254,14 +386,20 @@ function bind<D extends OrgSetupSubmissionData>(
|
||||
mapSecretErrorField: strategy.mapSecretErrorField,
|
||||
ingestDiscovery: (rawResult) => strategy.ingestDiscovery(rawResult, data),
|
||||
authFailureMessage: strategy.authFailureMessage,
|
||||
discoveryFailureMessage: (code) =>
|
||||
describeDiscoveryFailure(code, strategy.authFailureMessage()),
|
||||
discoveryFailureMessage: (code, serverMessage) =>
|
||||
describeDiscoveryFailure(
|
||||
code,
|
||||
strategy.authFailureMessage(),
|
||||
strategy.sharedErrorCopy,
|
||||
serverMessage,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the submission data to the strategy its own tag names. The switch has no
|
||||
* default, so a new organization type is a compile error until it brings one.
|
||||
* Binds the submission data to the strategy its own tag names. The `default` arm
|
||||
* assigns the remaining data to `never`, so a new organization type without a
|
||||
* strategy fails to compile here.
|
||||
*/
|
||||
export function bindOrgSetupStrategy(
|
||||
data: OrgSetupSubmissionData,
|
||||
@@ -269,7 +407,13 @@ export function bindOrgSetupStrategy(
|
||||
switch (data.orgType) {
|
||||
case ORGANIZATION_TYPE.AWS:
|
||||
return bind(awsOrgSetupStrategy, data);
|
||||
case ORGANIZATION_TYPE.AZURE:
|
||||
return bind(azureOrgSetupStrategy, data);
|
||||
case ORGANIZATION_TYPE.GCP:
|
||||
return bind(gcpOrgSetupStrategy, data);
|
||||
default: {
|
||||
const exhaustiveData: never = data;
|
||||
return exhaustiveData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,4 +731,38 @@ describe("useOrgSetupSubmission", () => {
|
||||
expect(result.current.apiError).toContain("Authentication failed");
|
||||
expect(result.current.apiError).not.toContain("gcp_some_future_code");
|
||||
});
|
||||
|
||||
it("prefers the server message over the auth-failure copy for an unrecognized failure code", async () => {
|
||||
// Given — the same unknown code, but the server sent its own wording: it is
|
||||
// display-safe and more specific than "check your credentials".
|
||||
mockFreshSetupChain();
|
||||
organizationsActionsMock.getDiscovery.mockResolvedValue({
|
||||
data: {
|
||||
attributes: {
|
||||
status: DISCOVERY_STATUS.FAILED,
|
||||
error: "gcp_some_future_code",
|
||||
error_message: "The organization is being migrated. Try again later.",
|
||||
result: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOrgSetupSubmission({
|
||||
stackSetExternalId: "",
|
||||
onNext: vi.fn(),
|
||||
setFieldError: vi.fn(() => true),
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await act(async () => {
|
||||
await result.current.submitOrganizationSetup(GCP_STATIC_SUBMIT_DATA);
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.apiError).toBe(
|
||||
"The organization is being migrated. Try again later.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,9 +201,13 @@ export function useOrgSetupSubmission({
|
||||
}
|
||||
|
||||
if (status === DISCOVERY_STATUS.FAILED) {
|
||||
// `attributes.error` is a machine code, not user copy.
|
||||
// `attributes.error` is a machine code, not user copy; `error_message`
|
||||
// is the server's own human wording for it.
|
||||
setApiError(
|
||||
strategy.discoveryFailureMessage(result.data.attributes.error),
|
||||
strategy.discoveryFailureMessage(
|
||||
result.data.attributes.error,
|
||||
result.data.attributes.error_message,
|
||||
),
|
||||
);
|
||||
return { kind: "failed" };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
getCandidateNoun,
|
||||
getNodeLabel,
|
||||
shortenNodeId,
|
||||
toNodeKind,
|
||||
} from "@/lib/organizations";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -71,18 +72,35 @@ function InertContainerNote({
|
||||
* An identifier in the fixed-width id column. GCP project ids run to 30
|
||||
* characters and AWS OU ids longer still, so the text ellipsizes and the full
|
||||
* value moves to a tooltip.
|
||||
*
|
||||
* `shortened` replaces the visible text when ellipsizing would hide everything
|
||||
* that distinguishes the value: every Azure management-group id in a tenant reads
|
||||
* `/providers/Microsoft....`. The accessible name stays the canonical value — the
|
||||
* tooltip needs a hover, a screen reader should not.
|
||||
*
|
||||
* `role="img"` is what makes that accessible name count: ARIA prohibits naming a
|
||||
* bare `span`, so assistive tech may drop the `aria-label` and read only the
|
||||
* shortened text. Same reason `InertContainerNote` above carries the role.
|
||||
*/
|
||||
function TruncatedId({
|
||||
value,
|
||||
shortened,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
shortened?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("truncate text-sm", className)}>{value}</span>
|
||||
<span
|
||||
role="img"
|
||||
className={cn("truncate text-sm", className)}
|
||||
aria-label={value}
|
||||
>
|
||||
{shortened ?? value}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{value}</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -123,7 +141,7 @@ export function OrgAccountTreeItem({
|
||||
{ItemIcon && (
|
||||
<ItemIcon className="text-text-neutral-tertiary size-4 shrink-0" />
|
||||
)}
|
||||
<TruncatedId value={item.id} />
|
||||
<TruncatedId value={item.id} shortened={shortenNodeId(item.id)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditableNode ? (
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { getAWSOrgDeploymentQuickLink } from "@/lib";
|
||||
import { organizationNameFallbackHint } from "@/lib/organizations";
|
||||
import { useOrgSetupStore } from "@/store/organizations/store";
|
||||
import type { OrgSetupPhase } from "@/types/organizations";
|
||||
import { ORG_SETUP_PHASE, ORGANIZATION_TYPE } from "@/types/organizations";
|
||||
@@ -113,6 +114,7 @@ export function OrgSetupForm({
|
||||
const [setupPhase, setSetupPhase] = useState<OrgSetupPhase>(initialPhase);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const formId = "org-wizard-setup-form";
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const isReadOnlyOrgId = Boolean(initialValues?.awsOrgId);
|
||||
|
||||
@@ -299,10 +301,8 @@ export function OrgSetupForm({
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiError) return;
|
||||
document
|
||||
.getElementById(formId)
|
||||
?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}, [apiError, formId]);
|
||||
formRef.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
}, [apiError]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -313,6 +313,7 @@ export function OrgSetupForm({
|
||||
/>
|
||||
<form
|
||||
id={formId}
|
||||
ref={formRef}
|
||||
onSubmit={handleFormSubmit}
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
@@ -410,8 +411,7 @@ export function OrgSetupForm({
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If left blank, Prowler will use the Organization name stored in
|
||||
AWS.
|
||||
{organizationNameFallbackHint(ORGANIZATION_TYPE.AWS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FC } from "react";
|
||||
|
||||
import {
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
GCPProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import { getCandidateNoun } from "@/lib/organizations";
|
||||
@@ -20,7 +21,10 @@ export interface OrgCandidateNoun {
|
||||
Plural: string;
|
||||
}
|
||||
|
||||
/** User-facing candidate noun: "project(s)" for GCP, "account(s)" for AWS. */
|
||||
/**
|
||||
* User-facing candidate noun: "project(s)" for GCP, "account(s)" for AWS,
|
||||
* "subscription(s)" for Azure.
|
||||
*/
|
||||
export function getOrgCandidateNoun(orgType: OrgFlowType): OrgCandidateNoun {
|
||||
const { singular, plural } = getCandidateNoun(orgType);
|
||||
|
||||
@@ -35,6 +39,7 @@ export function getOrgCandidateNoun(orgType: OrgFlowType): OrgCandidateNoun {
|
||||
// until it brings its own badge, instead of silently rendering the AWS one.
|
||||
const ORG_PROVIDER_BADGE = {
|
||||
[ORGANIZATION_TYPE.AWS]: AWSProviderBadge,
|
||||
[ORGANIZATION_TYPE.AZURE]: AzureProviderBadge,
|
||||
[ORGANIZATION_TYPE.GCP]: GCPProviderBadge,
|
||||
} as const satisfies Record<OrgFlowType, FC<IconSvgProps>>;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ORG_SETUP_PHASE,
|
||||
ORG_WIZARD_STEP,
|
||||
ORGANIZATION_TYPE,
|
||||
type OrganizationType,
|
||||
} from "@/types/organizations";
|
||||
import {
|
||||
PROVIDERS_GROUP_KIND,
|
||||
@@ -918,10 +919,12 @@ describe("DataTableRowActions", () => {
|
||||
|
||||
it("hides Update Credentials for an organization type without an onboarding flow", async () => {
|
||||
// Given: an organization type the wizard cannot onboard (display-only).
|
||||
// Every `ORGANIZATION_TYPE` is onboardable now, so the value comes from
|
||||
// outside the enum.
|
||||
const user = userEvent.setup();
|
||||
const row = createOrgRow();
|
||||
(row.original as ProvidersOrganizationRow).orgType =
|
||||
ORGANIZATION_TYPE.AZURE;
|
||||
"oraclecloud" as OrganizationType;
|
||||
|
||||
render(
|
||||
<DataTableRowActions
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { getNameSourceLabel, getNodeLabel } from "@/lib/organizations";
|
||||
import {
|
||||
getNodeLabel,
|
||||
organizationNameFallbackHint,
|
||||
} from "@/lib/organizations";
|
||||
import { testProviderConnection } from "@/lib/provider-helpers";
|
||||
import { getScanScheduleCapability } from "@/lib/schedules";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
@@ -179,7 +182,9 @@ function OrgGroupDropdownActions({
|
||||
const testCount = testIds.length;
|
||||
const nodeLabel = getNodeLabel(rowData.orgType, rowData.kind);
|
||||
const entityLabel = isOrgKind ? "organization" : nodeLabel.toLowerCase();
|
||||
const nameSourceLabel = getNameSourceLabel(rowData.orgType);
|
||||
// Blank falls back to the identifier, matching what creation does. A row with
|
||||
// no external id has nothing to fall back to, so there the name stays required.
|
||||
const nameFallback = rowData.externalId ?? "";
|
||||
// Credential updates re-enter the organization wizard, so this needs an
|
||||
// organization type with an onboarding flow.
|
||||
const orgFlowType: OrgFlowType | null = isOrgFlowType(rowData.orgType)
|
||||
@@ -215,9 +220,20 @@ function OrgGroupDropdownActions({
|
||||
currentValue={rowData.name}
|
||||
label="Name"
|
||||
successMessage="The organization name was updated successfully."
|
||||
helperText={`If left blank, Prowler will use the name stored in ${nameSourceLabel}.`}
|
||||
helperText={
|
||||
nameFallback
|
||||
? organizationNameFallbackHint(rowData.orgType)
|
||||
: undefined
|
||||
}
|
||||
validate={
|
||||
nameFallback
|
||||
? undefined
|
||||
: (value) => (value.trim() ? null : "Name is required.")
|
||||
}
|
||||
setIsOpen={setIsEditNameOpen}
|
||||
onSave={(name) => updateOrganizationName(rowData.id, name)}
|
||||
onSave={(name) =>
|
||||
updateOrganizationName(rowData.id, name.trim() || nameFallback)
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,7 @@ type WizardVariant = (typeof WIZARD_VARIANT)[keyof typeof WIZARD_VARIANT];
|
||||
|
||||
const ORG_DOCS_URL = {
|
||||
[ORGANIZATION_TYPE.AWS]: DOCS_URLS.AWS_ORGANIZATIONS,
|
||||
[ORGANIZATION_TYPE.AZURE]: DOCS_URLS.AZURE_ORGANIZATIONS,
|
||||
[ORGANIZATION_TYPE.GCP]: DOCS_URLS.GCP_ORGANIZATIONS,
|
||||
} as const satisfies Record<OrgFlowType, string>;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ExternalLink, Info } from "lucide-react";
|
||||
|
||||
import { AzureOrgSetupForm } from "@/components/providers/organizations/azure-org-setup-form";
|
||||
import { GcpOrgSetupForm } from "@/components/providers/organizations/gcp-org-setup-form";
|
||||
import { OrgAccountSelection } from "@/components/providers/organizations/org-account-selection";
|
||||
import { OrgLaunchScan } from "@/components/providers/organizations/org-launch-scan";
|
||||
@@ -217,6 +218,57 @@ export function ProviderWizardModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow &&
|
||||
orgCurrentStep === ORG_WIZARD_STEP.SETUP &&
|
||||
organizationType === ORGANIZATION_TYPE.AWS && (
|
||||
<OrgSetupForm
|
||||
onBack={
|
||||
isOrgDirectEntry ? handleClose : backToProviderFlow
|
||||
}
|
||||
onClose={handleClose}
|
||||
onNext={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
onPhaseChange={setOrgSetupPhase}
|
||||
initialPhase={orgSetupPhase}
|
||||
initialValues={
|
||||
orgInitialData
|
||||
? {
|
||||
organizationName: orgInitialData.organizationName,
|
||||
awsOrgId: orgInitialData.externalId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
intent={orgInitialData?.intent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow &&
|
||||
orgCurrentStep === ORG_WIZARD_STEP.SETUP &&
|
||||
organizationType === ORGANIZATION_TYPE.AZURE && (
|
||||
<AzureOrgSetupForm
|
||||
onBack={
|
||||
isOrgDirectEntry ? handleClose : backToProviderFlow
|
||||
}
|
||||
onNext={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
onPhaseChange={setOrgSetupPhase}
|
||||
initialPhase={orgSetupPhase}
|
||||
initialValues={
|
||||
orgInitialData
|
||||
? {
|
||||
organizationName: orgInitialData.organizationName,
|
||||
tenantId: orgInitialData.externalId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
intent={orgInitialData?.intent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow &&
|
||||
orgCurrentStep === ORG_WIZARD_STEP.SETUP &&
|
||||
organizationType === ORGANIZATION_TYPE.GCP && (
|
||||
@@ -243,32 +295,6 @@ export function ProviderWizardModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow &&
|
||||
orgCurrentStep === ORG_WIZARD_STEP.SETUP &&
|
||||
organizationType !== ORGANIZATION_TYPE.GCP && (
|
||||
<OrgSetupForm
|
||||
onBack={
|
||||
isOrgDirectEntry ? handleClose : backToProviderFlow
|
||||
}
|
||||
onClose={handleClose}
|
||||
onNext={() => {
|
||||
setOrgCurrentStep(ORG_WIZARD_STEP.VALIDATE);
|
||||
}}
|
||||
onFooterChange={setFooterConfig}
|
||||
onPhaseChange={setOrgSetupPhase}
|
||||
initialPhase={orgSetupPhase}
|
||||
initialValues={
|
||||
orgInitialData
|
||||
? {
|
||||
organizationName: orgInitialData.organizationName,
|
||||
awsOrgId: orgInitialData.externalId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
intent={orgInitialData?.intent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isProviderFlow &&
|
||||
orgCurrentStep === ORG_WIZARD_STEP.VALIDATE && (
|
||||
<OrgAccountSelection
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getProviderHelpText } from "@/lib/external-urls";
|
||||
import { DOCS_URLS, getProviderHelpText } from "@/lib/external-urls";
|
||||
import { ORG_SETUP_PHASE, ORG_WIZARD_STEP } from "@/types/organizations";
|
||||
import {
|
||||
PROVIDER_WIZARD_MODE,
|
||||
@@ -171,6 +171,17 @@ describe("getProviderWizardDocsDestination", () => {
|
||||
expect(destination).toBe("Microsoft 365");
|
||||
});
|
||||
|
||||
it("labels the Azure organizations tutorial after the wizard flow, not its page name", () => {
|
||||
// The tutorial page is named after Management Groups while the wizard flow is
|
||||
// "Azure Organizations", so the label cannot come from the slug — the header
|
||||
// would read "Azure Management Groups Documentation".
|
||||
const destination = getProviderWizardDocsDestination(
|
||||
DOCS_URLS.AZURE_ORGANIZATIONS,
|
||||
);
|
||||
|
||||
expect(destination).toBe("Azure Organizations");
|
||||
});
|
||||
|
||||
it("returns a compact destination label for long docs links", () => {
|
||||
const destination = getProviderWizardDocsDestination(
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
|
||||
|
||||
@@ -31,6 +31,7 @@ export function getProviderWizardModalTitle(mode: ProviderWizardMode) {
|
||||
export function getProviderWizardDocsDestination(docsLink: string) {
|
||||
const destinationLabelMap: Record<string, string> = {
|
||||
"aws-organizations": "AWS Organizations",
|
||||
"azure-management-groups": "Azure Organizations",
|
||||
"gcp-organizations": "GCP Organizations",
|
||||
aws: "AWS",
|
||||
azure: "Azure",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
||||
|
||||
import { addProvider } from "@/actions/providers/providers";
|
||||
import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
|
||||
import { AzureMethodSelector } from "@/components/providers/organizations/azure-method-selector";
|
||||
import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector";
|
||||
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
|
||||
@@ -20,7 +21,11 @@ import {
|
||||
KnownProviderType,
|
||||
ProviderType,
|
||||
} from "@/types";
|
||||
import { ORGANIZATION_TYPE, OrgFlowType } from "@/types/organizations";
|
||||
import {
|
||||
ORGANIZATION_TYPE,
|
||||
OrgFlowType,
|
||||
toOrgFlowType,
|
||||
} from "@/types/organizations";
|
||||
|
||||
import { RadioGroupProvider } from "../../radio-group-provider";
|
||||
|
||||
@@ -33,14 +38,14 @@ export interface ConnectAccountSuccessData {
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
/** Provider types that offer an organization-onboarding method choice. */
|
||||
/**
|
||||
* Provider types that offer an organization-onboarding method choice: exactly the
|
||||
* ones with an onboarding flow, so a new flow type cannot miss the fork.
|
||||
*/
|
||||
function providerHasOrgMethod(
|
||||
providerType: ProviderType | undefined,
|
||||
): providerType is OrgFlowType {
|
||||
return (
|
||||
providerType === ORGANIZATION_TYPE.AWS ||
|
||||
providerType === ORGANIZATION_TYPE.GCP
|
||||
);
|
||||
return toOrgFlowType(providerType) !== undefined;
|
||||
}
|
||||
|
||||
interface ConnectAccountFormProps {
|
||||
@@ -389,6 +394,18 @@ export const ConnectAccountForm = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Step 2: Azure method selector (before choosing a method) */}
|
||||
{prevStep === 2 && providerType === "azure" && method === null && (
|
||||
<>
|
||||
<ProviderTitleDocs providerType={providerType} />
|
||||
<AzureMethodSelector
|
||||
onSelectSingle={() => setMethod("single")}
|
||||
onSelectOrganizations={() =>
|
||||
onSelectOrganizations?.(ORGANIZATION_TYPE.AZURE)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Step 2: GCP method selector (before choosing a method) */}
|
||||
{prevStep === 2 && providerType === "gcp" && method === null && (
|
||||
<>
|
||||
@@ -402,7 +419,7 @@ export const ConnectAccountForm = ({
|
||||
</>
|
||||
)}
|
||||
{/* Step 2: UID, alias form (providers without a method choice, or the
|
||||
AWS/GCP single account/project method) */}
|
||||
single account/subscription/project method) */}
|
||||
{prevStep === 2 && showUidForm && (
|
||||
<>
|
||||
<ProviderTitleDocs providerType={providerType} />
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("cloud upgrade content", () => {
|
||||
"Keep Every Provider Checked Automatically",
|
||||
"Turn Findings into Alerts",
|
||||
"Add Your Entire AWS Organization",
|
||||
"Add Your Entire Azure Management Group",
|
||||
"Add Your Entire GCP Organization",
|
||||
"Bring CLI Findings into One Cloud View",
|
||||
"See Compliance Across Every Provider",
|
||||
@@ -68,6 +69,7 @@ describe("cloud upgrade URLs", () => {
|
||||
[CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING, "advanced-scheduling"],
|
||||
[CLOUD_UPGRADE_FEATURE.ALERTS, "alerts"],
|
||||
[CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS, "organization"],
|
||||
[CLOUD_UPGRADE_FEATURE.AZURE_ORGANIZATIONS, "azure-organization"],
|
||||
[CLOUD_UPGRADE_FEATURE.CLI_IMPORT, "cli-import"],
|
||||
[
|
||||
CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE,
|
||||
|
||||
@@ -23,6 +23,7 @@ const CLOUD_UPGRADE_UTM_CONTENT = {
|
||||
[CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING]: "advanced-scheduling",
|
||||
[CLOUD_UPGRADE_FEATURE.ALERTS]: "alerts",
|
||||
[CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS]: "organization",
|
||||
[CLOUD_UPGRADE_FEATURE.AZURE_ORGANIZATIONS]: "azure-organization",
|
||||
[CLOUD_UPGRADE_FEATURE.CLI_IMPORT]: "cli-import",
|
||||
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]:
|
||||
"cross-provider-compliance",
|
||||
@@ -69,6 +70,17 @@ export const CLOUD_UPGRADE_CONTENT = {
|
||||
],
|
||||
primaryCta: "Set Up AWS Organizations in Prowler Cloud",
|
||||
},
|
||||
[CLOUD_UPGRADE_FEATURE.AZURE_ORGANIZATIONS]: {
|
||||
title: "Add Your Entire Azure Management Group",
|
||||
description:
|
||||
"Discover management groups and subscriptions, then manage them from one place.",
|
||||
benefits: [
|
||||
"Discover management groups and subscriptions automatically",
|
||||
"Choose exactly which subscriptions to onboard",
|
||||
"Apply schedules across the selected subscriptions",
|
||||
],
|
||||
primaryCta: "Set Up Azure Management Groups in Prowler Cloud",
|
||||
},
|
||||
[CLOUD_UPGRADE_FEATURE.GCP_ORGANIZATIONS]: {
|
||||
title: "Add Your Entire GCP Organization",
|
||||
description:
|
||||
|
||||
@@ -14,6 +14,8 @@ export const DOCS_URLS = {
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage",
|
||||
AWS_ORGANIZATIONS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
|
||||
AZURE_ORGANIZATIONS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-azure-management-groups",
|
||||
GCP_ORGANIZATIONS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-gcp-organizations",
|
||||
ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
import {
|
||||
getCandidateNoun,
|
||||
getNameSourceLabel,
|
||||
organizationNameFallbackHint,
|
||||
getNodeLabel,
|
||||
toNodeKind,
|
||||
} from "./organizations";
|
||||
@@ -22,6 +22,9 @@ describe("getNodeLabel", () => {
|
||||
expect(getNodeLabel(ORGANIZATION_TYPE.GCP, NODE_KIND.FOLDER)).toBe(
|
||||
"Folder",
|
||||
);
|
||||
expect(
|
||||
getNodeLabel(ORGANIZATION_TYPE.AZURE, NODE_KIND.MANAGEMENT_GROUP),
|
||||
).toBe("Management Group");
|
||||
});
|
||||
|
||||
it("falls back to the organization type's container label when kind is absent", () => {
|
||||
@@ -29,11 +32,6 @@ describe("getNodeLabel", () => {
|
||||
// rows that have no kind at all.
|
||||
expect(getNodeLabel(ORGANIZATION_TYPE.AWS)).toBe("Organizational Unit");
|
||||
expect(getNodeLabel(ORGANIZATION_TYPE.GCP)).toBe("Folder");
|
||||
});
|
||||
|
||||
it("uses the organization type's own vocabulary for types without an onboarding flow", () => {
|
||||
// Display covers every organization type the API can report; an Azure
|
||||
// organization must never inherit AWS wording.
|
||||
expect(getNodeLabel(ORGANIZATION_TYPE.AZURE)).toBe("Management Group");
|
||||
});
|
||||
|
||||
@@ -41,8 +39,8 @@ describe("getNodeLabel", () => {
|
||||
// The enum mirrors a server-side one: an unknown value must render neutrally
|
||||
// instead of crashing the cell or claiming AWS.
|
||||
expect(getNodeLabel("oci" as OrganizationType)).toBe("Group");
|
||||
expect(getNameSourceLabel("oci" as OrganizationType)).toBe(
|
||||
"the cloud provider",
|
||||
expect(organizationNameFallbackHint("oci" as OrganizationType)).toBe(
|
||||
"If left blank, Prowler will use the organization identifier.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,13 +48,17 @@ describe("getNodeLabel", () => {
|
||||
// `kind` is typed but unvalidated: node rows pass the wire attribute
|
||||
// straight through, so a backend-added kind must resolve to the
|
||||
// organization's own container label. Returning `undefined` would crash
|
||||
// callers that lowercase the result (the deletion dialog).
|
||||
const unknownKind = "management-group" as NodeKind;
|
||||
// callers that lowercase the result (the deletion dialog). Every kind this
|
||||
// build knows now has a label, so the case needs a kind it does not.
|
||||
const unknownKind = "compartment" as NodeKind;
|
||||
|
||||
expect(getNodeLabel(ORGANIZATION_TYPE.AWS, unknownKind)).toBe(
|
||||
"Organizational Unit",
|
||||
);
|
||||
expect(getNodeLabel(ORGANIZATION_TYPE.GCP, unknownKind)).toBe("Folder");
|
||||
expect(getNodeLabel(ORGANIZATION_TYPE.AZURE, unknownKind)).toBe(
|
||||
"Management Group",
|
||||
);
|
||||
expect(getNodeLabel("oci" as OrganizationType, unknownKind)).toBe("Group");
|
||||
});
|
||||
|
||||
@@ -79,8 +81,8 @@ describe("getNodeLabel", () => {
|
||||
"Folder",
|
||||
);
|
||||
expect(getNodeLabel(key as OrganizationType)).toBe("Group");
|
||||
expect(getNameSourceLabel(key as OrganizationType)).toBe(
|
||||
"the cloud provider",
|
||||
expect(organizationNameFallbackHint(key as OrganizationType)).toBe(
|
||||
"If left blank, Prowler will use the organization identifier.",
|
||||
);
|
||||
expect(getCandidateNoun(key as OrganizationType)).toEqual({
|
||||
singular: "account",
|
||||
@@ -90,11 +92,18 @@ describe("getNodeLabel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNameSourceLabel", () => {
|
||||
it("names the provider-side source of the organization name", () => {
|
||||
expect(getNameSourceLabel(ORGANIZATION_TYPE.AWS)).toBe("AWS");
|
||||
expect(getNameSourceLabel(ORGANIZATION_TYPE.GCP)).toBe("Google Cloud");
|
||||
expect(getNameSourceLabel(ORGANIZATION_TYPE.AZURE)).toBe("Azure");
|
||||
describe("organizationNameFallbackHint", () => {
|
||||
it("names the identifier the organization falls back to, per type", () => {
|
||||
// Never a provider-side name: the organization exists before discovery runs.
|
||||
expect(organizationNameFallbackHint(ORGANIZATION_TYPE.AWS)).toBe(
|
||||
"If left blank, Prowler will use the AWS organization ID.",
|
||||
);
|
||||
expect(organizationNameFallbackHint(ORGANIZATION_TYPE.GCP)).toBe(
|
||||
"If left blank, Prowler will use the organization ID.",
|
||||
);
|
||||
expect(organizationNameFallbackHint(ORGANIZATION_TYPE.AZURE)).toBe(
|
||||
"If left blank, Prowler will use the tenant ID.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +117,10 @@ describe("getCandidateNoun", () => {
|
||||
singular: "project",
|
||||
plural: "projects",
|
||||
});
|
||||
expect(getCandidateNoun(ORGANIZATION_TYPE.AZURE)).toEqual({
|
||||
singular: "subscription",
|
||||
plural: "subscriptions",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,11 +130,13 @@ describe("toNodeKind", () => {
|
||||
NODE_KIND.ORGANIZATIONAL_UNIT,
|
||||
);
|
||||
expect(toNodeKind("folder")).toBe(NODE_KIND.FOLDER);
|
||||
expect(toNodeKind("management-group")).toBe(NODE_KIND.MANAGEMENT_GROUP);
|
||||
});
|
||||
|
||||
it("returns undefined for absent or unknown kinds", () => {
|
||||
expect(toNodeKind(undefined)).toBeUndefined();
|
||||
expect(toNodeKind("")).toBeUndefined();
|
||||
expect(toNodeKind("organizational_unit")).toBeUndefined();
|
||||
expect(toNodeKind("management_group")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+32
-9
@@ -23,8 +23,8 @@ interface CandidateNoun {
|
||||
interface OrgTypeTerminology {
|
||||
/** Hierarchy container label, used when a node carries no `kind`. */
|
||||
containerLabel: string;
|
||||
/** Where the provider-side organization/node name comes from. */
|
||||
nameSourceLabel: string;
|
||||
/** The identifier an organization is named after when no name is given. */
|
||||
identifierLabel: string;
|
||||
/** What a discovered candidate is called in the onboarding flow. */
|
||||
candidateNoun: CandidateNoun;
|
||||
}
|
||||
@@ -32,17 +32,17 @@ interface OrgTypeTerminology {
|
||||
const ORGANIZATION_TERMINOLOGY = {
|
||||
[ORGANIZATION_TYPE.AWS]: {
|
||||
containerLabel: "Organizational Unit",
|
||||
nameSourceLabel: "AWS",
|
||||
identifierLabel: "AWS organization ID",
|
||||
candidateNoun: { singular: "account", plural: "accounts" },
|
||||
},
|
||||
[ORGANIZATION_TYPE.AZURE]: {
|
||||
containerLabel: "Management Group",
|
||||
nameSourceLabel: "Azure",
|
||||
identifierLabel: "tenant ID",
|
||||
candidateNoun: { singular: "subscription", plural: "subscriptions" },
|
||||
},
|
||||
[ORGANIZATION_TYPE.GCP]: {
|
||||
containerLabel: "Folder",
|
||||
nameSourceLabel: "Google Cloud",
|
||||
identifierLabel: "organization ID",
|
||||
candidateNoun: { singular: "project", plural: "projects" },
|
||||
},
|
||||
} as const satisfies Record<OrganizationType, OrgTypeTerminology>;
|
||||
@@ -50,6 +50,7 @@ const ORGANIZATION_TERMINOLOGY = {
|
||||
const NODE_KIND_LABEL = {
|
||||
[NODE_KIND.ORGANIZATIONAL_UNIT]: "Organizational Unit",
|
||||
[NODE_KIND.FOLDER]: "Folder",
|
||||
[NODE_KIND.MANAGEMENT_GROUP]: "Management Group",
|
||||
} as const satisfies Record<NodeKind, string>;
|
||||
|
||||
const NODE_KINDS: readonly string[] = Object.values(NODE_KIND);
|
||||
@@ -61,7 +62,7 @@ const NODE_KINDS: readonly string[] = Object.values(NODE_KIND);
|
||||
*/
|
||||
const NEUTRAL_TERMINOLOGY: OrgTypeTerminology = {
|
||||
containerLabel: "Group",
|
||||
nameSourceLabel: "the cloud provider",
|
||||
identifierLabel: "organization identifier",
|
||||
candidateNoun: { singular: "account", plural: "accounts" },
|
||||
};
|
||||
|
||||
@@ -92,9 +93,31 @@ export function getNodeLabel(
|
||||
: terminologyFor(orgType).containerLabel;
|
||||
}
|
||||
|
||||
/** Provider-side source of the organization name (edit-name helper copy). */
|
||||
export function getNameSourceLabel(orgType: OrganizationType): string {
|
||||
return terminologyFor(orgType).nameSourceLabel;
|
||||
/**
|
||||
* Every management group in a tenant repeats the same ARM prefix, so only the
|
||||
* trailing name tells them apart. Case-insensitive, as ARM ids are.
|
||||
*/
|
||||
const MANAGEMENT_GROUP_ID =
|
||||
/^\/providers\/Microsoft\.Management\/managementGroups\/(.+)$/i;
|
||||
|
||||
/**
|
||||
* The readable tail of a node id, or undefined when the whole id already is (AWS
|
||||
* OU ids, GCP folder refs). Presentation only: the canonical id stays the node's
|
||||
* identity, so a caller that shortens must keep it reachable.
|
||||
*/
|
||||
export function shortenNodeId(id: string): string | undefined {
|
||||
return MANAGEMENT_GROUP_ID.exec(id)?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper copy for the optional organization-name field. The fallback is the
|
||||
* organization's own identifier, never a provider-side name: the organization
|
||||
* exists before discovery runs.
|
||||
*/
|
||||
export function organizationNameFallbackHint(
|
||||
orgType: OrganizationType,
|
||||
): string {
|
||||
return `If left blank, Prowler will use the ${terminologyFor(orgType).identifierLabel}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,9 @@ describe("useOrgSetupStore", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an organization type with no onboarding flow", ORGANIZATION_TYPE.AZURE],
|
||||
// Every `ORGANIZATION_TYPE` can be onboarded now, so the "no onboarding
|
||||
// flow" case has to come from outside the enum.
|
||||
["an organization type with no onboarding flow", "oraclecloud"],
|
||||
["a prototype key", "__proto__"],
|
||||
["a non-string", 42],
|
||||
])("discards %s rehydrated as the organization type", (_label, stored) => {
|
||||
@@ -79,4 +81,28 @@ describe("useOrgSetupStore", () => {
|
||||
ORGANIZATION_TYPE.AWS,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an onboardable organization type through rehydration", () => {
|
||||
// Given — the guard above must reject only what has no flow, not everything
|
||||
// that isn't AWS: a resumed Azure wizard has to come back as Azure.
|
||||
sessionStorage.setItem(
|
||||
"org-setup-store",
|
||||
JSON.stringify({
|
||||
state: {
|
||||
organizationType: ORGANIZATION_TYPE.AZURE,
|
||||
organizationId: "org-9",
|
||||
selectedCandidateIds: [],
|
||||
},
|
||||
version: useOrgSetupStore.persist.getOptions().version,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
useOrgSetupStore.persist.rehydrate();
|
||||
|
||||
// Then
|
||||
expect(useOrgSetupStore.getState().organizationType).toBe(
|
||||
ORGANIZATION_TYPE.AZURE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -710,6 +710,15 @@ export class ProvidersPage extends BasePage {
|
||||
await singleAccountOption.click();
|
||||
}
|
||||
|
||||
async selectAzureSingleSubscriptionMethod(): Promise<void> {
|
||||
const singleSubscriptionOption = this.page.getByRole("radio", {
|
||||
name: "Add A Single Azure Subscription",
|
||||
exact: true,
|
||||
});
|
||||
await expect(singleSubscriptionOption).toBeVisible({ timeout: 10000 });
|
||||
await singleSubscriptionOption.click();
|
||||
}
|
||||
|
||||
async selectAWSOrganizationsMethod(): Promise<void> {
|
||||
await this.page
|
||||
.getByRole("radio", {
|
||||
@@ -804,8 +813,10 @@ export class ProvidersPage extends BasePage {
|
||||
}
|
||||
|
||||
async fillAZUREProviderDetails(data: AZUREProviderData): Promise<void> {
|
||||
// Fill the AWS provider details
|
||||
|
||||
// Azure now offers the Management Group method, so the single-subscription
|
||||
// path goes through the method selector first (as AWS does).
|
||||
await this.selectAzureSingleSubscriptionMethod();
|
||||
await expect(this.azureSubscriptionIdInput).toBeVisible({ timeout: 10000 });
|
||||
await this.azureSubscriptionIdInput.fill(data.subscriptionId);
|
||||
|
||||
if (data.alias) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export const CLOUD_UPGRADE_FEATURE = {
|
||||
ADVANCED_SCHEDULING: "advanced_scheduling",
|
||||
ALERTS: "alerts",
|
||||
AWS_ORGANIZATIONS: "aws_organizations",
|
||||
AZURE_ORGANIZATIONS: "azure_organizations",
|
||||
CLI_IMPORT: "cli_import",
|
||||
CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance",
|
||||
FINDING_TRIAGE: "finding_triage",
|
||||
|
||||
+101
-4
@@ -45,6 +45,7 @@ export type ProviderSecretState =
|
||||
export const NODE_KIND = {
|
||||
ORGANIZATIONAL_UNIT: "organizational-unit",
|
||||
FOLDER: "folder",
|
||||
MANAGEMENT_GROUP: "management-group",
|
||||
} as const;
|
||||
|
||||
export type NodeKind = (typeof NODE_KIND)[keyof typeof NODE_KIND];
|
||||
@@ -114,6 +115,7 @@ export type OrganizationType =
|
||||
*/
|
||||
export const ORG_FLOW_TYPES = [
|
||||
ORGANIZATION_TYPE.AWS,
|
||||
ORGANIZATION_TYPE.AZURE,
|
||||
ORGANIZATION_TYPE.GCP,
|
||||
] as const;
|
||||
|
||||
@@ -128,8 +130,8 @@ export function isOrgFlowType(
|
||||
/**
|
||||
* Narrows an untrusted value (form data, wire payload) to an onboarding-capable
|
||||
* type — `isOrgFlowType` narrows inside the type domain, this guards the
|
||||
* boundary, the role `toNodeKind` plays for node kinds. `azure` is a real
|
||||
* `OrganizationType` but has no onboarding flow, so it does not pass either.
|
||||
* boundary, the role `toNodeKind` plays for node kinds. Every current
|
||||
* `OrganizationType` has a flow; a display-only type added later stops here.
|
||||
*/
|
||||
export function toOrgFlowType(orgType: unknown): OrgFlowType | undefined {
|
||||
return ORG_FLOW_TYPES.find((flowType) => flowType === orgType);
|
||||
@@ -217,8 +219,52 @@ export interface GcpDiscoveryResult {
|
||||
projects: GcpDiscoveredProject[];
|
||||
}
|
||||
|
||||
// ─── Azure Discovery Result (wire) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Identity here is the canonical Management Group resource ID
|
||||
* (`/providers/Microsoft.Management/managementGroups/{name}`), which is what
|
||||
* `id`/`parent_id` carry; `name` is the short segment and `display_name` the
|
||||
* human label. The root group is always the tenant root, derived by the API.
|
||||
*/
|
||||
export interface AzureDiscoveredRoot {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
export interface AzureDiscoveredManagementGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
parent_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriptions are identified by their UUID, not a resource ID, and parent
|
||||
* through the Management Group's resource ID. `not_applicable` node relations
|
||||
* mark the ones hanging directly off the root.
|
||||
*/
|
||||
export interface AzureDiscoveredSubscription {
|
||||
subscription_id: string;
|
||||
display_name: string;
|
||||
state?: string;
|
||||
parent_id: string;
|
||||
registration?: CandidateRegistration;
|
||||
}
|
||||
|
||||
export interface AzureDiscoveryResult {
|
||||
root_management_group: AzureDiscoveredRoot;
|
||||
management_groups: AzureDiscoveredManagementGroup[];
|
||||
subscriptions: AzureDiscoveredSubscription[];
|
||||
}
|
||||
|
||||
/** Raw discovery `result` blob — per-provider, carries no discriminant on the wire. */
|
||||
export type DiscoveryResult = AwsDiscoveryResult | GcpDiscoveryResult;
|
||||
export type DiscoveryResult =
|
||||
| AwsDiscoveryResult
|
||||
| AzureDiscoveryResult
|
||||
| GcpDiscoveryResult;
|
||||
|
||||
// ─── Normalized Hierarchy Model (store currency) ───────────────────────────────
|
||||
|
||||
@@ -251,11 +297,18 @@ export interface AwsOrgHierarchy extends BaseOrgHierarchy {
|
||||
orgType: typeof ORGANIZATION_TYPE.AWS;
|
||||
}
|
||||
|
||||
export interface AzureOrgHierarchy extends BaseOrgHierarchy {
|
||||
orgType: typeof ORGANIZATION_TYPE.AZURE;
|
||||
}
|
||||
|
||||
export interface GcpOrgHierarchy extends BaseOrgHierarchy {
|
||||
orgType: typeof ORGANIZATION_TYPE.GCP;
|
||||
}
|
||||
|
||||
export type OrgHierarchy = AwsOrgHierarchy | GcpOrgHierarchy;
|
||||
export type OrgHierarchy =
|
||||
| AwsOrgHierarchy
|
||||
| AzureOrgHierarchy
|
||||
| GcpOrgHierarchy;
|
||||
|
||||
// ─── Secret + Apply Payloads (per-type) ────────────────────────────────────────
|
||||
|
||||
@@ -274,23 +327,44 @@ export interface GcpStaticSecret {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
/** Service principal. The tenant comes from the organization, never the secret. */
|
||||
export interface AzureStaticSecret {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
}
|
||||
|
||||
export interface AwsRoleSecretPayload {
|
||||
orgType: typeof ORGANIZATION_TYPE.AWS;
|
||||
secretType: typeof ORG_SECRET_TYPE.ROLE;
|
||||
secret: AwsRoleSecret;
|
||||
}
|
||||
|
||||
export interface GcpServiceAccountSecretPayload {
|
||||
orgType: typeof ORGANIZATION_TYPE.GCP;
|
||||
secretType: typeof ORG_SECRET_TYPE.SERVICE_ACCOUNT;
|
||||
secret: GcpServiceAccountSecret;
|
||||
}
|
||||
|
||||
export interface GcpStaticSecretPayload {
|
||||
orgType: typeof ORGANIZATION_TYPE.GCP;
|
||||
secretType: typeof ORG_SECRET_TYPE.STATIC;
|
||||
secret: GcpStaticSecret;
|
||||
}
|
||||
|
||||
export interface AzureStaticSecretPayload {
|
||||
orgType: typeof ORGANIZATION_TYPE.AZURE;
|
||||
secretType: typeof ORG_SECRET_TYPE.STATIC;
|
||||
secret: AzureStaticSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated on `orgType` **and** `secretType`: `static` is not one shape —
|
||||
* GCP's carries a refresh token, Azure's does not — so the wire `secret_type`
|
||||
* alone cannot tell the payloads apart.
|
||||
*/
|
||||
export type OrgSecretPayload =
|
||||
| AwsRoleSecretPayload
|
||||
| AzureStaticSecretPayload
|
||||
| GcpServiceAccountSecretPayload
|
||||
| GcpStaticSecretPayload;
|
||||
|
||||
@@ -311,6 +385,16 @@ export interface ApplyProjectSelection {
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Azure sends subscriptions only; Management Group ancestors are derived
|
||||
* server-side. `subscription_id` is the Azure subscription UUID — never a
|
||||
* Prowler provider id, which the endpoint rejects.
|
||||
*/
|
||||
export interface ApplySubscriptionSelection {
|
||||
subscription_id: string;
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
export interface AwsApplyDiscoveryPayload {
|
||||
orgType: typeof ORGANIZATION_TYPE.AWS;
|
||||
accounts: ApplyAccountSelection[];
|
||||
@@ -322,8 +406,14 @@ export interface GcpApplyDiscoveryPayload {
|
||||
projects: ApplyProjectSelection[];
|
||||
}
|
||||
|
||||
export interface AzureApplyDiscoveryPayload {
|
||||
orgType: typeof ORGANIZATION_TYPE.AZURE;
|
||||
subscriptions: ApplySubscriptionSelection[];
|
||||
}
|
||||
|
||||
export type ApplyDiscoveryPayload =
|
||||
| AwsApplyDiscoveryPayload
|
||||
| AzureApplyDiscoveryPayload
|
||||
| GcpApplyDiscoveryPayload;
|
||||
|
||||
// ─── JSON:API Resource Interfaces ─────────────────────────────────────────────
|
||||
@@ -443,7 +533,14 @@ export interface CollectionFetch<T> {
|
||||
export interface DiscoveryAttributes {
|
||||
status: DiscoveryStatus;
|
||||
result: DiscoveryResult | Record<string, never>;
|
||||
/** Machine code, not user copy — the UI maps it to its own wording. */
|
||||
error: string | null;
|
||||
/**
|
||||
* Server-side human message for `error`, already sanitized for display. Used
|
||||
* only when the code has no curated copy, so a code the API adds later still
|
||||
* says something useful instead of falling back to generic auth wording.
|
||||
*/
|
||||
error_message?: string | null;
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user