Public/Test-NSPConditionalAccessDefinition.ps1
|
function Test-NSPConditionalAccessDefinition { <# .SYNOPSIS Checks policy definitions offline and returns one finding per problem. .DESCRIPTION Needs no tenant profile and no Graph connection. Errors make a definition unusable, and Resolve-NSPConditionalAccessPolicy refuses it. Warnings flag legal but doubtful definitions. No output means the definition passed. Checks: known keys only; Id format and file name; name, version, and default state; exact Graph values for closed sets; each reference in a field that accepts it; at least one user and application target; user actions and applications not mixed; a grant or session control; block used alone; an authentication strength from the catalog and not combined with mfa or block; a known Option; and the breakglass exclusion wherever RequireBreakGlassExclusion is not explicitly $false. When several definitions are piped, duplicate Ids are also reported. .EXAMPLE Get-NSPConditionalAccessDefinition | Test-NSPConditionalAccessDefinition | Format-Table #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)][System.Collections.IDictionary]$Definition ) begin { $schema = Get-NSPConditionalAccessDefinitionSchema $seen = @{} function ConvertTo-Finding { param($Definition, [string]$Severity, [string]$Rule, [string]$Message) [pscustomobject]@{ Id = $Definition['Id'] Source = if ($Definition['SourcePath']) { Split-Path -Leaf $Definition['SourcePath'] } else { $null } Severity = $Severity Rule = $Rule Message = $Message } } function Get-ItemCount { param($Value) @($Value | Where-Object { $null -ne $_ }).Count } function Get-ListValue { param($Definition, $Spec) $container = if ($Spec.Section) { $Definition[$Spec.Section] } else { $Definition } if ($container -isnot [System.Collections.IDictionary] -or -not $container.Contains($Spec.Key)) { return @() } @($container[$Spec.Key] | Where-Object { $null -ne $_ }) } } process { $d = $Definition $label = if ($d['Id']) { $d['Id'] } else { '(no Id)' } foreach ($key in $d.Keys) { if ($key -notin $schema.TopLevelKeys) { ConvertTo-Finding $d Error UnknownKey "Unknown key '$key'." } } if (-not ($d['Id'] -is [string] -and $d['Id'] -cmatch $schema.IdPattern)) { ConvertTo-Finding $d Error IdFormat "Id must match NSP-{Part2}-{Part3}-NN (uppercase, two-digit number)." } else { if ($seen.ContainsKey($d['Id'])) { ConvertTo-Finding $d Error DuplicateId "Id $($d['Id']) is also defined in $($seen[$d['Id']])." } $seen[$d['Id']] = if ($d['SourcePath']) { Split-Path -Leaf $d['SourcePath'] } else { $label } if ($d['SourcePath'] -and [System.IO.Path]::GetFileNameWithoutExtension($d['SourcePath']) -cne $d['Id']) { ConvertTo-Finding $d Error FileName "File name must be '$($d['Id']).psd1'." } } if (-not ($d['Name'] -is [string] -and $d['Name'].Trim())) { ConvertTo-Finding $d Error Name 'Name is required.' } if (-not ($d['Version'] -is [int] -and $d['Version'] -ge 1)) { ConvertTo-Finding $d Error Version 'Version must be a whole number of at least 1.' } if ($d.Contains('LegacyNames') -and @($d['LegacyNames'] | Where-Object { $_ -isnot [string] -or -not $_.Trim() }).Count) { ConvertTo-Finding $d Error LegacyNames 'LegacyNames must be a list of non-empty display names.' } if ($d.Contains('Option')) { $optionName, $optionValue = ([string]$d['Option']) -split '=', 2 $option = (Get-NSPConditionalAccessOption)[$optionName] if (-not $option -or -not $optionValue -or @($optionValue -split ',' | Where-Object { $_ -cnotin $option.Values }).Count) { ConvertTo-Finding $d Error Option "Option must be '<Name>=<Value>' (or '<Name>=<Value>,<Value>') for a known tenant option ($(@((Get-NSPConditionalAccessOption).Keys) -join ', '))." } } if ($d['DefaultState'] -cnotin @($schema.States.Keys)) { ConvertTo-Finding $d Error DefaultState "DefaultState must be one of: $(@($schema.States.Keys) -join ', ')." } foreach ($section in $schema.Sections.Keys) { if (-not $d.Contains($section)) { continue } if ($d[$section] -isnot [System.Collections.IDictionary]) { ConvertTo-Finding $d Error SectionShape "$section must be a hashtable." continue } foreach ($key in $d[$section].Keys) { if ($key -notin $schema.Sections[$section]) { ConvertTo-Finding $d Error UnknownKey "Unknown key '$section.$key'." } } } foreach ($spec in $schema.Lists) { $field = if ($spec.Section) { "$($spec.Section).$($spec.Key)" } else { $spec.Key } foreach ($item in Get-ListValue $d $spec) { if ($item -isnot [string]) { ConvertTo-Finding $d Error ValueType "$field contains a non-string value." continue } if ($spec.Contains('Values')) { if ($item -cnotin $spec.Values) { ConvertTo-Finding $d Error Value "$field value '$item' is not one of: $($spec.Values -join ', ')." } continue } if ($item -cmatch $schema.ReferencePattern) { $kind = $Matches['kind'] $hasName = [bool]$Matches['name'] if ($kind -notin $spec.References) { ConvertTo-Finding $d Error ReferencePlacement "$field cannot hold a {$kind} reference." } elseif ($kind -in 'BreakGlass', 'ServiceAccounts' -and $hasName) { ConvertTo-Finding $d Error ReferenceFormat "$field : {$kind} takes no name." } elseif ($kind -notin 'BreakGlass', 'ServiceAccounts' -and -not $hasName) { ConvertTo-Finding $d Error ReferenceFormat "$field : {$kind} needs a name, e.g. {${kind}:Name}." } } elseif ($item -cnotin $spec.Keywords) { ConvertTo-Finding $d Error Reference "$field value '$item' is neither a reference nor an allowed keyword ($($spec.Keywords -join ', '))." } } } $users = $d['Users'] $hasUserTarget = $users -is [System.Collections.IDictionary] -and ( (Get-ItemCount $users['IncludeUsers']) + (Get-ItemCount $users['IncludeGroups']) + (Get-ItemCount $users['IncludeRoles']) + (Get-ItemCount $users['IncludeGuestsOrExternalUsers']) -gt 0) if (-not $hasUserTarget) { ConvertTo-Finding $d Error UserTarget 'Users needs IncludeUsers, IncludeGroups, IncludeRoles, or IncludeGuestsOrExternalUsers.' } if ($hasUserTarget -and @($users['IncludeUsers']) -ccontains 'All' -and ((Get-ItemCount $users['IncludeGroups']) + (Get-ItemCount $users['IncludeRoles'])) -gt 0) { ConvertTo-Finding $d Warning RedundantInclude "IncludeUsers 'All' already covers IncludeGroups/IncludeRoles." } $apps = $d['Applications'] $appInclude = if ($apps -is [System.Collections.IDictionary]) { Get-ItemCount $apps['Include'] } else { 0 } $userActions = if ($apps -is [System.Collections.IDictionary]) { Get-ItemCount $apps['UserActions'] } else { 0 } if ($appInclude -eq 0 -and $userActions -eq 0) { ConvertTo-Finding $d Error ApplicationTarget 'Applications needs Include or UserActions.' } if ($appInclude -gt 0 -and $userActions -gt 0) { ConvertTo-Finding $d Error ApplicationTarget 'Applications.Include and UserActions cannot be combined.' } $grant = $d['Grant'] $session = $d['Session'] if ($null -eq $grant -and $null -eq $session) { ConvertTo-Finding $d Error Control 'A Grant or Session control is required.' } if ($grant -is [System.Collections.IDictionary]) { if ($grant['Operator'] -cnotin $schema.GrantOperators) { ConvertTo-Finding $d Error GrantOperator "Grant.Operator must be AND or OR." } $controls = @($grant['BuiltInControls'] | Where-Object { $null -ne $_ }) $strength = $grant['AuthenticationStrength'] if ($strength) { if (-not ($strength -is [string] -and $strength -cmatch '^\{Strength:(?<name>[^{}]+)\}$')) { ConvertTo-Finding $d Error AuthenticationStrength 'Grant.AuthenticationStrength must be a {Strength:Name} reference.' } elseif (-not (Get-NSPConditionalAccessStrengthCatalog).ContainsKey($Matches['name'])) { ConvertTo-Finding $d Error AuthenticationStrength "Grant.AuthenticationStrength: no strength '$($Matches['name'])' in the catalog ($(@((Get-NSPConditionalAccessStrengthCatalog).Keys) -join ', '))." } if ($controls -ccontains 'mfa' -or $controls -ccontains 'block') { ConvertTo-Finding $d Error AuthenticationStrength "Grant.AuthenticationStrength cannot be combined with 'mfa' or 'block'." } } if ($controls.Count -eq 0 -and -not $strength) { ConvertTo-Finding $d Error GrantControls 'Grant.BuiltInControls is empty and there is no AuthenticationStrength.' } foreach ($control in $controls) { if ($control -cnotin $schema.BuiltInControls) { ConvertTo-Finding $d Error GrantControls "Grant control '$control' is not one of: $($schema.BuiltInControls -join ', ')." } } if ($controls -ccontains 'block' -and $controls.Count -gt 1) { ConvertTo-Finding $d Error GrantControls "'block' cannot be combined with other grant controls." } } if ($session -is [System.Collections.IDictionary] -and $session.Contains('SignInFrequency')) { $frequency = $session['SignInFrequency'] $everyTime = $frequency -is [System.Collections.IDictionary] -and $frequency['EveryTime'] -eq $true -and $frequency.Count -eq 1 $timed = $frequency -is [System.Collections.IDictionary] -and $frequency['Value'] -is [int] -and $frequency['Value'] -ge 1 -and $frequency['Type'] -cin $schema.SignInFrequencyTypes -and -not $frequency.Contains('EveryTime') if (-not ($everyTime -or $timed)) { ConvertTo-Finding $d Error SignInFrequency "Session.SignInFrequency needs Value (whole number, at least 1) and Type (hours or days), or only EveryTime = `$true." } } if ($session -is [System.Collections.IDictionary] -and $session.Contains('PersistentBrowser') -and $session['PersistentBrowser'] -cnotin $schema.PersistentBrowserModes) { ConvertTo-Finding $d Error PersistentBrowser "Session.PersistentBrowser must be one of: $($schema.PersistentBrowserModes -join ', ')." } if ($d['RequireBreakGlassExclusion'] -ne $false) { $excluded = $users -is [System.Collections.IDictionary] -and @($users['ExcludeUsers']) -ccontains '{BreakGlass}' if (-not $excluded) { ConvertTo-Finding $d Error BreakGlass "Users.ExcludeUsers must contain '{BreakGlass}' (or set RequireBreakGlassExclusion = `$false deliberately)." } } } } |