Private/Test-AACStoragePlan.ps1
|
function Test-AACStoragePlan { <# .SYNOPSIS The gates of Deploy-AACStorageAccount: what would block the plan, break your standards, or be changed by Azure Policy - checked before anything is written. .DESCRIPTION Returns @{ Gates; Policies; PSRule }. Gates are AAC.StorageGate rows: Gate, Outcome, Resource, Item, Detail, Reference: Name the account is new and its name is taken (or invalid) - checkNameAvailability Blocks Immutable a property Azure can't change in place differs Blocks Unknown something couldn't be read Blocks Lock -Prune would delete under a lock Blocks Policy checkPolicyRestrictions with the exact body each write would send (a child with its parent as scope): a Deny the resource would fail Blocks an Audit it would fail Audit a field policy will set or remove (Modify, Append) Changes PSRule PSRule for Azure (and the module's naming and tag rules) on the account as it will be - with its services, containers and shares - through the module's PSRule runner: a rule that fails (or can't be evaluated) is Breaks, with its Fix (Get-AACStorageRuleFix); PSRule not running at all Blocks. Both stop the deployment. Network blobs to upload while the account denies public network access by default Warn Policies: the policy assignments that apply to the resource group and target storage (Get-AACAssignedPolicy), for reference. Fixes: the fix for each failing PSRule rule (AAC.StorageRuleFix). -SkipPolicy and -SkipPSRule leave those gates out. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Change, [Parameter(Mandatory)] [string] $SubscriptionId, [Parameter(Mandatory)] [string] $ResourceGroupName, [switch] $SkipPolicy, [switch] $SkipPSRule, [string] $Baseline, [string[]] $ExcludeRule = @(), # The configuration PSRule's fixes are expressed in. [System.Collections.IDictionary] $Configuration = @{} ) $gates = [System.Collections.Generic.List[object]]::new() $gate = { param([string] $Gate, [string] $Outcome, [string] $Resource, [string] $Item, [string] $Detail, [string] $Reference = '', [string] $Severity = '', [string] $Fix = '') $gates.Add([pscustomobject]@{ PSTypeName = 'AAC.StorageGate'; Gate = $Gate; Outcome = $Outcome; Resource = $Resource; Item = $Item; Detail = $Detail; Severity = $Severity; Reference = $Reference; Fix = $Fix }) } $group = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName" $account = @($Change | Where-Object Kind -EQ 'account')[0] $writes = @($Change | Where-Object Action -In 'Create', 'Update', 'Delete') # --- Name, immutables, unreadable, locks ------------------------------------------------------------------------- if ($account.Action -eq 'Create') { $check = Invoke-AACArmRequest -Method Post -Uri "/subscriptions/$SubscriptionId/providers/Microsoft.Storage/checkNameAvailability?api-version=2023-05-01" -Body (ConvertTo-Json -Compress -InputObject @{ name = $account.Name; type = 'Microsoft.Storage/storageAccounts' }) if (-not $check['nameAvailable']) { & $gate 'Name' 'Blocks' $account.Resource $account.Name "The name can't be used: $(if ($check['message']) { $check['message'] } else { $check['reason'] }). Storage account names are unique across Azure." } else { & $gate 'Name' 'Pass' $account.Resource $account.Name 'The name is available.' } } foreach ($item in $Change | Where-Object Action -EQ 'Replace') { foreach ($difference in @($item.Differences | Where-Object Immutable)) { & $gate 'Immutable' 'Blocks' $item.Resource $difference.Property "Azure can't change it in place: $($difference.Current) -> $($difference.Desired). Keep the current value, or create a new resource." } } foreach ($item in $Change | Where-Object Action -EQ 'Unknown') { & $gate 'Read' 'Blocks' $item.Resource '' $item.Reason } $lock = @($Change | Where-Object { $_.Kind -eq 'lock' -and $_.Exists }) | Select-AACFirst 1 if ($lock -and @($Change | Where-Object Action -EQ 'Delete').Count) { & $gate 'Lock' 'Blocks' $lock.Resource '' "-Prune would delete $(@($Change | Where-Object Action -EQ 'Delete').Count) item(s), but the account is locked: Azure refuses the deletes. Remove the lock first, or don't prune." } $acl = if ($account.Planned) { $account.Planned['properties']['networkAcls'] } else { $null } $blobs = @($Change | Where-Object { $_.Kind -eq 'blob' -and $_.Action -in 'Create', 'Update' }) if ($blobs.Count -and $acl -and [string]$acl['defaultAction'] -eq 'Deny') { & $gate 'Network' 'Warn' $account.Resource 'networkAcls.defaultAction' "$($blobs.Count) blob(s) are uploaded from this computer, but the account denies network access by default: add this computer's public IP to networkAcls.ipRules, or the uploads are refused." } # --- Azure Policy ------------------------------------------------------------------------------------------------- $policies = @() if (-not $SkipPolicy) { Update-AACProgress -Id 'policy' -Description 'Reading the policy assignments for storage' -Indeterminate $rows = @(Get-AACAssignedPolicy -SubscriptionId $SubscriptionId -NoDisplay) $names = @{} foreach ($row in $rows) { $names[([string]$row.AssignmentId).ToLowerInvariant()] = [string]$row.AssignmentDisplayName } $applies = @($rows | Where-Object { $scope = ([string]$_.AssignmentScope).TrimEnd('/') ($group -ieq $scope -or $group.StartsWith("$scope/", [StringComparison]::OrdinalIgnoreCase)) -and -not @(([string]$_.NotScopes) -split ',\s*' | Where-Object { $_ -and ($group -ieq $_.TrimEnd('/') -or $group.StartsWith("$($_.TrimEnd('/'))/", [StringComparison]::OrdinalIgnoreCase)) }).Count -and ([string]$_.ResourceType -match 'Microsoft\.Storage' -or [string]$_.ResourceType -like 'All*') }) $policies = @($applies | Group-Object AssignmentId | ForEach-Object { $first = $_.Group[0] $effect = @($_.Group | Where-Object { [string]$_.ParameterName -match 'effect' } | ForEach-Object { $_.EffectiveValue } | Select-Object -Unique) -join ', ' [pscustomobject]@{ PSTypeName = 'AAC.StoragePolicy'; Assignment = $first.AssignmentDisplayName; Definition = $first.DefinitionDisplayName; Kind = $first.DefinitionType Effect = $(if ($effect) { $effect } else { '(set in the rule)' }); Enforcement = $first.EnforcementMode; Scope = $first.ScopeName; ResourceType = $first.ResourceType; AssignmentId = $first.AssignmentId } } | Sort-Object Assignment) $checks = @($writes | Where-Object { $_.Action -in 'Create', 'Update' -and $_.Kind -notin 'roleAssignment', 'blob' }) Update-AACProgress -Id 'policy' -Total ([Math]::Max($checks.Count, 1)) -Description "Checking $($checks.Count) change(s) against Azure Policy" foreach ($item in $checks) { $content = [ordered]@{ type = $item.Type; name = $item.Name } foreach ($key in @($item.Planned.Keys)) { if ($key -notin 'id', 'name', 'type', 'etag', 'systemData') { $content[$key] = $item.Planned[$key] } } $details = [ordered]@{ resourceContent = $content; apiVersion = ($item.Uri -replace '^.*api-version=', '') } $isChild = $item.Kind -ne 'account' -and $item.Kind -ne 'privateEndpoint' if ($isChild) { $details['scope'] = $item.Parent } $where = if ($item.Kind -eq 'privateEndpoint') { $item.Parent } else { $group } $request = [ordered]@{ resourceDetails = $details; includeAuditEffect = $true } try { $result = Invoke-AACArmRequest -Method Post -Uri "$where/providers/Microsoft.PolicyInsights/checkPolicyRestrictions?api-version=2024-10-01" -Body (ConvertTo-Json -InputObject $request -Depth 50 -Compress) } catch { & $gate 'Policy' 'Warn' $item.Resource '' "The policy check couldn't run: $($_.Exception.Message)" Update-AACProgress -Id 'policy' -Increment 1 continue } $assignmentName = { param($Info) $id = ([string]$Info['policyAssignmentId']).ToLowerInvariant(); if ($names.Contains($id)) { $names[$id] } else { ($id -split '/')[-1] } } foreach ($evaluation in @($result['contentEvaluationResult']['policyEvaluations'])) { if ($evaluation -isnot [System.Collections.IDictionary] -or [string]$evaluation['evaluationResult'] -ne 'NonCompliant') { continue } $effect = [string]$evaluation['effectDetails']['policyEffect'] $why = [string]$evaluation['evaluationDetails']['reason'] $failed = @($evaluation['evaluationDetails']['evaluatedExpressions'] | Where-Object { $_ -is [System.Collections.IDictionary] -and [string]$_['result'] -eq 'True' -and $_['path'] -ne 'type' } | ForEach-Object { "$($_['path']) $($_['operator']) $(ConvertTo-Json -InputObject $_['targetValue'] -Compress)" }) $detail = @($(if ($why) { $why }), $(if ($failed) { "($($failed -join '; '))" })) | Where-Object { $_ } & $gate 'Policy' $(if ($effect -eq 'Deny') { 'Blocks' } elseif ($effect -match 'Audit') { 'Audit' } else { 'Changes' }) $item.Resource (& $assignmentName $evaluation['policyInfo']) "$effect$(if ($detail) { ": $($detail -join ' ')" })" ([string]$evaluation['policyInfo']['policyDefinitionId']) } foreach ($field in @($result['fieldRestrictions'])) { if ($field -isnot [System.Collections.IDictionary]) { continue } foreach ($restriction in @($field['restrictions'] | Where-Object { $_ -is [System.Collections.IDictionary] })) { $kind = [string]$restriction['result'] if ($kind -eq 'Required' -and $restriction['defaultValue']) { & $gate 'Policy' 'Changes' $item.Resource (& $assignmentName $restriction['policy']) "Sets $($field['field']) to '$($restriction['defaultValue'])' when it isn't given ($($restriction['policyEffect']))." } elseif ($kind -eq 'Removed') { & $gate 'Policy' 'Changes' $item.Resource (& $assignmentName $restriction['policy']) "Removes $($field['field']) ($($restriction['policyEffect']))." } } } Update-AACProgress -Id 'policy' -Increment 1 } $verdicts = @($gates | Where-Object Gate -EQ 'Policy') Update-AACProgress -Id 'policy' -Complete -Description "Azure Policy: $($policies.Count) assignment(s) apply to storage here; $(@($verdicts | Where-Object Outcome -EQ 'Blocks').Count) deny, $(@($verdicts | Where-Object Outcome -EQ 'Audit').Count) audit, $(@($verdicts | Where-Object Outcome -EQ 'Changes').Count) change(s) by policy" if (-not $verdicts.Count -and $checks.Count) { & $gate 'Policy' 'Pass' $account.Resource '' "No policy denies, audits or changes the $($checks.Count) change(s)." } } # --- PSRule on the account as it will be ----------------------------------------------------------------------------- $psrule = $null $fixes = @() if (-not $SkipPSRule -and $account.Planned) { $children = [System.Collections.Generic.List[object]]::new() foreach ($item in $Change | Where-Object { $_.Kind -in 'blobService', 'container', 'fileService', 'share', 'queueService', 'queue', 'tableService', 'table', 'managementPolicy', 'diagnosticSetting', 'lock' -and $_.Planned -and $_.Action -ne 'Delete' }) { $children.Add((Merge-AACObject -Base $item.Planned -Overlay ([ordered]@{ id = $item.Id; name = ($item.Name -split '/')[-1]; type = $item.Type }))) } # apiVersion as a deployment has it: PSRule reads defaults by it. $object = Merge-AACObject -Base $account.Planned -Overlay ([ordered]@{ id = $account.Id; name = $account.Name; type = 'Microsoft.Storage/storageAccounts'; apiVersion = ($account.Uri -replace '^.*api-version=', ''); resourceGroupName = $ResourceGroupName; subscriptionId = $SubscriptionId; resources = $children.ToArray() }) try { $psrule = Invoke-AACPSRuleEngine -InputObject @($object) -Baseline $Baseline -ExcludeRule $ExcludeRule $failing = @($psrule.Results | Where-Object { $_.Outcome -in 'Fail', 'Error' }) $fixes = @(Get-AACStorageRuleFix -Result $failing -Configuration $Configuration -AccountExists:($account.Action -ne 'Create')) foreach ($result in $failing) { $ruleFixes = @($fixes | Where-Object { $_.Rule -eq $result.RuleName }) $fixText = @(foreach ($fix in $ruleFixes) { if ($fix.Kind -eq 'Auto') { "Set $($fix.Setting) = $(ConvertTo-Json -InputObject $fix.Value -Compress). $($fix.Advice)" } else { $fix.Advice } }) -join ' ' & $gate 'PSRule' 'Breaks' $result.ResourceName $result.RuleName "$($result.Title)$(if ($result.Reason) { " - $($result.Reason)" })" $result.Link $result.Severity $fixText } $passed = @($psrule.Results | Where-Object Outcome -EQ 'Pass').Count if ($passed) { & $gate 'PSRule' 'Pass' $account.Resource '' "$passed rule(s) pass." } } catch { # Not checked is not passed. & $gate 'PSRule' 'Blocks' $account.Resource '' "PSRule couldn't run, so the account can't be checked against your standards: $($_.Exception.Message)" '' '' 'Fix PSRule for Azure (Install-PSResource PSRule.Rules.Azure), or deploy without it with -SkipPSRule.' } } @{ Gates = $gates.ToArray(); Policies = $policies; PSRule = $psrule; Fixes = $fixes } } |