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)."
            }
        }
    }
}