Skip to content

Commit b480907

Browse files
authored
feat(ui): onboard Azure subscriptions from a Management Group (#12386)
1 parent 6d7bc8a commit b480907

35 files changed

Lines changed: 2302 additions & 139 deletions

ui/__tests__/msw/handlers/organizations.fixtures.ts

Lines changed: 335 additions & 16 deletions
Large diffs are not rendered by default.

ui/__tests__/msw/handlers/organizations.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,11 @@ export const handlersForOrganizations = (
347347
orgType: String(attrs.org_type ?? "aws"),
348348
name: String(attrs.name ?? ""),
349349
externalId: String(attrs.external_id ?? ""),
350-
rootExternalId: null,
350+
// Writable on POST for Azure (the target Management Group); AWS and GCP
351+
// leave it to discovery and send nothing.
352+
rootExternalId: attrs.root_external_id
353+
? String(attrs.root_external_id)
354+
: null,
351355
providerIds: [],
352356
nodeIds: [],
353357
secretId: null,
@@ -503,6 +507,8 @@ export const handlersForOrganizations = (
503507
result:
504508
fx.discovery.status === "succeeded" ? fx.discovery.result : {},
505509
error: fx.discovery.error,
510+
// Machine code and human message are separate fields on the wire.
511+
error_message: fx.discovery.errorMessage ?? null,
506512
inserted_at: TS,
507513
updated_at: TS,
508514
},

ui/actions/organizations/organizations.adapter.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
ApplyDiscoveryPayload,
66
AwsDiscoveryResult,
77
AwsOrgHierarchy,
8+
AzureDiscoveryResult,
9+
AzureOrgHierarchy,
810
GcpDiscoveryResult,
911
GcpOrgHierarchy,
1012
NODE_KIND,
@@ -84,6 +86,43 @@ export function mapGcpDiscovery(result: GcpDiscoveryResult): GcpOrgHierarchy {
8486
};
8587
}
8688

89+
/**
90+
* Ingestion mapper: Azure discovery wire result → normalized hierarchy model.
91+
*
92+
* A management group's identity is its canonical resource ID
93+
* (`/providers/Microsoft.Management/managementGroups/{name}`), which is exactly
94+
* what its children carry as `parent_id`, so nesting matches on that ref. The
95+
* selected root group is collapsed away — it is absent from the node set, so
96+
* groups and subscriptions parented by it rebuild as top-level.
97+
*/
98+
export function mapAzureDiscovery(
99+
result: AzureDiscoveryResult,
100+
): AzureOrgHierarchy {
101+
return {
102+
orgType: ORGANIZATION_TYPE.AZURE,
103+
organization: {
104+
// Tenant id: what the user typed and what the organization stores as
105+
// `external_id`.
106+
uid: result.root_management_group.tenant_id,
107+
name:
108+
result.root_management_group.display_name ||
109+
result.root_management_group.name,
110+
},
111+
nodes: result.management_groups.map((group) => ({
112+
id: group.id,
113+
kind: NODE_KIND.MANAGEMENT_GROUP,
114+
name: group.display_name || group.name,
115+
parentId: group.parent_id,
116+
})),
117+
candidates: result.subscriptions.map((subscription) => ({
118+
uid: subscription.subscription_id,
119+
label: subscription.display_name || subscription.subscription_id,
120+
parentId: subscription.parent_id,
121+
registration: subscription.registration,
122+
})),
123+
};
124+
}
125+
87126
/**
88127
* Transforms the normalized hierarchy into hierarchical TreeDataItem[] for
89128
* TreeView. Container nodes (OUs / folders) nest candidates (accounts /
@@ -338,6 +377,14 @@ export function buildApplyPayload(
338377
selectedCandidateIds,
339378
).map((id) => ({ id })),
340379
};
380+
case ORGANIZATION_TYPE.AZURE:
381+
return {
382+
orgType: ORGANIZATION_TYPE.AZURE,
383+
subscriptions: selectedCandidateIds.map((id) => ({
384+
subscription_id: id,
385+
...aliasOf(id),
386+
})),
387+
};
341388
case ORGANIZATION_TYPE.GCP:
342389
return {
343390
orgType: ORGANIZATION_TYPE.GCP,

ui/actions/organizations/organizations.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe("organizations actions", () => {
5151
it("rejects invalid organization secret identifiers", async () => {
5252
// When
5353
const result = await updateOrganizationSecret("../secret-id", {
54+
orgType: ORGANIZATION_TYPE.AWS,
5455
secretType: ORG_SECRET_TYPE.ROLE,
5556
secret: {
5657
role_arn: "arn:aws:iam::123456789012:role/ProwlerOrgRole",
@@ -85,13 +86,13 @@ describe("organizations actions", () => {
8586
});
8687

8788
it("rejects an organization type with no onboarding flow instead of coercing it", async () => {
88-
// Given a form asking for a type this build cannot onboard. `azure` is a
89-
// real OrganizationType — display supports it, onboarding does not — so it
90-
// is the exact boundary a blind cast would let through.
89+
// Given a type this build cannot onboard. `oraclecloud` is real — display
90+
// supports it, onboarding does not — the exact boundary a blind cast lets
91+
// through.
9192
const formData = new FormData();
92-
formData.set("name", "Contoso");
93-
formData.set("externalId", "o-abc123def4");
94-
formData.set("orgType", ORGANIZATION_TYPE.AZURE);
93+
formData.set("name", "Tenancy");
94+
formData.set("externalId", "ocid1.tenancy.oc1..aaaa1111");
95+
formData.set("orgType", "oraclecloud");
9596

9697
// When
9798
const result = await createOrganization(formData);

ui/actions/organizations/organizations.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -489,11 +489,34 @@ export const getDiscovery = async (
489489
}
490490
};
491491

492+
/**
493+
* JSON:API attributes for an apply request. The `default` arm is the
494+
* exhaustiveness guard — `noImplicitReturns` is off.
495+
*/
496+
function buildApplyAttributes(payload: ApplyDiscoveryPayload) {
497+
switch (payload.orgType) {
498+
case ORGANIZATION_TYPE.AWS:
499+
return {
500+
accounts: payload.accounts,
501+
organizational_units: payload.organizationalUnits,
502+
};
503+
case ORGANIZATION_TYPE.AZURE:
504+
return { subscriptions: payload.subscriptions };
505+
case ORGANIZATION_TYPE.GCP:
506+
return { projects: payload.projects };
507+
default: {
508+
const exhaustivePayload: never = payload;
509+
return exhaustivePayload;
510+
}
511+
}
512+
}
513+
492514
/**
493515
* Applies discovery results — creates providers, links to org/nodes,
494516
* auto-generates secrets. The payload is discriminated by organization type:
495517
* AWS sends `accounts` + client-side-derived `organizational_units`; GCP sends
496-
* `projects` only (folder ancestors are derived server-side).
518+
* `projects` and Azure `subscriptions` only (folder / Management Group ancestors
519+
* are derived server-side).
497520
* POST /api/v1/organizations/{orgId}/discoveries/{discoveryId}/apply
498521
*/
499522
export const applyDiscovery = async (
@@ -525,13 +548,7 @@ export const applyDiscovery = async (
525548
// whole request. The created providers' uids are read afterwards instead, with
526549
// `getProviderUidsByIds`.
527550

528-
const attributes =
529-
payload.orgType === ORGANIZATION_TYPE.AWS
530-
? {
531-
accounts: payload.accounts,
532-
organizational_units: payload.organizationalUnits,
533-
}
534-
: { projects: payload.projects };
551+
const attributes = buildApplyAttributes(payload);
535552

536553
try {
537554
const response = await fetch(url.toString(), {

0 commit comments

Comments
 (0)