diff --git a/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/README.md b/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/README.md new file mode 100644 index 0000000000..f205dc6e9a --- /dev/null +++ b/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/README.md @@ -0,0 +1,196 @@ +# Azure Data Factory SSIS Integration Runtime License Type Configuration with Azure Policy + +This solution deploys and remediates a custom Azure Policy that audits the `licenseType` property on Azure Data Factory Managed **SSIS Integration Runtimes** (`Microsoft.DataFactory/factories/integrationRuntimes`) and provides a companion script to bring them to a selected target value. + +## What Is In This Folder + +- `policy/azurepolicy.json`: Custom policy definition (AuditIfNotExists). +- `scripts/deployment.ps1`: Creates/updates the policy definition and policy assignment. +- `scripts/start-remediation.ps1`: Enumerates non-compliant SSIS Integration Runtimes and updates them via `Set-AzDataFactoryV2IntegrationRuntime`. + +## License Type Mapping + +| Parameter value | Portal label | API `licenseType` | +|---|---|---| +| `LicenseIncluded` | Pay-as-you-go | `LicenseIncluded` | +| `BasePrice` | Azure Hybrid Benefit | `BasePrice` | + +> **Note:** Only **Managed** Integration Runtimes with `ssisProperties` (i.e., SSIS-enabled IRs) are in scope. Self-Hosted IRs and Managed IRs without `ssisProperties` (data movement / managed VNet only) do not consume a SQL license and are ignored by both the policy and the remediation script. + +## Why AuditIfNotExists Instead Of DeployIfNotExists + +The four sibling packs (`sql-arc`, `sql-mi`, `sql-iaas`, `sql-paas`) use `DeployIfNotExists` with an embedded ARM template that PATCHes a single license property. That works because their resource providers merge nested properties on PUT. + +The Azure Data Factory resource provider does **not**: `properties.typeProperties` on `Microsoft.DataFactory/factories/integrationRuntimes` is a discriminated union, and a partial PUT through ARM would null out `computeProperties` (node size, count, VNet, etc.) and break the runtime. The official Microsoft sample [`enable-payg-for-azure-sql.ps1`](https://github.com/microsoft/sql-server-samples/blob/master/samples/manage/enable-payg-for-azure-sql/enable-payg-for-azure-sql.ps1) avoids this by calling `Set-AzDataFactoryV2IntegrationRuntime`, which performs an internal GET → merge → PUT against the data plane. + +The `Modify` effect is also not available: the `Managed.typeProperties.ssisProperties.licenseType` alias is **not** marked `Modifiable`. + +This pack therefore: + +- **Audits** drift via the policy assignment (so SSIS IRs show up in the compliance dashboard alongside the other SQL workloads). +- **Remediates** out-of-band via `scripts/start-remediation.ps1`, which mirrors the Microsoft sample. + +## Licensing Conditions + +When selecting Azure Hybrid Benefit, ensure you meet the licensing requirements: + +- **Azure Hybrid Benefit** (`BasePrice`): *"I confirm that I have a SQL Server License with Software Assurance to apply this Azure Hybrid Benefit for SQL Server."* + +The deployment script will prompt for confirmation when targeting `BasePrice`. Use `-SkipLicenseConfirmation` to suppress the prompt in automated pipelines (the operator assumes responsibility for license compliance). + +## Prerequisites + +- PowerShell with Az modules installed (`Az.Resources` for deployment; `Az.DataFactory` for remediation). +- Logged in to Azure (`Connect-AzAccount`). +- Permissions to create policy definitions/assignments at target scope. +- For remediation: Contributor on each Data Factory whose Integration Runtimes will be updated. + +## Deploy Policy + +Parameter reference: + +| Parameter | Required | Default | Allowed values | Description | +|---|---|---|---|---| +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Scope where the policy definition is created. Defaults to the tenant root management group when not specified. | +| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, policy assignment scope is the subscription. | +| `TargetLicenseType` | Yes | N/A | `LicenseIncluded`, `BasePrice` | Target license type to enforce. | +| `LicenseTypesToOverwrite` | No | All | `LicenseIncluded`, `BasePrice` | Select which current license states are eligible for update. | +| `SkipLicenseConfirmation` | No | `false` | Switch (`present`/`not present`) | Skip the interactive license confirmation prompt (for CI/CD pipelines). | + +Definition and assignment creation: + +1. Download the required files. + +```powershell +# Optional: create and enter a local working directory +mkdir sa-sql-ssis-policy +cd sa-sql-ssis-policy +``` + +```powershell +$baseUrl = "https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance" + +New-Item -ItemType Directory -Path policy, scripts -Force | Out-Null + +curl -sLo policy/azurepolicy.json "$baseUrl/policy/azurepolicy.json" +curl -sLo scripts/deployment.ps1 "$baseUrl/scripts/deployment.ps1" +curl -sLo scripts/start-remediation.ps1 "$baseUrl/scripts/start-remediation.ps1" +``` + +> **Note:** On Windows PowerShell 5.1, `curl` is an alias for `Invoke-WebRequest`. Use `curl.exe` instead, or run the commands in PowerShell 7+. + +2. Login to Azure. + +```powershell +Connect-AzAccount +``` + +3. Set your variables. Only `TargetLicenseType` is required — all others are optional. + +```powershell +# ── Required ── +$TargetLicenseType = "LicenseIncluded" # "LicenseIncluded" or "BasePrice" + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: policy assigned at management group scope +# $LicenseTypesToOverwrite = @("LicenseIncluded","BasePrice") # Default: all +``` + +4. Run the deployment. + +```powershell +# Minimal — uses defaults for management group and overwrite targets +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType + +# With subscription scope +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId + +# With all options +.\scripts\deployment.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType ` + -LicenseTypesToOverwrite $LicenseTypesToOverwrite +``` + +This will: +* Create/update the policy definition at the management group scope. +* Create/assign the policy (at subscription scope when `-SubscriptionId` is provided, otherwise at management group scope). +* Audit (no system-assigned identity is created — `AuditIfNotExists` does not require one) all Managed SSIS Integration Runtimes whose current `licenseType` is in `LicenseTypesToOverwrite` and does not match `TargetLicenseType`. + +**Scenario examples:** + +```powershell +# Audit all SSIS IRs against Pay-as-you-go +.\scripts\deployment.ps1 -TargetLicenseType "LicenseIncluded" + +# Audit only IRs currently on Pay-as-you-go against Azure Hybrid Benefit +.\scripts\deployment.ps1 -TargetLicenseType "BasePrice" -LicenseTypesToOverwrite @("LicenseIncluded") +``` + +## Start Remediation + +Unlike the sibling packs, remediation does **not** use `Start-AzPolicyRemediation` — see [Why AuditIfNotExists Instead Of DeployIfNotExists](#why-auditifnotexists-instead-of-deployifnotexists). Instead, `scripts/start-remediation.ps1` enumerates SSIS IRs in scope and updates each one via `Set-AzDataFactoryV2IntegrationRuntime`. + +Parameter reference: + +| Parameter | Required | Default | Allowed values | Description | +|---|---|---|---|---| +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Enumerates all subscriptions under this management group when `SubscriptionId` is not specified. | +| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, remediation is limited to this subscription. | +| `TargetLicenseType` | Yes | N/A | `LicenseIncluded`, `BasePrice` | Must match the assignment target license type. | +| `LicenseTypesToOverwrite` | No | All | `LicenseIncluded`, `BasePrice` | Filter which current license states are eligible for update. Should match the value used at deployment time. | +| `Force` | No | `false` | Switch (`present`/`not present`) | Skip the interactive confirmation that lists candidates before applying updates. | + +1. Set your variables. `TargetLicenseType` is required and must match the value used during deployment — all others are optional. + +```powershell +# ── Required ── +$TargetLicenseType = "LicenseIncluded" # Must match the deployment target + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: scans all subscriptions under the management group +# $LicenseTypesToOverwrite = @("LicenseIncluded","BasePrice") # Default: all +``` + +2. Run the remediation. + +```powershell +# Minimal — scans all subscriptions under the tenant root management group +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType + +# With subscription scope +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId + +# With all options (non-interactive) +.\scripts\start-remediation.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType ` + -LicenseTypesToOverwrite $LicenseTypesToOverwrite ` + -Force +``` + +The script: + +1. Enumerates Data Factories and Integration Runtimes in scope. +2. Filters to Managed SSIS IRs whose `LicenseType` does not match `TargetLicenseType` and whose current value is in `LicenseTypesToOverwrite`. +3. Prints the candidate list and prompts for confirmation (skip with `-Force`). +4. Calls `Set-AzDataFactoryV2IntegrationRuntime -LicenseType -Force` against each candidate (the cmdlet performs the required GET → merge → PUT internally). +5. Reports per-IR success or failure. + +> **Note:** `Set-AzDataFactoryV2IntegrationRuntime` requires the caller to have at least **Data Factory Contributor** (or Contributor) on each factory. The script runs in the interactive Az context — there is no managed identity involved. + +## Scope + +The policy targets `Microsoft.DataFactory/factories/integrationRuntimes` resources and is filtered to: + +- IRs whose `type` is `Managed`. +- IRs that have `typeProperties.ssisProperties` (i.e., SSIS-enabled). Self-Hosted IRs and Managed IRs without SSIS properties are out of scope. + +## Reference + +- Microsoft sample script (origin of this pattern): [enable-payg-for-azure-sql.ps1](https://github.com/microsoft/sql-server-samples/blob/master/samples/manage/enable-payg-for-azure-sql/enable-payg-for-azure-sql.ps1) +- Azure Data Factory: [Configure Azure-SSIS Integration Runtime](https://learn.microsoft.com/azure/data-factory/create-azure-ssis-integration-runtime) diff --git a/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/policy/azurepolicy.json b/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/policy/azurepolicy.json new file mode 100644 index 0000000000..2a44111600 --- /dev/null +++ b/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/policy/azurepolicy.json @@ -0,0 +1,109 @@ +{ + "displayName": "Audit Azure Data Factory SSIS Integration Runtime license type", + "policyType": "Custom", + "mode": "All", + "description": "Audits Azure Data Factory Managed SSIS Integration Runtimes whose licenseType does not match the target value (LicenseIncluded = PAYG, BasePrice = Azure Hybrid Benefit). Remediate non-compliant IRs by running start-remediation.ps1 (see metadata.helpLink) during a maintenance window: stop the IR, set licenseType, restart. DeployIfNotExists is not supported because the ARM PUT requires the IR to be in Initial or Stopped state.", + "metadata": { + "category": "Data Factory", + "helpLink": "https://github.com/microsoft/sql-server-samples/blob/master/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/scripts/start-remediation.ps1", + "source": "https://github.com/microsoft/sql-server-samples/tree/master/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance" + }, + "parameters": { + "effect": { + "type": "String", + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy." + }, + "allowedValues": [ + "AuditIfNotExists", + "Disabled" + ], + "defaultValue": "AuditIfNotExists" + }, + "targetLicenseType": { + "type": "String", + "metadata": { + "displayName": "Target license type", + "description": "License type to enforce on the SSIS Integration Runtime. LicenseIncluded = Pay-as-you-go, BasePrice = Azure Hybrid Benefit." + }, + "allowedValues": [ + "LicenseIncluded", + "BasePrice" + ], + "defaultValue": "LicenseIncluded" + }, + "licenseTypesToOverwrite": { + "type": "Array", + "metadata": { + "displayName": "Current license types to overwrite", + "description": "Select which current license type states are eligible for update." + }, + "allowedValues": [ + "LicenseIncluded", + "BasePrice" + ], + "defaultValue": [ + "LicenseIncluded", + "BasePrice" + ] + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.DataFactory/factories/integrationRuntimes" + }, + { + "field": "Microsoft.DataFactory/factories/integrationruntimes/type", + "equals": "Managed" + }, + { + "field": "Microsoft.DataFactory/factories/integrationRuntimes/Managed.typeProperties.ssisProperties.licenseType", + "exists": "true" + } + ] + }, + "then": { + "effect": "[parameters('effect')]", + "details": { + "type": "Microsoft.DataFactory/factories/integrationRuntimes", + "name": "[field('fullName')]", + "existenceCondition": { + "anyOf": [ + { + "field": "Microsoft.DataFactory/factories/integrationRuntimes/Managed.typeProperties.ssisProperties.licenseType", + "equals": "[parameters('targetLicenseType')]" + }, + { + "allOf": [ + { + "field": "Microsoft.DataFactory/factories/integrationRuntimes/Managed.typeProperties.ssisProperties.licenseType", + "equals": "LicenseIncluded" + }, + { + "value": "[contains(parameters('licenseTypesToOverwrite'), 'LicenseIncluded')]", + "equals": false + } + ] + }, + { + "allOf": [ + { + "field": "Microsoft.DataFactory/factories/integrationRuntimes/Managed.typeProperties.ssisProperties.licenseType", + "equals": "BasePrice" + }, + { + "value": "[contains(parameters('licenseTypesToOverwrite'), 'BasePrice')]", + "equals": false + } + ] + } + ] + } + } + } + } +} diff --git a/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/scripts/deployment.ps1 b/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/scripts/deployment.ps1 new file mode 100644 index 0000000000..ad5aa34dec --- /dev/null +++ b/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/scripts/deployment.ps1 @@ -0,0 +1,93 @@ +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ManagementGroupId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionId, + + [Parameter(Mandatory = $true)] + [ValidateSet('LicenseIncluded', 'BasePrice')] + [string]$TargetLicenseType, + + [Parameter(Mandatory = $false)] + [ValidateSet('LicenseIncluded', 'BasePrice')] + [string[]]$LicenseTypesToOverwrite = @('LicenseIncluded', 'BasePrice'), + + [Parameter(Mandatory = $false)] + [switch]$SkipLicenseConfirmation +) + +$LicenseConfirmations = @{ + 'BasePrice' = "I confirm that I have a SQL Server License with Software Assurance to apply this Azure Hybrid Benefit for SQL Server." +} + +if (-not $SkipLicenseConfirmation -and $LicenseConfirmations.ContainsKey($TargetLicenseType)) { + $confirmationMessage = $LicenseConfirmations[$TargetLicenseType] + Write-Host "`n$confirmationMessage" -ForegroundColor Yellow + $response = Read-Host "Do you agree? (Y/N)" + if ($response -notin @('Y', 'y', 'Yes', 'yes')) { + Write-Output "Deployment cancelled. License confirmation was not accepted." + return + } +} + +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + +$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" + +if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $AssignmentScope = "/subscriptions/$SubscriptionId" +} + +$PolicyJsonPath = Join-Path $PSScriptRoot '..\policy\azurepolicy.json' + +$LicenseToken = switch ($TargetLicenseType) { + 'LicenseIncluded' { 'payg' } + 'BasePrice' { 'ahb' } +} + +$LicenseTypeLabel = switch ($TargetLicenseType) { + 'LicenseIncluded' { 'Pay-as-you-go' } + 'BasePrice' { 'Azure Hybrid Benefit' } +} + +$PolicyDefinitionName = "audit-sql-ssis-$LicenseToken" +$PolicyAssignmentName = "sql-ssis-$LicenseToken" +$PolicyDefinitionDisplayName = "Audit Azure Data Factory SSIS Integration Runtime license type ('$LicenseTypeLabel')" +$PolicyAssignmentDisplayName = "Audit Azure Data Factory SSIS Integration Runtime license type ('$LicenseTypeLabel')" + +#Create policy definition +New-AzPolicyDefinition ` + -Name $PolicyDefinitionName ` + -DisplayName $PolicyDefinitionDisplayName ` + -Policy $PolicyJsonPath ` + -ManagementGroupName $ManagementGroupId ` + -Mode All ` + -ErrorAction Stop + +#Assign policy definition +$RemediationScriptUrl = 'https://github.com/microsoft/sql-server-samples/blob/master/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/scripts/start-remediation.ps1' + +$NonComplianceMessageText = "SSIS Integration Runtime licenseType is not '$TargetLicenseType' ($LicenseTypeLabel). Remediate by running start-remediation.ps1 during a maintenance window (IR will be briefly stopped, reconfigured, then started). DeployIfNotExists is not supported (ARM PUT requires Initial/Stopped state). Script: $RemediationScriptUrl" + +$Policy = Get-AzPolicyDefinition -Name $PolicyDefinitionName -ManagementGroupName $ManagementGroupId +New-AzPolicyAssignment ` + -Name $PolicyAssignmentName ` + -DisplayName $PolicyAssignmentDisplayName ` + -PolicyDefinition $Policy ` + -PolicyParameterObject @{ + targetLicenseType = $TargetLicenseType + licenseTypesToOverwrite = $LicenseTypesToOverwrite + } ` + -Scope $AssignmentScope ` + -NonComplianceMessage @( + @{ + Message = $NonComplianceMessageText + } + ) ` + -ErrorAction Stop diff --git a/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/scripts/start-remediation.ps1 b/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/scripts/start-remediation.ps1 new file mode 100644 index 0000000000..89ceab51f3 --- /dev/null +++ b/samples/manage/azure-data-factory-ssis/sql-ssis-license-type-compliance/scripts/start-remediation.ps1 @@ -0,0 +1,206 @@ +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ManagementGroupId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionId, + + [Parameter(Mandatory = $true)] + [ValidateSet('LicenseIncluded', 'BasePrice')] + [string]$TargetLicenseType, + + [Parameter(Mandatory = $false)] + [ValidateSet('LicenseIncluded', 'BasePrice')] + [string[]]$LicenseTypesToOverwrite = @('LicenseIncluded', 'BasePrice'), + + [Parameter(Mandatory = $false)] + [switch]$AutoStopStart, + + [Parameter(Mandatory = $false)] + [switch]$Force +) + +# Resolve subscriptions in scope. +# SSIS IR remediation cannot use Start-AzPolicyRemediation because the underlying +# ARM CreateOrUpdate replaces the integrationRuntime's discriminated-union +# typeProperties block. We mirror the official Microsoft sample pattern instead, +# which calls Set-AzDataFactoryV2IntegrationRuntime (it performs an internal +# GET / merge / PUT against the data plane). +if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $subscriptions = @([pscustomobject]@{ Id = $SubscriptionId }) +} +else { + if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" + } + try { + $subscriptions = Get-AzManagementGroupSubscription -GroupId $ManagementGroupId -ErrorAction Stop + } + catch { + throw "Failed to enumerate subscriptions under management group '$ManagementGroupId': $($_.Exception.Message)" + } +} + +if (-not (Get-Module -ListAvailable -Name Az.DataFactory)) { + throw "Az.DataFactory module is required. Install with: Install-Module Az.DataFactory -Scope CurrentUser" +} +Import-Module Az.DataFactory -ErrorAction Stop + +$candidates = @() + +foreach ($sub in $subscriptions) { + # Normalize: Get-AzManagementGroupSubscription returns .Id as a full ARM path + # (/providers/Microsoft.Management/managementGroups//subscriptions/), + # while the synthetic pscustomobject we create from -SubscriptionId puts the + # bare GUID in .Id. Extract the GUID for Set-AzContext. + $subId = $null + if ($sub.Id -match 'subscriptions/([0-9a-fA-F-]{36})$') { $subId = $matches[1] } + elseif ($sub.Id -match '^[0-9a-fA-F-]{36}$') { $subId = $sub.Id } + elseif ($sub.SubscriptionId) { $subId = $sub.SubscriptionId } + elseif ($sub.Name -match '^[0-9a-fA-F-]{36}$') { $subId = $sub.Name } + else { $subId = [string]$sub } + + try { + Set-AzContext -SubscriptionId $subId -ErrorAction Stop | Out-Null + } + catch { + Write-Warning "Skipping subscription $subId (cannot set context): $($_.Exception.Message)" + continue + } + + $factories = Get-AzDataFactoryV2 -ErrorAction SilentlyContinue + foreach ($factory in $factories) { + $irs = Get-AzDataFactoryV2IntegrationRuntime ` + -ResourceGroupName $factory.ResourceGroupName ` + -DataFactoryName $factory.DataFactoryName ` + -ErrorAction SilentlyContinue + foreach ($ir in $irs) { + # Only Managed SSIS IRs have NodeSize set. + if ($null -eq $ir.NodeSize) { continue } + if ($null -eq $ir.LicenseType) { continue } + if ($ir.LicenseType -eq $TargetLicenseType) { continue } + if ($ir.LicenseType -notin $LicenseTypesToOverwrite) { continue } + + $candidates += [pscustomobject]@{ + SubscriptionId = $subId + ResourceGroupName = $factory.ResourceGroupName + DataFactoryName = $factory.DataFactoryName + Name = $ir.Name + CurrentLicenseType = $ir.LicenseType + State = $ir.State + } + } + } +} + +if ($candidates.Count -eq 0) { + Write-Output "No SSIS Integration Runtimes require remediation to '$TargetLicenseType'." + return +} + +Write-Output ([Environment]::NewLine + "Found $($candidates.Count) SSIS Integration Runtime(s) to remediate to '$TargetLicenseType':") +$candidates | + Format-Table SubscriptionId, ResourceGroupName, DataFactoryName, Name, CurrentLicenseType, State -AutoSize | + Out-String | + Write-Output + +$startedCandidates = @($candidates | Where-Object { $_.State -eq 'Started' }) +if ($startedCandidates.Count -gt 0) { + if ($AutoStopStart) { + Write-Warning "$($startedCandidates.Count) IR(s) are in 'Started' state. With -AutoStopStart, each will be STOPPED, reconfigured, then STARTED again (provisioning a Managed SSIS IR back to Started typically takes 20-30 minutes per IR)." + } + else { + Write-Warning "$($startedCandidates.Count) IR(s) are in 'Started' state. ARM will reject the licenseType change with 'IntegrationRuntimeCannotModify'. Re-run with -AutoStopStart to stop, reconfigure, and restart automatically." + } +} + +if (-not $Force) { + $response = Read-Host "Proceed with remediation? (Y/N)" + if ($response -notin @('Y', 'y', 'Yes', 'yes')) { + Write-Output "Remediation cancelled." + return + } +} + +foreach ($c in $candidates) { + $stoppedByUs = $false + try { + Set-AzContext -SubscriptionId $c.SubscriptionId -ErrorAction Stop | Out-Null + + # Stop the IR first if requested and currently Started. + if ($AutoStopStart -and $c.State -eq 'Started') { + Write-Output "Stopping $($c.DataFactoryName)/$($c.Name) (rg=$($c.ResourceGroupName))..." + Stop-AzDataFactoryV2IntegrationRuntime ` + -ResourceGroupName $c.ResourceGroupName ` + -DataFactoryName $c.DataFactoryName ` + -Name $c.Name ` + -Force ` + -ErrorAction Stop | Out-Null + $stoppedByUs = $true + } + + Set-AzDataFactoryV2IntegrationRuntime ` + -ResourceGroupName $c.ResourceGroupName ` + -DataFactoryName $c.DataFactoryName ` + -Name $c.Name ` + -LicenseType $TargetLicenseType ` + -Force ` + -ErrorAction Stop | Out-Null + + Write-Output "Updated $($c.DataFactoryName)/$($c.Name) from '$($c.CurrentLicenseType)' to '$TargetLicenseType' (rg=$($c.ResourceGroupName), sub=$($c.SubscriptionId))." + + # Restart only IRs that we stopped (preserves operator intent for IRs that + # were already Stopped at the time of discovery). + if ($stoppedByUs) { + Write-Output "Starting $($c.DataFactoryName)/$($c.Name) (this can take 20-30 minutes)..." + $startErr = $null + try { + Start-AzDataFactoryV2IntegrationRuntime ` + -ResourceGroupName $c.ResourceGroupName ` + -DataFactoryName $c.DataFactoryName ` + -Name $c.Name ` + -Force ` + -ErrorAction Stop | Out-Null + } + catch { + # The cmdlet's status-polling step is known to fail transiently even + # when ARM accepted the Start request. Verify the actual IR state + # before deciding whether to warn. + $startErr = $_ + } + + try { + $postState = (Get-AzDataFactoryV2IntegrationRuntime ` + -ResourceGroupName $c.ResourceGroupName ` + -DataFactoryName $c.DataFactoryName ` + -Name $c.Name ` + -Status -ErrorAction Stop).State + } + catch { + $postState = $null + } + + if ($postState -in @('Started','Starting')) { + if ($startErr) { + Write-Output "Start cmdlet returned a polling error but the IR is actually '$postState'. Treating as success." + } + Write-Output "Started $($c.DataFactoryName)/$($c.Name) (state=$postState)." + } + elseif ($startErr) { + throw $startErr + } + else { + Write-Warning "$($c.DataFactoryName)/$($c.Name) did not transition to Started/Starting (current state: '$postState'). Please investigate." + } + } + } + catch { + Write-Warning "Failed to update $($c.DataFactoryName)/$($c.Name) (rg=$($c.ResourceGroupName), sub=$($c.SubscriptionId)): $($_.Exception.Message)" + if ($stoppedByUs) { + Write-Warning "$($c.DataFactoryName)/$($c.Name) was stopped by this run but the Set/Start step failed. Please review and start it manually if intended." + } + } +} diff --git a/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/README.md b/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/README.md new file mode 100644 index 0000000000..18416c2cdd --- /dev/null +++ b/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/README.md @@ -0,0 +1,183 @@ +# SQL Managed Instance License Type Configuration with Azure Policy + +This solution deploys and remediates a custom Azure Policy that configures and enforces the `licenseType` property on Azure SQL Managed Instances (`Microsoft.Sql/managedInstances`) to a selected target value. + +## What Is In This Folder + +- `policy/azurepolicy.json`: Custom policy definition (DeployIfNotExists). +- `scripts/deployment.ps1`: Creates/updates the policy definition and policy assignment. +- `scripts/start-remediation.ps1`: Starts a remediation task for the created assignment. + +## License Type Mapping + +The policy uses logical license type values that map to API properties: + +| Parameter value | Portal label | `licenseType` | `hybridSecondaryUsage` | +|---|---|---|---| +| `LicenseIncluded` | Pay-as-you-go | `LicenseIncluded` | `Active` | +| `BasePrice` | Azure Hybrid Benefit | `BasePrice` | `Active` | +| `HybridFailoverRights` | Hybrid failover rights | `BasePrice` | `Passive` | + +## Licensing Conditions + +When selecting certain license types, ensure you meet the licensing requirements: + +- **Azure Hybrid Benefit** (`BasePrice`): *"I confirm that I have a SQL Server License with Software Assurance to apply this Azure Hybrid Benefit for SQL Server."* +- **Hybrid failover rights** (`HybridFailoverRights`): *"I confirm that I will use this Managed Instance as a passive replica of SQL Server(s) for which I have a SQL Server license with Software Assurance, or for which I use Pay-as-you-go billing option."* + +The deployment script will prompt for confirmation when targeting `BasePrice` or `HybridFailoverRights`. Use `-SkipLicenseConfirmation` to suppress the prompt in automated pipelines (the operator assumes responsibility for license compliance). + +## Prerequisites + +- PowerShell with Az modules installed (`Az.Resources`). +- Logged in to Azure (`Connect-AzAccount`). +- Permissions to create policy definitions/assignments and remediation tasks at target scope. + +## Deploy Policy + +Parameter reference: + +| Parameter | Required | Default | Allowed values | Description | +|---|---|---|---|---| +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Scope where the policy definition is created. Defaults to the tenant root management group when not specified. | +| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, policy assignment scope is the subscription. | +| `TargetLicenseType` | Yes | N/A | `LicenseIncluded`, `BasePrice`, `HybridFailoverRights` | Target license type to enforce. | +| `LicenseTypesToOverwrite` | No | All | `LicenseIncluded`, `BasePrice`, `HybridFailoverRights` | Select which current license states are eligible for update. | +| `SkipLicenseConfirmation` | No | `false` | Switch (`present`/`not present`) | Skip the interactive license confirmation prompt (for CI/CD pipelines). | + +Definition and assignment creation: + +1. Download the required files. + +```powershell +# Optional: create and enter a local working directory +mkdir sa-sql-mi-policy +cd sa-sql-mi-policy +``` + +```powershell +$baseUrl = "https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance" + +New-Item -ItemType Directory -Path policy, scripts -Force | Out-Null + +curl -sLo policy/azurepolicy.json "$baseUrl/policy/azurepolicy.json" +curl -sLo scripts/deployment.ps1 "$baseUrl/scripts/deployment.ps1" +curl -sLo scripts/start-remediation.ps1 "$baseUrl/scripts/start-remediation.ps1" +``` + +> **Note:** On Windows PowerShell 5.1, `curl` is an alias for `Invoke-WebRequest`. Use `curl.exe` instead, or run the commands in PowerShell 7+. + +2. Login to Azure. + +```powershell +Connect-AzAccount +``` + +3. Set your variables. Only `TargetLicenseType` is required — all others are optional. + +```powershell +# ── Required ── +$TargetLicenseType = "LicenseIncluded" # "LicenseIncluded", "BasePrice", or "HybridFailoverRights" + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: policy assigned at management group scope +# $LicenseTypesToOverwrite = @("LicenseIncluded","BasePrice","HybridFailoverRights") # Default: all +``` + +4. Run the deployment. + +```powershell +# Minimal — uses defaults for management group and overwrite targets +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType + +# With subscription scope +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId + +# With all options +.\scripts\deployment.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType ` + -LicenseTypesToOverwrite $LicenseTypesToOverwrite +``` + +This will: +* Create/update the policy definition at the management group scope. +* Create/assign the policy (at subscription scope when `-SubscriptionId` is provided, otherwise at management group scope). +* Enforce the selected `TargetLicenseType` on resources matching the `LicenseTypesToOverwrite` filter. + +**Scenario examples:** + +```powershell +# Move all instances to Pay-as-you-go +.\scripts\deployment.ps1 -TargetLicenseType "LicenseIncluded" + +# Move Pay-as-you-go instances to Azure Hybrid Benefit +.\scripts\deployment.ps1 -TargetLicenseType "BasePrice" -LicenseTypesToOverwrite @("LicenseIncluded") + +# Configure hybrid failover rights, only for instances currently on Azure Hybrid Benefit +.\scripts\deployment.ps1 -TargetLicenseType "HybridFailoverRights" -LicenseTypesToOverwrite @("BasePrice") +``` + +> **Note:** `deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope. + +## Start Remediation + +Parameter reference: + +| Parameter | Required | Default | Allowed values | Description | +|---|---|---|---|---| +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Used to resolve the policy definition/assignment naming context. Defaults to the tenant root management group when not specified. | +| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, remediation runs at subscription scope. | +| `TargetLicenseType` | Yes | N/A | `LicenseIncluded`, `BasePrice`, `HybridFailoverRights` | Must match the assignment target license type. | +| `GrantMissingPermissions` | No | `false` | Switch (`present`/`not present`) | If set, checks and assigns missing required roles before remediation. | + +1. Set your variables. `TargetLicenseType` is required and must match the value used during deployment — all others are optional. + +```powershell +# ── Required ── +$TargetLicenseType = "LicenseIncluded" # Must match the deployment target + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: remediation runs at management group scope +``` + +2. Run the remediation. + +```powershell +# Minimal — uses defaults for management group +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -GrantMissingPermissions + +# With subscription scope +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId -GrantMissingPermissions + +# With all options +.\scripts\start-remediation.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType ` + -GrantMissingPermissions +``` + +> **Note:** Use `-GrantMissingPermissions` to automatically check and assign any missing required roles before remediation starts. + +## Managed Identity And Roles + +The policy assignment is created with `-IdentityType SystemAssigned`. Azure creates a managed identity on the assignment and uses it to apply DeployIfNotExists changes during enforcement and remediation. + +Required roles: + +- `SQL Managed Instance Contributor` (`4939a1f6-9ae0-4e48-a1e0-f2cbe897382d`) +- `Reader` (`acdd72a7-3385-48ef-bd42-f606fba81ae7`) +- `Resource Policy Contributor` (required so DeployIfNotExists can create template deployments) + +## Troubleshooting + +If you see `PolicyAuthorizationFailed`, the policy assignment identity is missing one or more required roles at assignment scope. + +Use one of these options: + +- Re-run `scripts/deployment.ps1` (default behavior assigns required roles automatically). +- Run `scripts/start-remediation.ps1 -GrantMissingPermissions` (checks and assigns missing required roles before remediation). diff --git a/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/policy/azurepolicy.json b/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/policy/azurepolicy.json new file mode 100644 index 0000000000..2fa2a9be85 --- /dev/null +++ b/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/policy/azurepolicy.json @@ -0,0 +1,222 @@ +{ + "displayName": "Configure SQL Managed Instance license type", + "policyType": "Custom", + "mode": "Indexed", + "description": "This policy configures the license type for Azure SQL Managed Instances to a specified target value.", + "metadata": { + "category": "" + }, + "version": "1.0.0", + "parameters": { + "effect": { + "type": "String", + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy." + }, + "allowedValues": [ + "DeployIfNotExists", + "Disabled" + ], + "defaultValue": "DeployIfNotExists" + }, + "targetLicenseType": { + "type": "String", + "metadata": { + "displayName": "Target license type", + "description": "License type to enforce on the SQL Managed Instance. LicenseIncluded = Pay-as-you-go, BasePrice = Azure Hybrid Benefit, HybridFailoverRights = Hybrid failover rights (passive DR)." + }, + "allowedValues": [ + "LicenseIncluded", + "BasePrice", + "HybridFailoverRights" + ], + "defaultValue": "LicenseIncluded" + }, + "licenseTypesToOverwrite": { + "type": "Array", + "metadata": { + "displayName": "Current license types to overwrite", + "description": "Select which current license type states are eligible for update." + }, + "allowedValues": [ + "LicenseIncluded", + "BasePrice", + "HybridFailoverRights" + ], + "defaultValue": [ + "LicenseIncluded", + "BasePrice", + "HybridFailoverRights" + ] + } + }, + "policyRule": { + "if": { + "field": "type", + "equals": "Microsoft.Sql/managedInstances" + }, + "then": { + "effect": "[parameters('effect')]", + "details": { + "type": "Microsoft.Sql/managedInstances", + "roleDefinitionIds": [ + "/providers/Microsoft.Authorization/roleDefinitions/4939a1f6-9ae0-4e48-a1e0-f2cbe897382d", + "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" + ], + "name": "[field('name')]", + "existenceCondition": { + "anyOf": [ + { + "allOf": [ + { + "value": "[parameters('targetLicenseType')]", + "equals": "LicenseIncluded" + }, + { + "field": "Microsoft.Sql/managedInstances/licenseType", + "equals": "LicenseIncluded" + } + ] + }, + { + "allOf": [ + { + "value": "[parameters('targetLicenseType')]", + "equals": "BasePrice" + }, + { + "field": "Microsoft.Sql/managedInstances/licenseType", + "equals": "BasePrice" + }, + { + "field": "Microsoft.Sql/managedInstances/hybridSecondaryUsage", + "notEquals": "Passive" + } + ] + }, + { + "allOf": [ + { + "value": "[parameters('targetLicenseType')]", + "equals": "HybridFailoverRights" + }, + { + "field": "Microsoft.Sql/managedInstances/licenseType", + "equals": "BasePrice" + }, + { + "field": "Microsoft.Sql/managedInstances/hybridSecondaryUsage", + "equals": "Passive" + } + ] + }, + { + "allOf": [ + { + "field": "Microsoft.Sql/managedInstances/licenseType", + "equals": "LicenseIncluded" + }, + { + "value": "[contains(parameters('licenseTypesToOverwrite'), 'LicenseIncluded')]", + "equals": false + } + ] + }, + { + "allOf": [ + { + "field": "Microsoft.Sql/managedInstances/licenseType", + "equals": "BasePrice" + }, + { + "field": "Microsoft.Sql/managedInstances/hybridSecondaryUsage", + "notEquals": "Passive" + }, + { + "value": "[contains(parameters('licenseTypesToOverwrite'), 'BasePrice')]", + "equals": false + } + ] + }, + { + "allOf": [ + { + "field": "Microsoft.Sql/managedInstances/licenseType", + "equals": "BasePrice" + }, + { + "field": "Microsoft.Sql/managedInstances/hybridSecondaryUsage", + "equals": "Passive" + }, + { + "value": "[contains(parameters('licenseTypesToOverwrite'), 'HybridFailoverRights')]", + "equals": false + } + ] + } + ] + }, + "evaluationDelay": "AfterProvisioningSuccess", + "deployment": { + "properties": { + "mode": "incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "managedInstanceName": { + "type": "string", + "metadata": { + "description": "The name of the SQL Managed Instance." + } + }, + "location": { + "type": "string", + "metadata": { + "description": "The location of the SQL Managed Instance." + } + }, + "targetLicenseType": { + "type": "string", + "metadata": { + "description": "The logical license type to enforce. Maps to licenseType and hybridSecondaryUsage API properties." + } + } + }, + "functions": [], + "variables": { + "resolvedLicenseType": "[if(equals(parameters('targetLicenseType'), 'HybridFailoverRights'), 'BasePrice', parameters('targetLicenseType'))]", + "resolvedHybridSecondaryUsage": "[if(equals(parameters('targetLicenseType'), 'HybridFailoverRights'), 'Passive', 'Active')]" + }, + "resources": [ + { + "type": "Microsoft.Sql/managedInstances", + "apiVersion": "2023-08-01-preview", + "name": "[parameters('managedInstanceName')]", + "location": "[parameters('location')]", + "properties": { + "licenseType": "[variables('resolvedLicenseType')]", + "hybridSecondaryUsage": "[variables('resolvedHybridSecondaryUsage')]" + } + } + ], + "outputs": {} + }, + "parameters": { + "managedInstanceName": { + "value": "[field('name')]" + }, + "location": { + "value": "[field('location')]" + }, + "targetLicenseType": { + "value": "[parameters('targetLicenseType')]" + } + } + } + } + } + } + } +} diff --git a/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/scripts/deployment.ps1 b/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/scripts/deployment.ps1 new file mode 100644 index 0000000000..c2b2df5665 --- /dev/null +++ b/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/scripts/deployment.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ManagementGroupId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionId, + + [Parameter(Mandatory = $true)] + [ValidateSet('LicenseIncluded', 'BasePrice', 'HybridFailoverRights')] + [string]$TargetLicenseType, + + [Parameter(Mandatory = $false)] + [ValidateSet('LicenseIncluded', 'BasePrice', 'HybridFailoverRights')] + [string[]]$LicenseTypesToOverwrite = @('LicenseIncluded', 'BasePrice', 'HybridFailoverRights'), + + [Parameter(Mandatory = $false)] + [switch]$SkipManagedIdentityRoleAssignment, + + [Parameter(Mandatory = $false)] + [switch]$SkipLicenseConfirmation +) + +$LicenseConfirmations = @{ + 'BasePrice' = "I confirm that I have a SQL Server License with Software Assurance to apply this Azure Hybrid Benefit for SQL Server." + 'HybridFailoverRights' = "I confirm that I will use this Managed Instance as a passive replica of SQL Server(s) for which I have a SQL Server license with Software Assurance, or for which I use Pay-as-you-go billing option." +} + +if (-not $SkipLicenseConfirmation -and $LicenseConfirmations.ContainsKey($TargetLicenseType)) { + $confirmationMessage = $LicenseConfirmations[$TargetLicenseType] + Write-Host "`n$confirmationMessage" -ForegroundColor Yellow + $response = Read-Host "Do you agree? (Y/N)" + if ($response -notin @('Y', 'y', 'Yes', 'yes')) { + Write-Output "Deployment cancelled. License confirmation was not accepted." + return + } +} + +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + +$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" + +if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $AssignmentScope = "/subscriptions/$SubscriptionId" +} + +$PolicyJsonPath = Join-Path $PSScriptRoot '..\policy\azurepolicy.json' + +$LicenseToken = switch ($TargetLicenseType) { + 'LicenseIncluded' { 'payg' } + 'BasePrice' { 'ahb' } + 'HybridFailoverRights' { 'hfr' } +} + +$LicenseTypeLabel = switch ($TargetLicenseType) { + 'LicenseIncluded' { 'Pay-as-you-go' } + 'BasePrice' { 'Azure Hybrid Benefit' } + 'HybridFailoverRights' { 'Hybrid failover rights' } +} + +$PolicyDefinitionName = "activate-sql-mi-$LicenseToken" +$PolicyAssignmentName = "sql-mi-$LicenseToken" +$PolicyDefinitionDisplayName = "Configure SQL Managed Instance license type to '$LicenseTypeLabel'" +$PolicyAssignmentDisplayName = "Configure SQL Managed Instance license type to '$LicenseTypeLabel'" + +#Create policy definition +New-AzPolicyDefinition ` + -Name $PolicyDefinitionName ` + -DisplayName $PolicyDefinitionDisplayName ` + -Policy $PolicyJsonPath ` + -ManagementGroupName $ManagementGroupId ` + -Mode Indexed ` + -ErrorAction Stop + +#Assign policy definition +$Policy = Get-AzPolicyDefinition -Name $PolicyDefinitionName -ManagementGroupName $ManagementGroupId +$PolicyAssignment = New-AzPolicyAssignment ` + -Name $PolicyAssignmentName ` + -DisplayName $PolicyAssignmentDisplayName ` + -PolicyDefinition $Policy ` + -PolicyParameterObject @{ + targetLicenseType = $TargetLicenseType + licenseTypesToOverwrite = $LicenseTypesToOverwrite + } ` + -Scope $AssignmentScope ` + -Location 'westeurope' ` + -IdentityType 'SystemAssigned' ` + -ErrorAction Stop + +if (-not $SkipManagedIdentityRoleAssignment) { + $requiredRoleNames = @( + 'SQL Managed Instance Contributor' + 'Reader' + 'Resource Policy Contributor' + ) + $principalId = $PolicyAssignment.IdentityPrincipalId + + if ([string]::IsNullOrEmpty($principalId)) { + throw "Policy assignment identity principal ID is empty. Cannot assign required roles." + } + + foreach ($requiredRoleName in $requiredRoleNames) { + $existingRole = Get-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction SilentlyContinue + + if (-not $existingRole) { + $maxRetries = 5 + $retryDelay = 10 + for ($i = 1; $i -le $maxRetries; $i++) { + try { + New-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction Stop | Out-Null + + Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope." + break + } + catch { + if ($_.Exception.Message -match 'Conflict') { + Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope (confirmed after retry)." + break + } + if ($i -eq $maxRetries) { throw } + Write-Output "Waiting ${retryDelay}s for identity replication before assigning '$requiredRoleName' ($i/$maxRetries)..." + Start-Sleep -Seconds $retryDelay + } + } + } + else { + Write-Output "Policy assignment identity already has '$requiredRoleName' at scope $AssignmentScope." + } + } +} diff --git a/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/scripts/start-remediation.ps1 b/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/scripts/start-remediation.ps1 new file mode 100644 index 0000000000..1377d0971f --- /dev/null +++ b/samples/manage/azure-sql-db-managed-instance/sql-mi-license-type-compliance/scripts/start-remediation.ps1 @@ -0,0 +1,124 @@ +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ManagementGroupId, + + [Parameter(Mandatory = $true)] + [ValidateSet('LicenseIncluded', 'BasePrice', 'HybridFailoverRights')] + [string]$TargetLicenseType, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$PolicyAssignmentName, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$RemediationName, + + [Parameter(Mandatory = $false)] + [ValidateSet('ExistingNonCompliant', 'ReEvaluateCompliance')] + [string]$ResourceDiscoveryMode, + + [Parameter(Mandatory = $false)] + [switch]$GrantMissingPermissions +) + +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + +$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" + +if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $AssignmentScope = "/subscriptions/$SubscriptionId" +} + +$LicenseToken = switch ($TargetLicenseType) { + 'LicenseIncluded' { 'payg' } + 'BasePrice' { 'ahb' } + 'HybridFailoverRights' { 'hfr' } +} + +if (-not $PSBoundParameters.ContainsKey('PolicyAssignmentName')) { + $PolicyAssignmentName = "sql-mi-$LicenseToken" +} + +if (-not $PSBoundParameters.ContainsKey('RemediationName')) { + $RemediationName = "remediate-sql-mi-$LicenseToken" +} + +if (-not $PSBoundParameters.ContainsKey('ResourceDiscoveryMode')) { + if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $ResourceDiscoveryMode = 'ReEvaluateCompliance' + } + else { + $ResourceDiscoveryMode = 'ExistingNonCompliant' + } +} + +# Validate assignment exists before creating remediation. +$PolicyAssignmentObj = Get-AzPolicyAssignment -Scope $AssignmentScope -Name $PolicyAssignmentName -ErrorAction Stop + +$requiredRoleNames = @( + 'SQL Managed Instance Contributor' + 'Reader' + 'Resource Policy Contributor' +) +$principalId = $PolicyAssignmentObj.IdentityPrincipalId + +if ([string]::IsNullOrEmpty($principalId)) { + throw "Policy assignment identity principal ID is empty. Cannot verify required roles." +} + +$missingRoles = @() + +foreach ($requiredRoleName in $requiredRoleNames) { + $requiredRole = Get-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction SilentlyContinue + + if (-not $requiredRole) { + $missingRoles += $requiredRoleName + } +} + +if ($missingRoles.Count -gt 0) { + if ($GrantMissingPermissions) { + foreach ($missingRole in $missingRoles) { + New-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $missingRole ` + -Scope $AssignmentScope ` + -ErrorAction Stop | Out-Null + + Write-Output "Assigned '$missingRole' to policy assignment identity ($principalId) at scope $AssignmentScope." + } + } + else { + throw "Missing required roles [$($missingRoles -join ', ')] for policy assignment identity ($principalId) at scope $AssignmentScope. Re-run with -GrantMissingPermissions or assign the roles manually." + } +} + +$CommonParams = @{ + Name = $RemediationName + PolicyAssignmentId = $PolicyAssignmentObj.Id + Scope = $AssignmentScope + ResourceDiscoveryMode = $ResourceDiscoveryMode +} + +if (Get-Command -Name Start-AzPolicyRemediation -ErrorAction SilentlyContinue) { + Start-AzPolicyRemediation @CommonParams +} +elseif (Get-Command -Name New-AzPolicyRemediation -ErrorAction SilentlyContinue) { + New-AzPolicyRemediation @CommonParams +} +else { + throw "Neither Start-AzPolicyRemediation nor New-AzPolicyRemediation is available. Install/update Az.PolicyInsights." +} diff --git a/samples/manage/azure-sql-db/sql-paas-license-type-compliance/README.md b/samples/manage/azure-sql-db/sql-paas-license-type-compliance/README.md new file mode 100644 index 0000000000..7f7aaa1f98 --- /dev/null +++ b/samples/manage/azure-sql-db/sql-paas-license-type-compliance/README.md @@ -0,0 +1,181 @@ +# Azure SQL Database (PaaS) License Type Configuration with Azure Policy + +This solution deploys and remediates a custom Azure Policy that configures and enforces the `licenseType` property on Azure SQL Databases (`Microsoft.Sql/servers/databases`) to a selected target value. + +## What Is In This Folder + +- `policy/azurepolicy.json`: Custom policy definition (DeployIfNotExists). +- `scripts/deployment.ps1`: Creates/updates the policy definition and policy assignment. +- `scripts/start-remediation.ps1`: Starts a remediation task for the created assignment. + +## License Type Mapping + +| Parameter value | Portal label | `licenseType` | +|---|---|---| +| `LicenseIncluded` | Pay-as-you-go | `LicenseIncluded` | +| `BasePrice` | Azure Hybrid Benefit | `BasePrice` | + +> **Note:** License type configuration is only available for databases using the **Provisioned** compute tier. Databases configured with the **Serverless** compute tier do not support the `licenseType` property. These databases will be flagged as non-compliant by the policy, but remediation cannot change the license type. To configure the license type, switch the database to the Provisioned compute tier. + +## Licensing Conditions + +When selecting Azure Hybrid Benefit, ensure you meet the licensing requirements: + +- **Azure Hybrid Benefit** (`BasePrice`): *"I confirm that I have a SQL Server License with Software Assurance to apply this Azure Hybrid Benefit for SQL Server."* + +The deployment script will prompt for confirmation when targeting `BasePrice`. Use `-SkipLicenseConfirmation` to suppress the prompt in automated pipelines (the operator assumes responsibility for license compliance). + +## Prerequisites + +- PowerShell with Az modules installed (`Az.Resources`). +- Logged in to Azure (`Connect-AzAccount`). +- Permissions to create policy definitions/assignments and remediation tasks at target scope. + +## Deploy Policy + +Parameter reference: + +| Parameter | Required | Default | Allowed values | Description | +|---|---|---|---|---| +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Scope where the policy definition is created. Defaults to the tenant root management group when not specified. | +| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, policy assignment scope is the subscription. | +| `TargetLicenseType` | Yes | N/A | `LicenseIncluded`, `BasePrice` | Target license type to enforce. | +| `SkipLicenseConfirmation` | No | `false` | Switch (`present`/`not present`) | Skip the interactive license confirmation prompt (for CI/CD pipelines). | + +Definition and assignment creation: + +1. Download the required files. + +```powershell +# Optional: create and enter a local working directory +mkdir sa-sql-paas-policy +cd sa-sql-paas-policy +``` + +```powershell +$baseUrl = "https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/azure-sql-db/sql-paas-license-type-compliance" + +New-Item -ItemType Directory -Path policy, scripts -Force | Out-Null + +curl -sLo policy/azurepolicy.json "$baseUrl/policy/azurepolicy.json" +curl -sLo scripts/deployment.ps1 "$baseUrl/scripts/deployment.ps1" +curl -sLo scripts/start-remediation.ps1 "$baseUrl/scripts/start-remediation.ps1" +``` + +> **Note:** On Windows PowerShell 5.1, `curl` is an alias for `Invoke-WebRequest`. Use `curl.exe` instead, or run the commands in PowerShell 7+. + +2. Login to Azure. + +```powershell +Connect-AzAccount +``` + +3. Set your variables. Only `TargetLicenseType` is required — all others are optional. + +```powershell +# ── Required ── +$TargetLicenseType = "LicenseIncluded" # "LicenseIncluded" or "BasePrice" + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: policy assigned at management group scope +``` + +4. Run the deployment. + +```powershell +# Minimal — uses defaults for management group +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType + +# With subscription scope +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId + +# With all options +.\scripts\deployment.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType +``` + +This will: +* Create/update the policy definition at the management group scope. +* Create/assign the policy (at subscription scope when `-SubscriptionId` is provided, otherwise at management group scope). +* Enforce the selected `TargetLicenseType` on all vCore-based SQL databases (excludes Basic/DTU-based databases and the `master` database). + +**Scenario examples:** + +```powershell +# Move all SQL databases to Pay-as-you-go +.\scripts\deployment.ps1 -TargetLicenseType "LicenseIncluded" + +# Move all SQL databases to Azure Hybrid Benefit +.\scripts\deployment.ps1 -TargetLicenseType "BasePrice" +``` + +> **Note:** `deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope. + +## Start Remediation + +Parameter reference: + +| Parameter | Required | Default | Allowed values | Description | +|---|---|---|---|---| +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Used to resolve the policy definition/assignment naming context. Defaults to the tenant root management group when not specified. | +| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, remediation runs at subscription scope. | +| `TargetLicenseType` | Yes | N/A | `LicenseIncluded`, `BasePrice` | Must match the assignment target license type. | +| `GrantMissingPermissions` | No | `false` | Switch (`present`/`not present`) | If set, checks and assigns missing required roles before remediation. | + +1. Set your variables. `TargetLicenseType` is required and must match the value used during deployment — all others are optional. + +```powershell +# ── Required ── +$TargetLicenseType = "LicenseIncluded" # Must match the deployment target + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: remediation runs at management group scope +``` + +2. Run the remediation. + +```powershell +# Minimal — uses defaults for management group +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -GrantMissingPermissions + +# With subscription scope +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId -GrantMissingPermissions + +# With all options +.\scripts\start-remediation.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType ` + -GrantMissingPermissions +``` + +> **Note:** Use `-GrantMissingPermissions` to automatically check and assign any missing required roles before remediation starts. + +## Managed Identity And Roles + +The policy assignment is created with `-IdentityType SystemAssigned`. Azure creates a managed identity on the assignment and uses it to apply DeployIfNotExists changes during enforcement and remediation. + +Required roles: + +- `SQL DB Contributor` (`9b7fa17d-e63e-47b0-bb0a-15c516ac86ec`) +- `Reader` (`acdd72a7-3385-48ef-bd42-f606fba81ae7`) +- `Resource Policy Contributor` (required so DeployIfNotExists can create template deployments) + +## Troubleshooting + +If you see `PolicyAuthorizationFailed`, the policy assignment identity is missing one or more required roles at assignment scope. + +Use one of these options: + +- Re-run `scripts/deployment.ps1` (default behavior assigns required roles automatically). +- Run `scripts/start-remediation.ps1 -GrantMissingPermissions` (checks and assigns missing required roles before remediation). + +## Scope + +The policy targets vCore-based Azure SQL Databases only. It excludes: +- The `master` system database. +- Basic/DTU-based databases (which don't support the `licenseType` property). diff --git a/samples/manage/azure-sql-db/sql-paas-license-type-compliance/policy/azurepolicy.json b/samples/manage/azure-sql-db/sql-paas-license-type-compliance/policy/azurepolicy.json new file mode 100644 index 0000000000..47787e390c --- /dev/null +++ b/samples/manage/azure-sql-db/sql-paas-license-type-compliance/policy/azurepolicy.json @@ -0,0 +1,116 @@ +{ + "parameters": { + "effect": { + "type": "String", + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy." + }, + "allowedValues": [ + "DeployIfNotExists", + "Disabled" + ], + "defaultValue": "DeployIfNotExists" + }, + "targetLicenseType": { + "type": "String", + "metadata": { + "displayName": "Target license type", + "description": "License type to enforce on the Azure SQL Database. LicenseIncluded = Pay-as-you-go, BasePrice = Azure Hybrid Benefit." + }, + "allowedValues": [ + "LicenseIncluded", + "BasePrice" + ], + "defaultValue": "LicenseIncluded" + } + }, + "policyRule": { + "if": { + "allOf": [ + { + "field": "type", + "equals": "Microsoft.Sql/servers/databases" + }, + { + "field": "Microsoft.Sql/servers/databases/sku.tier", + "notEquals": "Basic" + }, + { + "field": "name", + "notEquals": "master" + } + ] + }, + "then": { + "effect": "[parameters('effect')]", + "details": { + "type": "Microsoft.Sql/servers/databases", + "roleDefinitionIds": [ + "/providers/Microsoft.Authorization/roleDefinitions/9b7fa17d-e63e-47b0-bb0a-15c516ac86ec", + "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" + ], + "name": "[field('fullName')]", + "existenceCondition": { + "field": "Microsoft.Sql/servers/databases/licenseType", + "equals": "[parameters('targetLicenseType')]" + }, + "evaluationDelay": "AfterProvisioningSuccess", + "deployment": { + "properties": { + "mode": "incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "databaseFullName": { + "type": "string", + "metadata": { + "description": "The full name of the database (server/database)." + } + }, + "location": { + "type": "string", + "metadata": { + "description": "The location of the database." + } + }, + "targetLicenseType": { + "type": "string", + "metadata": { + "description": "The license type to enforce." + } + } + }, + "functions": [], + "variables": {}, + "resources": [ + { + "type": "Microsoft.Sql/servers/databases", + "apiVersion": "2023-08-01-preview", + "name": "[parameters('databaseFullName')]", + "location": "[parameters('location')]", + "properties": { + "licenseType": "[parameters('targetLicenseType')]" + } + } + ], + "outputs": {} + }, + "parameters": { + "databaseFullName": { + "value": "[field('fullName')]" + }, + "location": { + "value": "[field('location')]" + }, + "targetLicenseType": { + "value": "[parameters('targetLicenseType')]" + } + } + } + } + } + } + } +} diff --git a/samples/manage/azure-sql-db/sql-paas-license-type-compliance/scripts/deployment.ps1 b/samples/manage/azure-sql-db/sql-paas-license-type-compliance/scripts/deployment.ps1 new file mode 100644 index 0000000000..6275f72780 --- /dev/null +++ b/samples/manage/azure-sql-db/sql-paas-license-type-compliance/scripts/deployment.ps1 @@ -0,0 +1,139 @@ +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ManagementGroupId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionId, + + [Parameter(Mandatory = $true)] + [ValidateSet('LicenseIncluded', 'BasePrice')] + [string]$TargetLicenseType, + + [Parameter(Mandatory = $false)] + [switch]$SkipManagedIdentityRoleAssignment, + + [Parameter(Mandatory = $false)] + [switch]$SkipLicenseConfirmation +) + +$LicenseConfirmations = @{ + 'BasePrice' = "I confirm that I have a SQL Server License with Software Assurance to apply this Azure Hybrid Benefit for SQL Server." +} + +if (-not $SkipLicenseConfirmation -and $LicenseConfirmations.ContainsKey($TargetLicenseType)) { + $confirmationMessage = $LicenseConfirmations[$TargetLicenseType] + Write-Host "`n$confirmationMessage" -ForegroundColor Yellow + $response = Read-Host "Do you agree? (Y/N)" + if ($response -notin @('Y', 'y', 'Yes', 'yes')) { + Write-Output "Deployment cancelled. License confirmation was not accepted." + return + } +} + +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + +$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" + +if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $AssignmentScope = "/subscriptions/$SubscriptionId" +} + +$PolicyJsonPath = Join-Path $PSScriptRoot '..\policy\azurepolicy.json' + +$LicenseToken = switch ($TargetLicenseType) { + 'LicenseIncluded' { 'payg' } + 'BasePrice' { 'ahb' } +} + +$LicenseTypeLabel = switch ($TargetLicenseType) { + 'LicenseIncluded' { 'Pay-as-you-go' } + 'BasePrice' { 'Azure Hybrid Benefit' } +} + +$PolicyDefinitionName = "activate-sql-paas-$LicenseToken" +$PolicyAssignmentName = "sql-paas-$LicenseToken" +$PolicyDefinitionDisplayName = "Configure Azure SQL Database license type to '$LicenseTypeLabel'" +$PolicyAssignmentDisplayName = "Configure Azure SQL Database license type to '$LicenseTypeLabel'" + +#Create policy definition +New-AzPolicyDefinition ` + -Name $PolicyDefinitionName ` + -DisplayName $PolicyDefinitionDisplayName ` + -Policy $PolicyJsonPath ` + -ManagementGroupName $ManagementGroupId ` + -Mode Indexed ` + -ErrorAction Stop + +#Assign policy definition +$Policy = Get-AzPolicyDefinition -Name $PolicyDefinitionName -ManagementGroupName $ManagementGroupId +$PolicyAssignment = New-AzPolicyAssignment ` + -Name $PolicyAssignmentName ` + -DisplayName $PolicyAssignmentDisplayName ` + -PolicyDefinition $Policy ` + -PolicyParameterObject @{ + targetLicenseType = $TargetLicenseType + } ` + -Scope $AssignmentScope ` + -Location 'westeurope' ` + -IdentityType 'SystemAssigned' ` + -NonComplianceMessage @( + @{ + Message = "The database license type is not set to '$LicenseTypeLabel'. If the database uses the Serverless compute tier, license type configuration is not supported — switch to the Provisioned compute tier to configure the license type." + } + ) ` + -ErrorAction Stop + +if (-not $SkipManagedIdentityRoleAssignment) { + $requiredRoleNames = @( + 'SQL DB Contributor' + 'Reader' + 'Resource Policy Contributor' + ) + $principalId = $PolicyAssignment.IdentityPrincipalId + + if ([string]::IsNullOrEmpty($principalId)) { + throw "Policy assignment identity principal ID is empty. Cannot assign required roles." + } + + foreach ($requiredRoleName in $requiredRoleNames) { + $existingRole = Get-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction SilentlyContinue + + if (-not $existingRole) { + $maxRetries = 5 + $retryDelay = 10 + for ($i = 1; $i -le $maxRetries; $i++) { + try { + New-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction Stop | Out-Null + + Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope." + break + } + catch { + if ($_.Exception.Message -match 'Conflict') { + Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope (confirmed after retry)." + break + } + if ($i -eq $maxRetries) { throw } + Write-Output "Waiting ${retryDelay}s for identity replication before assigning '$requiredRoleName' ($i/$maxRetries)..." + Start-Sleep -Seconds $retryDelay + } + } + } + else { + Write-Output "Policy assignment identity already has '$requiredRoleName' at scope $AssignmentScope." + } + } +} diff --git a/samples/manage/azure-sql-db/sql-paas-license-type-compliance/scripts/start-remediation.ps1 b/samples/manage/azure-sql-db/sql-paas-license-type-compliance/scripts/start-remediation.ps1 new file mode 100644 index 0000000000..7e3704005d --- /dev/null +++ b/samples/manage/azure-sql-db/sql-paas-license-type-compliance/scripts/start-remediation.ps1 @@ -0,0 +1,123 @@ +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ManagementGroupId, + + [Parameter(Mandatory = $true)] + [ValidateSet('LicenseIncluded', 'BasePrice')] + [string]$TargetLicenseType, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$PolicyAssignmentName, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$RemediationName, + + [Parameter(Mandatory = $false)] + [ValidateSet('ExistingNonCompliant', 'ReEvaluateCompliance')] + [string]$ResourceDiscoveryMode, + + [Parameter(Mandatory = $false)] + [switch]$GrantMissingPermissions +) + +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + +$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" + +if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $AssignmentScope = "/subscriptions/$SubscriptionId" +} + +$LicenseToken = switch ($TargetLicenseType) { + 'LicenseIncluded' { 'payg' } + 'BasePrice' { 'ahb' } +} + +if (-not $PSBoundParameters.ContainsKey('PolicyAssignmentName')) { + $PolicyAssignmentName = "sql-paas-$LicenseToken" +} + +if (-not $PSBoundParameters.ContainsKey('RemediationName')) { + $RemediationName = "remediate-sql-paas-$LicenseToken" +} + +if (-not $PSBoundParameters.ContainsKey('ResourceDiscoveryMode')) { + if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $ResourceDiscoveryMode = 'ReEvaluateCompliance' + } + else { + $ResourceDiscoveryMode = 'ExistingNonCompliant' + } +} + +# Validate assignment exists before creating remediation. +$PolicyAssignmentObj = Get-AzPolicyAssignment -Scope $AssignmentScope -Name $PolicyAssignmentName -ErrorAction Stop + +$requiredRoleNames = @( + 'SQL DB Contributor' + 'Reader' + 'Resource Policy Contributor' +) +$principalId = $PolicyAssignmentObj.IdentityPrincipalId + +if ([string]::IsNullOrEmpty($principalId)) { + throw "Policy assignment identity principal ID is empty. Cannot verify required roles." +} + +$missingRoles = @() + +foreach ($requiredRoleName in $requiredRoleNames) { + $requiredRole = Get-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction SilentlyContinue + + if (-not $requiredRole) { + $missingRoles += $requiredRoleName + } +} + +if ($missingRoles.Count -gt 0) { + if ($GrantMissingPermissions) { + foreach ($missingRole in $missingRoles) { + New-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $missingRole ` + -Scope $AssignmentScope ` + -ErrorAction Stop | Out-Null + + Write-Output "Assigned '$missingRole' to policy assignment identity ($principalId) at scope $AssignmentScope." + } + } + else { + throw "Missing required roles [$($missingRoles -join ', ')] for policy assignment identity ($principalId) at scope $AssignmentScope. Re-run with -GrantMissingPermissions or assign the roles manually." + } +} + +$CommonParams = @{ + Name = $RemediationName + PolicyAssignmentId = $PolicyAssignmentObj.Id + Scope = $AssignmentScope + ResourceDiscoveryMode = $ResourceDiscoveryMode +} + +if (Get-Command -Name Start-AzPolicyRemediation -ErrorAction SilentlyContinue) { + Start-AzPolicyRemediation @CommonParams +} +elseif (Get-Command -Name New-AzPolicyRemediation -ErrorAction SilentlyContinue) { + New-AzPolicyRemediation @CommonParams +} +else { + throw "Neither Start-AzPolicyRemediation nor New-AzPolicyRemediation is available. Install/update Az.PolicyInsights." +} diff --git a/samples/manage/sql-vm/sql-iaas-license-type-compliance/README.md b/samples/manage/sql-vm/sql-iaas-license-type-compliance/README.md new file mode 100644 index 0000000000..01d4d1eba4 --- /dev/null +++ b/samples/manage/sql-vm/sql-iaas-license-type-compliance/README.md @@ -0,0 +1,181 @@ +# SQL Server on Azure VMs (IaaS) License Type Configuration with Azure Policy + +This solution deploys and remediates a custom Azure Policy that configures and enforces the `sqlServerLicenseType` property on SQL Server on Azure Virtual Machines (`Microsoft.SqlVirtualMachine/sqlVirtualMachines`) to a selected target value. + +## What Is In This Folder + +- `policy/azurepolicy.json`: Custom policy definition (DeployIfNotExists). +- `scripts/deployment.ps1`: Creates/updates the policy definition and policy assignment. +- `scripts/start-remediation.ps1`: Starts a remediation task for the created assignment. + +## License Type Mapping + +| Parameter value | Portal label | `sqlServerLicenseType` | +|---|---|---| +| `PAYG` | Pay-as-you-go | `PAYG` | +| `AHUB` | Azure Hybrid Benefit | `AHUB` | +| `DR` | HA/DR | `DR` | + +## Licensing Conditions + +When selecting certain license types, ensure you meet the licensing requirements: + +- **Azure Hybrid Benefit** (`AHUB`): *"I confirm that I have a SQL Server License with Software Assurance to apply this Azure Hybrid Benefit for SQL Server."* +- **HA/DR** (`DR`): *"I confirm that I will use this SQL Server VM as a passive HA/DR replica for which I have a SQL Server license with Software Assurance, or for which I use Pay-as-you-go billing option."* + +The deployment script will prompt for confirmation when targeting `AHUB` or `DR`. Use `-SkipLicenseConfirmation` to suppress the prompt in automated pipelines (the operator assumes responsibility for license compliance). + +## Prerequisites + +- PowerShell with Az modules installed (`Az.Resources`). +- Logged in to Azure (`Connect-AzAccount`). +- Permissions to create policy definitions/assignments and remediation tasks at target scope. + +## Deploy Policy + +Parameter reference: + +| Parameter | Required | Default | Allowed values | Description | +|---|---|---|---|---| +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Scope where the policy definition is created. Defaults to the tenant root management group when not specified. | +| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, policy assignment scope is the subscription. | +| `TargetLicenseType` | Yes | N/A | `PAYG`, `AHUB`, `DR` | Target license type to enforce. | +| `LicenseTypesToOverwrite` | No | All | `PAYG`, `AHUB`, `DR` | Select which current license states are eligible for update. | +| `SkipLicenseConfirmation` | No | `false` | Switch (`present`/`not present`) | Skip the interactive license confirmation prompt (for CI/CD pipelines). | + +Definition and assignment creation: + +1. Download the required files. + +```powershell +# Optional: create and enter a local working directory +mkdir sa-sql-iaas-policy +cd sa-sql-iaas-policy +``` + +```powershell +$baseUrl = "https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/sql-vm/sql-iaas-license-type-compliance" + +New-Item -ItemType Directory -Path policy, scripts -Force | Out-Null + +curl -sLo policy/azurepolicy.json "$baseUrl/policy/azurepolicy.json" +curl -sLo scripts/deployment.ps1 "$baseUrl/scripts/deployment.ps1" +curl -sLo scripts/start-remediation.ps1 "$baseUrl/scripts/start-remediation.ps1" +``` + +> **Note:** On Windows PowerShell 5.1, `curl` is an alias for `Invoke-WebRequest`. Use `curl.exe` instead, or run the commands in PowerShell 7+. + +2. Login to Azure. + +```powershell +Connect-AzAccount +``` + +3. Set your variables. Only `TargetLicenseType` is required — all others are optional. + +```powershell +# ── Required ── +$TargetLicenseType = "PAYG" # "PAYG", "AHUB", or "DR" + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: policy assigned at management group scope +# $LicenseTypesToOverwrite = @("PAYG","AHUB","DR") # Default: all +``` + +4. Run the deployment. + +```powershell +# Minimal — uses defaults for management group and overwrite targets +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType + +# With subscription scope +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId + +# With all options +.\scripts\deployment.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType ` + -LicenseTypesToOverwrite $LicenseTypesToOverwrite +``` + +This will: +* Create/update the policy definition at the management group scope. +* Create/assign the policy (at subscription scope when `-SubscriptionId` is provided, otherwise at management group scope). +* Enforce the selected `TargetLicenseType` on resources matching the `LicenseTypesToOverwrite` filter. + +**Scenario examples:** + +```powershell +# Move all SQL VMs to Pay-as-you-go +.\scripts\deployment.ps1 -TargetLicenseType "PAYG" + +# Move Pay-as-you-go SQL VMs to Azure Hybrid Benefit +.\scripts\deployment.ps1 -TargetLicenseType "AHUB" -LicenseTypesToOverwrite @("PAYG") + +# Configure HA/DR, only for SQL VMs currently on Azure Hybrid Benefit +.\scripts\deployment.ps1 -TargetLicenseType "DR" -LicenseTypesToOverwrite @("AHUB") +``` + +> **Note:** `deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope. + +## Start Remediation + +Parameter reference: + +| Parameter | Required | Default | Allowed values | Description | +|---|---|---|---|---| +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Used to resolve the policy definition/assignment naming context. Defaults to the tenant root management group when not specified. | +| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, remediation runs at subscription scope. | +| `TargetLicenseType` | Yes | N/A | `PAYG`, `AHUB`, `DR` | Must match the assignment target license type. | +| `GrantMissingPermissions` | No | `false` | Switch (`present`/`not present`) | If set, checks and assigns missing required roles before remediation. | + +1. Set your variables. `TargetLicenseType` is required and must match the value used during deployment — all others are optional. + +```powershell +# ── Required ── +$TargetLicenseType = "PAYG" # Must match the deployment target + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: remediation runs at management group scope +``` + +2. Run the remediation. + +```powershell +# Minimal — uses defaults for management group +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -GrantMissingPermissions + +# With subscription scope +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId -GrantMissingPermissions + +# With all options +.\scripts\start-remediation.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType ` + -GrantMissingPermissions +``` + +> **Note:** Use `-GrantMissingPermissions` to automatically check and assign any missing required roles before remediation starts. + +## Managed Identity And Roles + +The policy assignment is created with `-IdentityType SystemAssigned`. Azure creates a managed identity on the assignment and uses it to apply DeployIfNotExists changes during enforcement and remediation. + +Required roles: + +- `Virtual Machine Contributor` (`9980e02c-c2be-4d73-94e8-173b1dc7cf3c`) +- `Reader` (`acdd72a7-3385-48ef-bd42-f606fba81ae7`) +- `Resource Policy Contributor` (required so DeployIfNotExists can create template deployments) + +## Troubleshooting + +If you see `PolicyAuthorizationFailed`, the policy assignment identity is missing one or more required roles at assignment scope. + +Use one of these options: + +- Re-run `scripts/deployment.ps1` (default behavior assigns required roles automatically). +- Run `scripts/start-remediation.ps1 -GrantMissingPermissions` (checks and assigns missing required roles before remediation). diff --git a/samples/manage/sql-vm/sql-iaas-license-type-compliance/policy/azurepolicy.json b/samples/manage/sql-vm/sql-iaas-license-type-compliance/policy/azurepolicy.json new file mode 100644 index 0000000000..db0f8fc6e3 --- /dev/null +++ b/samples/manage/sql-vm/sql-iaas-license-type-compliance/policy/azurepolicy.json @@ -0,0 +1,176 @@ +{ + "parameters": { + "effect": { + "type": "String", + "metadata": { + "displayName": "Effect", + "description": "Enable or disable the execution of the policy." + }, + "allowedValues": [ + "DeployIfNotExists", + "Disabled" + ], + "defaultValue": "DeployIfNotExists" + }, + "targetLicenseType": { + "type": "String", + "metadata": { + "displayName": "Target license type", + "description": "License type to enforce on the SQL Virtual Machine. PAYG = Pay-as-you-go, AHUB = Azure Hybrid Benefit, DR = HA/DR." + }, + "allowedValues": [ + "PAYG", + "AHUB", + "DR" + ], + "defaultValue": "PAYG" + }, + "licenseTypesToOverwrite": { + "type": "Array", + "metadata": { + "displayName": "Current license types to overwrite", + "description": "Select which current license type states are eligible for update." + }, + "allowedValues": [ + "PAYG", + "AHUB", + "DR" + ], + "defaultValue": [ + "PAYG", + "AHUB", + "DR" + ] + } + }, + "policyRule": { + "if": { + "field": "type", + "equals": "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + }, + "then": { + "effect": "[parameters('effect')]", + "details": { + "type": "Microsoft.SqlVirtualMachine/sqlVirtualMachines", + "roleDefinitionIds": [ + "/providers/Microsoft.Authorization/roleDefinitions/9980e02c-c2be-4d73-94e8-173b1dc7cf3c", + "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7" + ], + "name": "[field('name')]", + "existenceCondition": { + "anyOf": [ + { + "allOf": [ + { + "field": "Microsoft.SqlVirtualMachine/sqlVirtualMachines/sqlServerLicenseType", + "equals": "[parameters('targetLicenseType')]" + } + ] + }, + { + "allOf": [ + { + "field": "Microsoft.SqlVirtualMachine/sqlVirtualMachines/sqlServerLicenseType", + "equals": "PAYG" + }, + { + "value": "[contains(parameters('licenseTypesToOverwrite'), 'PAYG')]", + "equals": false + } + ] + }, + { + "allOf": [ + { + "field": "Microsoft.SqlVirtualMachine/sqlVirtualMachines/sqlServerLicenseType", + "equals": "AHUB" + }, + { + "value": "[contains(parameters('licenseTypesToOverwrite'), 'AHUB')]", + "equals": false + } + ] + }, + { + "allOf": [ + { + "field": "Microsoft.SqlVirtualMachine/sqlVirtualMachines/sqlServerLicenseType", + "equals": "DR" + }, + { + "value": "[contains(parameters('licenseTypesToOverwrite'), 'DR')]", + "equals": false + } + ] + } + ] + }, + "evaluationDelay": "AfterProvisioningSuccess", + "deployment": { + "properties": { + "mode": "incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "sqlVmName": { + "type": "string", + "metadata": { + "description": "The name of the SQL Virtual Machine." + } + }, + "location": { + "type": "string", + "metadata": { + "description": "The location of the SQL Virtual Machine." + } + }, + "targetLicenseType": { + "type": "string", + "metadata": { + "description": "The license type to enforce." + } + }, + "virtualMachineResourceId": { + "type": "string", + "metadata": { + "description": "The resource ID of the underlying Compute VM." + } + } + }, + "functions": [], + "variables": {}, + "resources": [ + { + "type": "Microsoft.SqlVirtualMachine/sqlVirtualMachines", + "apiVersion": "2023-10-01", + "name": "[parameters('sqlVmName')]", + "location": "[parameters('location')]", + "properties": { + "virtualMachineResourceId": "[parameters('virtualMachineResourceId')]", + "sqlServerLicenseType": "[parameters('targetLicenseType')]" + } + } + ], + "outputs": {} + }, + "parameters": { + "sqlVmName": { + "value": "[field('name')]" + }, + "location": { + "value": "[field('location')]" + }, + "targetLicenseType": { + "value": "[parameters('targetLicenseType')]" + }, + "virtualMachineResourceId": { + "value": "[field('Microsoft.SqlVirtualMachine/sqlVirtualMachines/virtualMachineResourceId')]" + } + } + } + } + } + } + } +} diff --git a/samples/manage/sql-vm/sql-iaas-license-type-compliance/scripts/deployment.ps1 b/samples/manage/sql-vm/sql-iaas-license-type-compliance/scripts/deployment.ps1 new file mode 100644 index 0000000000..a08596a9e9 --- /dev/null +++ b/samples/manage/sql-vm/sql-iaas-license-type-compliance/scripts/deployment.ps1 @@ -0,0 +1,142 @@ +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ManagementGroupId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionId, + + [Parameter(Mandatory = $true)] + [ValidateSet('PAYG', 'AHUB', 'DR')] + [string]$TargetLicenseType, + + [Parameter(Mandatory = $false)] + [ValidateSet('PAYG', 'AHUB', 'DR')] + [string[]]$LicenseTypesToOverwrite = @('PAYG', 'AHUB', 'DR'), + + [Parameter(Mandatory = $false)] + [switch]$SkipManagedIdentityRoleAssignment, + + [Parameter(Mandatory = $false)] + [switch]$SkipLicenseConfirmation +) + +$LicenseConfirmations = @{ + 'AHUB' = "I confirm that I have a SQL Server License with Software Assurance to apply this Azure Hybrid Benefit for SQL Server." + 'DR' = "I confirm that I will use this SQL Server VM as a passive HA/DR replica for which I have a SQL Server license with Software Assurance, or for which I use Pay-as-you-go billing option." +} + +if (-not $SkipLicenseConfirmation -and $LicenseConfirmations.ContainsKey($TargetLicenseType)) { + $confirmationMessage = $LicenseConfirmations[$TargetLicenseType] + Write-Host "`n$confirmationMessage" -ForegroundColor Yellow + $response = Read-Host "Do you agree? (Y/N)" + if ($response -notin @('Y', 'y', 'Yes', 'yes')) { + Write-Output "Deployment cancelled. License confirmation was not accepted." + return + } +} + +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + +$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" + +if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $AssignmentScope = "/subscriptions/$SubscriptionId" +} + +$PolicyJsonPath = Join-Path $PSScriptRoot '..\policy\azurepolicy.json' + +$LicenseToken = switch ($TargetLicenseType) { + 'PAYG' { 'payg' } + 'AHUB' { 'ahub' } + 'DR' { 'dr' } +} + +$LicenseTypeLabel = switch ($TargetLicenseType) { + 'PAYG' { 'Pay-as-you-go' } + 'AHUB' { 'Azure Hybrid Benefit' } + 'DR' { 'HA/DR' } +} + +$PolicyDefinitionName = "activate-sql-iaas-$LicenseToken" +$PolicyAssignmentName = "sql-iaas-$LicenseToken" +$PolicyDefinitionDisplayName = "Configure SQL Server on Azure VM license type to '$LicenseTypeLabel'" +$PolicyAssignmentDisplayName = "Configure SQL Server on Azure VM license type to '$LicenseTypeLabel'" + +#Create policy definition +New-AzPolicyDefinition ` + -Name $PolicyDefinitionName ` + -DisplayName $PolicyDefinitionDisplayName ` + -Policy $PolicyJsonPath ` + -ManagementGroupName $ManagementGroupId ` + -Mode Indexed ` + -ErrorAction Stop + +#Assign policy definition +$Policy = Get-AzPolicyDefinition -Name $PolicyDefinitionName -ManagementGroupName $ManagementGroupId +$PolicyAssignment = New-AzPolicyAssignment ` + -Name $PolicyAssignmentName ` + -DisplayName $PolicyAssignmentDisplayName ` + -PolicyDefinition $Policy ` + -PolicyParameterObject @{ + targetLicenseType = $TargetLicenseType + licenseTypesToOverwrite = $LicenseTypesToOverwrite + } ` + -Scope $AssignmentScope ` + -Location 'westeurope' ` + -IdentityType 'SystemAssigned' ` + -ErrorAction Stop + +if (-not $SkipManagedIdentityRoleAssignment) { + $requiredRoleNames = @( + 'Virtual Machine Contributor' + 'Reader' + 'Resource Policy Contributor' + ) + $principalId = $PolicyAssignment.IdentityPrincipalId + + if ([string]::IsNullOrEmpty($principalId)) { + throw "Policy assignment identity principal ID is empty. Cannot assign required roles." + } + + foreach ($requiredRoleName in $requiredRoleNames) { + $existingRole = Get-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction SilentlyContinue + + if (-not $existingRole) { + $maxRetries = 5 + $retryDelay = 10 + for ($i = 1; $i -le $maxRetries; $i++) { + try { + New-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction Stop | Out-Null + + Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope." + break + } + catch { + if ($_.Exception.Message -match 'Conflict') { + Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope (confirmed after retry)." + break + } + if ($i -eq $maxRetries) { throw } + Write-Output "Waiting ${retryDelay}s for identity replication before assigning '$requiredRoleName' ($i/$maxRetries)..." + Start-Sleep -Seconds $retryDelay + } + } + } + else { + Write-Output "Policy assignment identity already has '$requiredRoleName' at scope $AssignmentScope." + } + } +} diff --git a/samples/manage/sql-vm/sql-iaas-license-type-compliance/scripts/start-remediation.ps1 b/samples/manage/sql-vm/sql-iaas-license-type-compliance/scripts/start-remediation.ps1 new file mode 100644 index 0000000000..874196b8cc --- /dev/null +++ b/samples/manage/sql-vm/sql-iaas-license-type-compliance/scripts/start-remediation.ps1 @@ -0,0 +1,124 @@ +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$ManagementGroupId, + + [Parameter(Mandatory = $true)] + [ValidateSet('PAYG', 'AHUB', 'DR')] + [string]$TargetLicenseType, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$SubscriptionId, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$PolicyAssignmentName, + + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$RemediationName, + + [Parameter(Mandatory = $false)] + [ValidateSet('ExistingNonCompliant', 'ReEvaluateCompliance')] + [string]$ResourceDiscoveryMode, + + [Parameter(Mandatory = $false)] + [switch]$GrantMissingPermissions +) + +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + +$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" + +if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $AssignmentScope = "/subscriptions/$SubscriptionId" +} + +$LicenseToken = switch ($TargetLicenseType) { + 'PAYG' { 'payg' } + 'AHUB' { 'ahub' } + 'DR' { 'dr' } +} + +if (-not $PSBoundParameters.ContainsKey('PolicyAssignmentName')) { + $PolicyAssignmentName = "sql-iaas-$LicenseToken" +} + +if (-not $PSBoundParameters.ContainsKey('RemediationName')) { + $RemediationName = "remediate-sql-iaas-$LicenseToken" +} + +if (-not $PSBoundParameters.ContainsKey('ResourceDiscoveryMode')) { + if ($PSBoundParameters.ContainsKey('SubscriptionId')) { + $ResourceDiscoveryMode = 'ReEvaluateCompliance' + } + else { + $ResourceDiscoveryMode = 'ExistingNonCompliant' + } +} + +# Validate assignment exists before creating remediation. +$PolicyAssignmentObj = Get-AzPolicyAssignment -Scope $AssignmentScope -Name $PolicyAssignmentName -ErrorAction Stop + +$requiredRoleNames = @( + 'Virtual Machine Contributor' + 'Reader' + 'Resource Policy Contributor' +) +$principalId = $PolicyAssignmentObj.IdentityPrincipalId + +if ([string]::IsNullOrEmpty($principalId)) { + throw "Policy assignment identity principal ID is empty. Cannot verify required roles." +} + +$missingRoles = @() + +foreach ($requiredRoleName in $requiredRoleNames) { + $requiredRole = Get-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction SilentlyContinue + + if (-not $requiredRole) { + $missingRoles += $requiredRoleName + } +} + +if ($missingRoles.Count -gt 0) { + if ($GrantMissingPermissions) { + foreach ($missingRole in $missingRoles) { + New-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $missingRole ` + -Scope $AssignmentScope ` + -ErrorAction Stop | Out-Null + + Write-Output "Assigned '$missingRole' to policy assignment identity ($principalId) at scope $AssignmentScope." + } + } + else { + throw "Missing required roles [$($missingRoles -join ', ')] for policy assignment identity ($principalId) at scope $AssignmentScope. Re-run with -GrantMissingPermissions or assign the roles manually." + } +} + +$CommonParams = @{ + Name = $RemediationName + PolicyAssignmentId = $PolicyAssignmentObj.Id + Scope = $AssignmentScope + ResourceDiscoveryMode = $ResourceDiscoveryMode +} + +if (Get-Command -Name Start-AzPolicyRemediation -ErrorAction SilentlyContinue) { + Start-AzPolicyRemediation @CommonParams +} +elseif (Get-Command -Name New-AzPolicyRemediation -ErrorAction SilentlyContinue) { + New-AzPolicyRemediation @CommonParams +} +else { + throw "Neither Start-AzPolicyRemediation nor New-AzPolicyRemediation is available. Install/update Az.PolicyInsights." +}