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
+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;
}