feat(ui): onboard Azure subscriptions from a Management Group (#12386)

This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-08-12 09:07:43 +02:00
committed by GitHub
parent 6d7bc8a86e
commit b480907484
35 changed files with 2323 additions and 160 deletions
@@ -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,
+7 -1
View File
@@ -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);
+25 -8
View File
@@ -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} />
+2
View File
@@ -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,
+12
View File
@@ -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:
+2
View File
@@ -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",
+32 -17
View File
@@ -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
View File
@@ -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}.`;
}
/**
+27 -1
View File
@@ -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,
);
});
});
+13 -2
View File
@@ -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) {
+1
View File
@@ -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
View File
@@ -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;
}