source/Private/AzureResourceOperations.ps1
|
function Connect-RJAzureContext { [CmdletBinding()] [OutputType([PSCustomObject])] param( [string]$SubscriptionId ) Write-Verbose "Checking Azure connection..." # Return cached context if it still matches the active Az session if ($script:RJAzureContextCache) { if (-not $SubscriptionId -or $SubscriptionId -eq $script:RJAzureContextCache.SubscriptionId) { $activeContext = Get-AzContext -ErrorAction SilentlyContinue if ($activeContext -and $activeContext.Tenant.Id -eq $script:RJAzureContextCache.TenantId -and $activeContext.Subscription.Id -eq $script:RJAzureContextCache.SubscriptionId) { Write-Verbose "Using cached Azure context (Tenant: $($script:RJAzureContextCache.TenantName), Subscription: $($script:RJAzureContextCache.SubscriptionName))" return $script:RJAzureContextCache } Write-Verbose "Active Az context changed — re-evaluating connection." $script:RJAzureContextCache = $null } } $context = Get-AzContext -ErrorAction SilentlyContinue if (-not $context -or -not $context.Account) { Write-Information "Connecting to Azure..." $connectParams = @{} if ($SubscriptionId) { $connectParams.SubscriptionId = $SubscriptionId } Connect-AzAccount @connectParams | Out-Null $context = Get-AzContext -ErrorAction SilentlyContinue if (-not $context -or -not $context.Account) { throw "Failed to establish Azure connection" } Write-Verbose "Successfully connected to Azure as $($context.Account.Id)" } else { Write-Verbose "Already connected to Azure as $($context.Account.Id)" } $allContexts = @(Get-AzContext -ListAvailable) # Tenant selection $tenantGroups = @($allContexts | Group-Object { $_.Tenant.Id }) if ($tenantGroups.Count -gt 1) { $counter = 0 $tenantOptions = @($tenantGroups | ForEach-Object { $counter++ $firstContext = $_.Group[0] $tenantName = if ($firstContext.Tenant.Name) { $firstContext.Tenant.Name } else { $firstContext.Tenant.Id } [PSCustomObject]@{ Key = $firstContext.Tenant.Id Display = "[$counter] $tenantName" Description = "Tenant ID: $($firstContext.Tenant.Id)" } }) $tenantOptions += [PSCustomObject]@{ Key = $null Display = "[$($counter + 1)] Cancel" Description = "Cancel and return" } $selectedTenantId = Show-RJGenericMenu -Title "Multiple Azure Tenants Found" -Prompt "Select a tenant:" -Options $tenantOptions if ($null -eq $selectedTenantId) { return $null } $tenantContexts = @($allContexts | Where-Object { $_.Tenant.Id -eq $selectedTenantId }) } else { $selectedTenantId = $tenantGroups[0].Group[0].Tenant.Id $tenantContexts = @($tenantGroups[0].Group) } # True only when the user picked a tenant from the menu above. With a single known tenant the # requested subscription may legitimately live in a tenant we haven't signed into yet, so the # fallback sign-in below must not be pinned to it. $tenantExplicitlySelected = $tenantGroups.Count -gt 1 # Subscription selection if ($SubscriptionId) { $targetContext = $tenantContexts | Where-Object { $_.Subscription.Id -eq $SubscriptionId } if ($targetContext) { $selectedContext = $targetContext } else { Write-Verbose "Switching to subscription $SubscriptionId" try { Set-AzContext -SubscriptionId $SubscriptionId -ErrorAction Stop | Out-Null } catch { Write-Information "Subscription $SubscriptionId not found in current contexts. Connecting to Azure..." $fallbackParams = @{ SubscriptionId = $SubscriptionId } if ($tenantExplicitlySelected) { $fallbackParams.TenantId = $selectedTenantId } Connect-AzAccount @fallbackParams | Out-Null } $selectedContext = Get-AzContext if (-not $selectedContext -or $selectedContext.Subscription.Id -ne $SubscriptionId) { throw "Failed to switch to subscription $SubscriptionId" } if ($tenantExplicitlySelected -and $selectedContext.Tenant.Id -ne $selectedTenantId) { throw "Subscription $SubscriptionId belongs to tenant $($selectedContext.Tenant.Id), not the selected tenant $selectedTenantId" } } } elseif ($tenantContexts.Count -gt 1) { $counter = 0 $subOptions = @($tenantContexts | ForEach-Object { $counter++ [PSCustomObject]@{ Key = $_.Subscription.Id Display = "[$counter] $($_.Subscription.Name)" Description = "ID: $($_.Subscription.Id)" } }) $subOptions += [PSCustomObject]@{ Key = $null Display = "[$($counter + 1)] Cancel" Description = "Cancel and return" } $selectedSubId = Show-RJGenericMenu -Title "Multiple Azure Subscriptions Found" -Prompt "Select a subscription:" -Options $subOptions if ($null -eq $selectedSubId) { return $null } $selectedContext = $tenantContexts | Where-Object { $_.Subscription.Id -eq $selectedSubId } } else { $selectedContext = $tenantContexts[0] } # Set the selected context as active if ($selectedContext.Subscription.Id -ne $context.Subscription.Id -or $selectedContext.Tenant.Id -ne $context.Tenant.Id) { Set-AzContext -Context $selectedContext -ErrorAction Stop | Out-Null Write-Verbose "Switched to context: $($selectedContext.Subscription.Name)" } $result = [PSCustomObject]@{ TenantId = $selectedContext.Tenant.Id TenantName = $selectedContext.Tenant.Name SubscriptionId = $selectedContext.Subscription.Id SubscriptionName = $selectedContext.Subscription.Name } Write-Verbose "Selected tenant: $($result.TenantName) ($($result.TenantId))" Write-Verbose "Selected subscription: $($result.SubscriptionName) ($($result.SubscriptionId))" $script:RJAzureContextCache = $result return $result } function Test-ResourceGroup { <# .SYNOPSIS Verifies that a resource group exists. .PARAMETER ResourceGroupName The name of the resource group to check. .OUTPUTS The resource group object if it exists. Throws if not found. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ResourceGroupName ) Write-Verbose "Verifying resource group '$ResourceGroupName' exists..." try { $rg = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction Stop Write-Verbose "Resource group found in '$($rg.Location)'" return $rg } catch { throw "Resource group '$ResourceGroupName' not found." } } function Get-ArmTemplatePath { <# .SYNOPSIS Resolves the path to a JSON ARM template bundled with the module. .PARAMETER RelativePath Path relative to the azure-resources directory. .OUTPUTS Absolute path to the template file. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$RelativePath ) # $PSScriptRoot points to source/Private/ when dot-sourced from the module # Module root is two levels up: source/Private/ -> source/ -> module root $moduleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent $templatePath = Join-Path -Path (Join-Path -Path $moduleRoot -ChildPath "azure-resources") -ChildPath $RelativePath if (-not (Test-Path $templatePath)) { throw "ARM template not found at: $templatePath" } return $templatePath } function New-RJArmDeployment { <# .SYNOPSIS Deploys a JSON ARM template to a resource group. .PARAMETER ResourceGroupName The target resource group. .PARAMETER TemplatePath Path to the JSON ARM template file. .PARAMETER DeploymentName Name for the deployment (for tracking in Azure portal). .PARAMETER Parameters Optional hashtable of template parameters. .OUTPUTS Hashtable of deployment outputs. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$ResourceGroupName, [Parameter(Mandatory)] [string]$TemplatePath, [Parameter(Mandatory)] [string]$DeploymentName, [hashtable]$Parameters = @{} ) Write-Verbose "Deploying '$DeploymentName' from $(Split-Path $TemplatePath -Leaf)..." if (-not $PSCmdlet.ShouldProcess("Resource group '$ResourceGroupName'", "Deploy ARM template '$DeploymentName'")) { return } $deployment = Invoke-RJArmDeploymentWithProgress -ResourceGroupName $ResourceGroupName ` -TemplatePath $TemplatePath ` -DeploymentName $DeploymentName ` -Parameters $Parameters Write-Verbose "Deployment '$DeploymentName' succeeded" return $deployment.Outputs } function Wait-RJServicePrincipalReplication { <# .SYNOPSIS Waits for a service principal to replicate across Azure AD with exponential backoff. .PARAMETER ServicePrincipalId The object ID of the service principal to wait for. .OUTPUTS [bool] True if replicated, False if timed out. #> [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ServicePrincipalId ) Write-Information "Waiting for service principal to replicate across Microsoft Entra ID..." for ($attempt = 1; $attempt -le $ReplicationMaxAttempts; $attempt++) { if ($attempt -gt 1) { $delay = $ReplicationBaseDelaySeconds * [math]::Pow(2, $attempt - 2) # 8, 16, 32 Write-Information " Not yet available - retrying in $delay seconds (attempt $attempt/$ReplicationMaxAttempts)..." Start-Sleep -Seconds $delay } try { $sp = Get-AzADServicePrincipal -ObjectId $ServicePrincipalId -ErrorAction Stop if ($sp) { Write-Verbose "Service principal replicated successfully" return $true } } catch { if ($_.FullyQualifiedErrorId -notmatch 'Request_ResourceNotFound') { throw # Unexpected error — don't retry } if ($attempt -eq $ReplicationMaxAttempts) { Write-Warning "Service principal not found after $ReplicationMaxAttempts attempts. Proceeding anyway..." return $false } Write-Verbose "Service principal not yet available. Retrying..." } } return $false } function Test-RJRoleAssignmentExist { <# .SYNOPSIS Checks whether a role assignment already exists for a given principal and role on a scope. .PARAMETER Scope The Azure resource ID to check for role assignments. .PARAMETER PrincipalId The object ID of the service principal. .PARAMETER RoleDefinitionId The GUID of the role definition (without the full resource ID prefix). .OUTPUTS [bool] True if the role assignment already exists. #> [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Scope, [Parameter(Mandatory)] [string]$PrincipalId, [Parameter(Mandatory)] [string]$RoleDefinitionId ) try { $existing = Get-AzRoleAssignment -ObjectId $PrincipalId -Scope $Scope -RoleDefinitionId $RoleDefinitionId -ErrorAction SilentlyContinue if ($existing) { Write-Verbose "Role assignment already exists for principal '$PrincipalId' with role '$RoleDefinitionId' on scope '$Scope'" return $true } } catch { Write-Verbose "Could not check existing role assignments: $($_.Exception.Message). Will attempt assignment." } return $false } function Get-RJLogAnalyticsWorkspaceByCustomerId { <# .SYNOPSIS Resolves a Log Analytics workspace in the selected subscription by its workspace ID (CustomerId GUID). .PARAMETER CustomerId The workspace ID (CustomerId GUID) of the Log Analytics workspace. .OUTPUTS The matching workspace resource (from Get-AzResource). Throws if none or multiple match. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CustomerId ) Write-Verbose "Resolving Log Analytics workspace with CustomerId '$CustomerId'..." $workspaces = @(Get-AzResource -ResourceType 'Microsoft.OperationalInsights/workspaces' -ExpandProperties -ErrorAction Stop | Where-Object { $_.Properties.customerId -eq $CustomerId }) if ($workspaces.Count -eq 0) { throw "No Log Analytics workspace with workspace ID (CustomerId) '$CustomerId' found in subscription '$($script:RJAzureContextCache.SubscriptionName)'. Use -SubscriptionId to target a different subscription." } if ($workspaces.Count -gt 1) { throw "Multiple workspaces matched CustomerId '$CustomerId': $($workspaces.ResourceId -join ', ')" } Write-Verbose "Resolved workspace '$($workspaces[0].Name)' ($($workspaces[0].ResourceId))" return $workspaces[0] } function Set-RJAutomationAccountResource { <# .SYNOPSIS Orchestrates the full Automation Account deployment (3 steps). .DESCRIPTION Step 1: Configure RealmJoin Service Principal Step 2: Configure Automation Account with system-assigned managed identity Step 3: Configure permissions (Graph, Exchange, Defender, SharePoint app roles) .PARAMETER ResourceGroupName The target resource group (must exist). .PARAMETER AutomationAccountName Optional name for the automation account. Auto-generated if not provided. .OUTPUTS Hashtable with Success, Outputs (automationAccountId, automationAccountName, automationAccountPrincipalId, permissionCounts). #> [CmdletBinding(SupportsShouldProcess)] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$ResourceGroupName, [string]$AutomationAccountName ) $result = @{ Success = $false Outputs = @{} } if (-not $PSCmdlet.ShouldProcess("Resource group '$ResourceGroupName'", "Deploy Automation Account and permissions")) { return $result } # Step 1: Configure RealmJoin Service Principal Write-Information "Step 1/3: Configuring RealmJoin Service Principal..." $spTemplatePath = Get-ArmTemplatePath -RelativePath "realmJoinServicePrincipal.json" $step1Outputs = New-RJArmDeployment -ResourceGroupName $ResourceGroupName ` -TemplatePath $spTemplatePath ` -DeploymentName "rj-serviceprincipal-$(Get-Date -Format 'yyyyMMddHHmmss')" ` -Parameters @{ realmJoinAzureResourcesAppId = Get-RJAzureResourcesAppId } $rjServicePrincipalId = $step1Outputs["servicePrincipalId"].Value Write-Verbose "RJ Service Principal ID: $rjServicePrincipalId" # Wait for replication $null = Wait-RJServicePrincipalReplication -ServicePrincipalId $rjServicePrincipalId # Step 2: Configure Automation Account Write-Information "Step 2/3: Configuring Automation Account..." $aaTemplatePath = Get-ArmTemplatePath -RelativePath "azure-automation-account/automationAccount.json" $step2Params = @{ rjServicePrincipalId = $rjServicePrincipalId contributorRoleId = $ContributorRoleId } if ($AutomationAccountName) { $step2Params.automationAccountName = $AutomationAccountName } # Check if role is already assigned (avoids RoleAssignmentExists error from manual portal assignments) $assignContributorRole = $true if ($AutomationAccountName) { $aaScope = "/subscriptions/$($script:RJAzureContextCache.SubscriptionId)/resourceGroups/$ResourceGroupName/providers/Microsoft.Automation/automationAccounts/$AutomationAccountName" if (Test-RJRoleAssignmentExist -Scope $aaScope -PrincipalId $rjServicePrincipalId -RoleDefinitionId $ContributorRoleId) { $assignContributorRole = $false Write-Information " Contributor role already assigned — skipping role assignment." } } $step2Params.assignContributorRole = $assignContributorRole $step2Outputs = New-RJArmDeployment -ResourceGroupName $ResourceGroupName ` -TemplatePath $aaTemplatePath ` -DeploymentName "rj-automationaccount-$(Get-Date -Format 'yyyyMMddHHmmss')" ` -Parameters $step2Params $automationAccountId = $step2Outputs["automationAccountId"].Value $automationAccountPrincipalId = $step2Outputs["automationAccountPrincipalId"].Value Write-Verbose "Automation Account ID: $automationAccountId" Write-Verbose "Managed Identity Principal ID: $automationAccountPrincipalId" # Wait for Managed Identity replication $null = Wait-RJServicePrincipalReplication -ServicePrincipalId $automationAccountPrincipalId # Step 3: Configure Permissions Write-Information "Step 3/3: Configuring permissions..." $permTemplatePath = Get-ArmTemplatePath -RelativePath "azure-automation-account/rjAutomationAccountPermissions.json" $step3Outputs = New-RJArmDeployment -ResourceGroupName $ResourceGroupName ` -TemplatePath $permTemplatePath ` -DeploymentName "rj-permissions-$(Get-Date -Format 'yyyyMMddHHmmss')" ` -Parameters @{ principalId = $automationAccountPrincipalId } $result.Success = $true $result.Outputs = @{ AutomationAccountId = $automationAccountId AutomationAccountName = $step2Outputs["automationAccountName"].Value AutomationAccountPrincipalId = $automationAccountPrincipalId ServicePrincipalId = $rjServicePrincipalId GraphPermissionsCount = $step3Outputs["graphPermissionsCount"].Value ExchangePermissionsCount = $step3Outputs["exchangePermissionsCount"].Value DefenderPermissionsCount = $step3Outputs["defenderPermissionsCount"].Value SharePointPermissionsCount = $step3Outputs["sharePointPermissionsCount"].Value } return $result } function Set-RJLogAnalyticsResource { <# .SYNOPSIS Orchestrates the full Log Analytics Workspace deployment (2 steps). .DESCRIPTION Step 1: Configure RealmJoin Service Principal Step 2: Configure Log Analytics Workspace with conditional DCRs and custom tables .PARAMETER ResourceGroupName The target resource group (must exist). .PARAMETER WorkspaceName Optional name for the workspace. Auto-generated if not provided. .PARAMETER DeployAuditLogsDCR Whether to deploy the audit logs DCR. Default: true. .PARAMETER DeployRunbookLogsDCR Whether to deploy the runbook logs DCR. Default: true. .PARAMETER DeployOperationalLogsDCR Whether to deploy the operational logs DCR. Default: true. .OUTPUTS Hashtable with Success, Outputs (workspace info, DCR info). #> [CmdletBinding(SupportsShouldProcess)] [OutputType([hashtable])] param( [Parameter(Mandatory)] [string]$ResourceGroupName, [string]$WorkspaceName, [bool]$DeployAuditLogsDCR = $DefaultDeployAuditLogsDCR, [bool]$DeployRunbookLogsDCR = $DefaultDeployRunbookLogsDCR, [bool]$DeployOperationalLogsDCR = $DefaultDeployOperationalLogsDCR ) $result = @{ Success = $false Outputs = @{} } if (-not $PSCmdlet.ShouldProcess("Resource group '$ResourceGroupName'", "Deploy Log Analytics Workspace and DCRs")) { return $result } # Step 1: Configure RealmJoin Service Principal Write-Information "Step 1/2: Configuring RealmJoin Service Principal..." $spTemplatePath = Get-ArmTemplatePath -RelativePath "realmJoinServicePrincipal.json" $step1Outputs = New-RJArmDeployment -ResourceGroupName $ResourceGroupName ` -TemplatePath $spTemplatePath ` -DeploymentName "rj-serviceprincipal-$(Get-Date -Format 'yyyyMMddHHmmss')" ` -Parameters @{ realmJoinAzureResourcesAppId = Get-RJAzureResourcesAppId } $rjServicePrincipalId = $step1Outputs["servicePrincipalId"].Value Write-Verbose "RJ Service Principal ID: $rjServicePrincipalId" # Wait for replication $null = Wait-RJServicePrincipalReplication -ServicePrincipalId $rjServicePrincipalId # Step 2: Configure Log Analytics Workspace Write-Information "Step 2/2: Configuring Log Analytics Workspace..." $lawTemplatePath = Get-ArmTemplatePath -RelativePath "log-analytics-workspace/logAnalyticsWorkspace.json" $step2Params = @{ servicePrincipalId = $rjServicePrincipalId deployAuditLogsDCR = $DeployAuditLogsDCR deployRunbookLogsDCR = $DeployRunbookLogsDCR deployOperationalLogsDCR = $DeployOperationalLogsDCR logAnalyticsReaderRoleId = $LogAnalyticsReaderRoleId monitoringMetricsPublisherRoleId = $MonitoringMetricsPublisherRoleId } if ($WorkspaceName) { $step2Params.workspaceName = $WorkspaceName } # Check if roles are already assigned (avoids RoleAssignmentExists error from manual portal assignments) $subscriptionId = $script:RJAzureContextCache.SubscriptionId $assignWorkspaceRole = $true if ($WorkspaceName) { $lawScope = "/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.OperationalInsights/workspaces/$WorkspaceName" if (Test-RJRoleAssignmentExist -Scope $lawScope -PrincipalId $rjServicePrincipalId -RoleDefinitionId $LogAnalyticsReaderRoleId) { $assignWorkspaceRole = $false Write-Information " Log Analytics Reader role already assigned — skipping." } } $step2Params.assignWorkspaceRole = $assignWorkspaceRole # Check each DCR role — only for DCRs we're actually deploying $assignAuditLogsDcrRole = $true $assignRunbookLogsDcrRole = $true $assignOperationalLogsDcrRole = $true if ($DeployAuditLogsDCR -or $DeployRunbookLogsDCR -or $DeployOperationalLogsDCR) { $existingDcrs = Get-AzResource -ResourceGroupName $ResourceGroupName -ResourceType 'Microsoft.Insights/dataCollectionRules' -ErrorAction SilentlyContinue foreach ($dcr in $existingDcrs) { if ($dcr.Name -like 'dcr-rj-aud-logs-*' -and $DeployAuditLogsDCR) { if (Test-RJRoleAssignmentExist -Scope $dcr.ResourceId -PrincipalId $rjServicePrincipalId -RoleDefinitionId $MonitoringMetricsPublisherRoleId) { $assignAuditLogsDcrRole = $false Write-Information " Audit logs DCR role already assigned — skipping." } } elseif ($dcr.Name -like 'dcr-rj-rb-logs-*' -and $DeployRunbookLogsDCR) { if (Test-RJRoleAssignmentExist -Scope $dcr.ResourceId -PrincipalId $rjServicePrincipalId -RoleDefinitionId $MonitoringMetricsPublisherRoleId) { $assignRunbookLogsDcrRole = $false Write-Information " Runbook logs DCR role already assigned — skipping." } } elseif ($dcr.Name -like 'dcr-rj-op-logs-*' -and $DeployOperationalLogsDCR) { if (Test-RJRoleAssignmentExist -Scope $dcr.ResourceId -PrincipalId $rjServicePrincipalId -RoleDefinitionId $MonitoringMetricsPublisherRoleId) { $assignOperationalLogsDcrRole = $false Write-Information " Operational logs DCR role already assigned — skipping." } } } } $step2Params.assignAuditLogsDcrRole = $assignAuditLogsDcrRole $step2Params.assignRunbookLogsDcrRole = $assignRunbookLogsDcrRole $step2Params.assignOperationalLogsDcrRole = $assignOperationalLogsDcrRole $step2Outputs = New-RJArmDeployment -ResourceGroupName $ResourceGroupName ` -TemplatePath $lawTemplatePath ` -DeploymentName "rj-laworkspace-$(Get-Date -Format 'yyyyMMddHHmmss')" ` -Parameters $step2Params $result.Success = $true $result.Outputs = @{ ServicePrincipalId = $rjServicePrincipalId WorkspaceName = $step2Outputs["workspaceName"].Value WorkspaceId = $step2Outputs["workspaceId"].Value CustomerId = $step2Outputs["customerId"].Value # Audit Logs DCR AuditLogsTableName = $step2Outputs["auditLogsTableName"].Value AuditLogsStreamName = $step2Outputs["auditLogsStreamName"].Value AuditLogsIngestionEndpoint = $step2Outputs["auditLogsIngestionEndpoint"].Value AuditLogsDcrImmutableId = $step2Outputs["auditLogsDcrImmutableId"].Value AuditLogsDcrId = $step2Outputs["auditLogsDcrId"].Value AuditLogsDcrName = $step2Outputs["auditLogsDcrName"].Value # Runbook Logs DCR RunbookLogsTableName = $step2Outputs["runbookLogsTableName"].Value RunbookLogsStreamName = $step2Outputs["runbookLogsStreamName"].Value RunbookLogsIngestionEndpoint = $step2Outputs["runbookLogsIngestionEndpoint"].Value RunbookLogsDcrImmutableId = $step2Outputs["runbookLogsDcrImmutableId"].Value RunbookLogsDcrId = $step2Outputs["runbookLogsDcrId"].Value RunbookLogsDcrName = $step2Outputs["runbookLogsDcrName"].Value # Operational Logs DCR OperationalLogsTableName = $step2Outputs["operationalLogsTableName"].Value OperationalLogsStreamName = $step2Outputs["operationalLogsStreamName"].Value OperationalLogsIngestionEndpoint = $step2Outputs["operationalLogsIngestionEndpoint"].Value OperationalLogsDcrImmutableId = $step2Outputs["operationalLogsDcrImmutableId"].Value OperationalLogsDcrId = $step2Outputs["operationalLogsDcrId"].Value OperationalLogsDcrName = $step2Outputs["operationalLogsDcrName"].Value } return $result } |