Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This repo deploys and remediates a custom Azure Policy that configures and enforces Arc-enabled SQL Server extension `LicenseType` to a selected target value (for example `Paid` or `PAYG`).

## What Is In This Repo
## What Is In This Folder

- `policy/azurepolicy.json`: Custom policy definition (DeployIfNotExists).
- `scripts/deployment.ps1`: Creates/updates the policy definition and policy assignment.
Expand All @@ -21,77 +21,144 @@ Parameter reference:

| Parameter | Required | Default | Allowed values | Description |
|---|---|---|---|---|
| `ManagementGroupId` | Yes | N/A | Any valid management group ID | Scope where the policy definition is created. |
| `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. |
| `ExtensionType` | No | `Both` | `Windows`, `Linux`, `Both` | Targets the Arc SQL extension platform. When `Both` (default), a single policy definition and assignment covers both platforms. When a specific type is selected, the naming and scope are tailored to that platform. |
| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, policy assignment scope is the subscription. |
| `TargetLicenseType` | Yes | N/A | `Paid`, `PAYG` | Target `LicenseType` value to enforce. |
| `LicenseTypesToOverwrite` | No | All | `Unspecified`, `Paid`, `PAYG`, `LicenseOnly` | Select which current license states are eligible for update. Use `Unspecified` to include resources with no `LicenseType` configured. |

Definition and assignment creation:

1. Clone the repo.
1. Download the required files.

```powershell
git clone https://github.com/microsoft/sql-server-samples.git
cd sql-server-samples/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance
# Optional: create and enter a local working directory
mkdir sql-arc-lt-compliance
cd sql-arc-lt-compliance
```

```powershell
$baseUrl = "https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-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
# Example: target both platforms (default)
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
# ── Required ──
$TargetLicenseType = "PAYG" # "Paid" or "PAYG"

# ── Optional (uncomment to override defaults) ──
# $ManagementGroupId = "<management-group-id>" # Default: tenant root management group
# $SubscriptionId = "<subscription-id>" # Default: policy assigned at management group scope
# $ExtensionType = "Both" # "Windows", "Linux", or "Both" (default)
# $LicenseTypesToOverwrite = @("Unspecified","Paid","PAYG","LicenseOnly") # Default: all
```

4. Run the deployment.

# Example: target only Linux
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -ExtensionType "Linux" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
```powershell
# Minimal — uses defaults for management group, platform, 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 `
-ExtensionType $ExtensionType `
-TargetLicenseType $TargetLicenseType `
-LicenseTypesToOverwrite $LicenseTypesToOverwrite
```
The first example (without `-ExtensionType`) will:
* Create/update a single policy definition and assignment covering **both** Windows and Linux.
* Assign that policy at the specified subscription scope.
* Enforce LicenseType = PAYG.
* Update only resources where current `LicenseType` is `Paid`.

The second example creates a Linux-specific definition and assignment, with platform-tailored naming.
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).
* Target the selected `ExtensionType` platform(s) — `Both` by default covers Windows and Linux.
* Enforce the selected `TargetLicenseType` on resources matching the `LicenseTypesToOverwrite` filter.

Scenario examples:
**Scenario examples:**

```powershell
# Target Paid, both Linux and Windows, but only for resources with missing LicenseType or LicenseOnly (do not target PAYG)
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Unspecified","LicenseOnly")
# Move all Paid licenses to PAYG, both platforms
.\scripts\deployment.ps1 -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")

# Target PAYG, but only where current LicenseType is Paid (do not target missing or LicenseOnly)
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -ExtensionType "Linux" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
# Set missing and LicenseOnly to Paid, skip resources already on PAYG
.\scripts\deployment.ps1 -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Unspecified","LicenseOnly")

# Overwrite all known existing LicenseType values (Paid, PAYG, LicenseOnly), but not missing
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -ExtensionType "Linux" -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Paid","PAYG","LicenseOnly")
# Linux only — move Paid to PAYG at a specific subscription
.\scripts\deployment.ps1 -ExtensionType "Linux" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
```

Note: `scripts/deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope, preventing common `PolicyAuthorizationFailed` errors during DeployIfNotExists deployments.
> **Note:** `deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope, preventing common `PolicyAuthorizationFailed` errors during DeployIfNotExists deployments.

## Start Remediation

Parameter reference:

| Parameter | Required | Default | Allowed values | Description |
|---|---|---|---|---|
| `ManagementGroupId` | Yes | N/A | Any valid management group ID | Used to resolve the policy definition/assignment naming context. |
| `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. |
| `ExtensionType` | No | `Both` | `Windows`, `Linux`, `Both` | Must match the platform used for the assignment. When `Both` (default), remediates the combined assignment. |
| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, remediation runs at subscription scope. |
| `TargetLicenseType` | Yes | N/A | `Paid`, `PAYG` | 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
# Example: remediate both platforms (default)
.\scripts\start-remediation.ps1 -ManagementGroupId "<management-group-id>" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -GrantMissingPermissions
# ── Required ──
$TargetLicenseType = "PAYG" # Must match the deployment target

# Example: remediate only Linux
.\scripts\start-remediation.ps1 -ManagementGroupId "<management-group-id>" -ExtensionType "Linux" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -GrantMissingPermissions
# ── Optional (uncomment to override defaults) ──
# $ManagementGroupId = "<management-group-id>" # Default: tenant root management group
# $SubscriptionId = "<subscription-id>" # Default: remediation runs at management group scope
# $ExtensionType = "Both" # Must match the platform used for deployment
```

2. Run the remediation.

```powershell
# Minimal — uses defaults for management group and platform
.\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 `
-ExtensionType $ExtensionType `
-SubscriptionId $SubscriptionId `
-TargetLicenseType $TargetLicenseType `
-GrantMissingPermissions
```

> **Note:** Use `-GrantMissingPermissions` to automatically check and assign any missing required roles before remediation starts.

## Recurring Billing Consent (PAYG)

When `TargetLicenseType` is set to `PAYG`, the policy automatically includes `ConsentToRecurringPAYG` in the extension settings with `Consented: true` and a UTC timestamp. This is required for recurring pay-as-you-go billing as described in the [Microsoft documentation](https://learn.microsoft.com/en-us/sql/sql-server/azure-arc/manage-pay-as-you-go-transition?view=sql-server-ver17#recurring-billing-consent).

The policy also checks for `ConsentToRecurringPAYG` in its compliance evaluation — resources with `LicenseType: PAYG` but missing the consent property are flagged as non-compliant and remediated. This applies both when transitioning to PAYG and for existing PAYG extensions that predate the consent requirement (backward compatibility).

> **Note:** Once `ConsentToRecurringPAYG` is set on an extension, it cannot be removed — this is enforced by the Azure resource provider. When transitioning away from PAYG, the policy changes `LicenseType` but leaves the consent property in place.

## 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.
Expand All @@ -110,11 +177,4 @@ Use one of these options:

- Re-run `scripts/deployment.ps1` (default behavior assigns `Resource Policy Contributor` automatically).
- 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).

## Screenshots

![overview](./docs/screenshots/overview.png)
![pre-policy](./docs/screenshots/pre-policy.png)
![remediation](./docs/screenshots/remediation.png)
![postpolicy](./docs/screenshots/post-policy.png)
- Run `scripts/start-remediation.ps1 -GrantMissingPermissions` (checks and assigns missing required roles before remediation).
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"displayName": "Set Arc-enabled SQL Server license type to 'License With Software Assurance'",
"displayName": "Configure Arc-enabled SQL Server license type",
"policyType": "Custom",
"mode": "Indexed",
"description": "This policy sets the license type for Arc-enabled SQL Server to 'License With Software Assurance'. ",
"description": "This policy configures the license type for Arc-enabled SQL Server extensions to a specified target value.",
"metadata": {
"category": ""
},
Expand Down Expand Up @@ -112,6 +112,26 @@
{
"value": "[length(intersection(field('Microsoft.HybridCompute/machines/extensions/settings'), createObject('LicenseType', parameters('targetLicenseType'))))]",
"equals": 1
},
{
"anyOf": [
{
"value": "[parameters('targetLicenseType')]",
"notEquals": "PAYG"
},
{
"allOf": [
{
"value": "[parameters('targetLicenseType')]",
"equals": "PAYG"
},
{
"field": "Microsoft.HybridCompute/machines/extensions/settings",
"ContainsKey": "ConsentToRecurringPAYG"
}
]
}
]
}
]
},
Expand Down Expand Up @@ -156,6 +176,10 @@
{
"value": "[contains(parameters('licenseTypesToOverwrite'), 'PAYG')]",
"equals": false
},
{
"field": "Microsoft.HybridCompute/machines/extensions/settings",
"ContainsKey": "ConsentToRecurringPAYG"
}
]
},
Expand Down Expand Up @@ -214,14 +238,29 @@
"metadata": {
"description": "LicenseType value to set on the extension."
}
},
"consentTimestamp": {
"type": "string",
"defaultValue": "[utcNow('yyyy-MM-ddTHH:mm:ssZ')]",
"metadata": {
"description": "UTC timestamp for recurring PAYG consent. Auto-generated at deployment time."
}
}
},
"functions": [],
"variables": {
"vmExtensionPublisher": "Microsoft.AzureData",
"licenseSettings": {
"baseSettings": {
"LicenseType": "[parameters('targetLicenseType')]"
}
},
"paygConsentSettings": {
"LicenseType": "[parameters('targetLicenseType')]",
"ConsentToRecurringPAYG": {
"Consented": true,
"ConsentTimestamp": "[parameters('consentTimestamp')]"
}
},
"licenseSettings": "[if(equals(parameters('targetLicenseType'), 'PAYG'), variables('paygConsentSettings'), variables('baseSettings'))]"
},
"resources": [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
param(
[Parameter(Mandatory = $true)]
[Parameter(Mandatory = $false)]
[ValidateNotNullOrEmpty()]
[string]$ManagementGroupId,

Expand All @@ -23,6 +23,11 @@ param(
[switch]$SkipManagedIdentityRoleAssignment
)

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')) {
Expand Down Expand Up @@ -55,14 +60,9 @@ else {
$PolicyDefinitionName = "activate-sql-arc-$LicenseToken-$PlatformToken"
$PolicyAssignmentName = "sql-arc-$LicenseToken-$PlatformToken"

if ($TargetLicenseType -eq 'PAYG') {
$PolicyDefinitionDisplayName = "Arc-enabled SQL Server ($PlatformLabel) license type to 'Pay-as-you-go'"
$PolicyAssignmentDisplayName = "Arc-enabled SQL Server ($PlatformLabel) license type to 'Pay-as-you-go'"
}
else {
$PolicyDefinitionDisplayName = "Set Arc-enabled SQL Server ($PlatformLabel) license type to 'License With Software Assurance'"
$PolicyAssignmentDisplayName = "Set Arc-enabled SQL Server ($PlatformLabel) license type to 'License With Software Assurance'"
}
$LicenseTypeLabel = if ($TargetLicenseType -eq 'PAYG') { 'Pay-as-you-go' } else { 'License With Software Assurance' }
$PolicyDefinitionDisplayName = "Configure Arc-enabled SQL Server ($PlatformLabel) license type to '$LicenseTypeLabel'"
$PolicyAssignmentDisplayName = "Configure Arc-enabled SQL Server ($PlatformLabel) license type to '$LicenseTypeLabel'"

#Create policy definition
New-AzPolicyDefinition `
Expand Down Expand Up @@ -109,13 +109,29 @@ if (-not $SkipManagedIdentityRoleAssignment) {
-ErrorAction SilentlyContinue

if (-not $existingRole) {
New-AzRoleAssignment `
-ObjectId $principalId `
-RoleDefinitionName $requiredRoleName `
-Scope $AssignmentScope `
-ErrorAction Stop | Out-Null

Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope."
$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."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
param(
[Parameter(Mandatory = $true)]
[Parameter(Mandatory = $false)]
[ValidateNotNullOrEmpty()]
[string]$ManagementGroupId,

Expand Down Expand Up @@ -31,6 +31,11 @@ param(
[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')) {
Expand Down