internal/Audit/Resolve-GwsStudentOuScope.ps1
|
# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution # Student-OU scope: the shared machinery behind every OU-scoped K12 check. # # Student posture is an OU subtree, not a tenant-wide property. A check that # assesses student posture must be TOLD which OUs hold students (-StudentOU); # when it is not told, it reports Not Assessed. It never silently assesses the # whole tenant as if that were the student population. function ConvertTo-GuerrillaStudentOuList { <# .SYNOPSIS Normalizes a -StudentOU argument into a canonical, deduplicated, sorted list. .DESCRIPTION Trims entries, drops blanks, deduplicates case-insensitively, and sorts ordinally so the same scope always produces the same list (the list is part of the run's comparison-series identity). With -EnsureLeadingSlash (GWS OU paths), a missing leading '/' is added and a trailing '/' is stripped (except on the root itself). AD DNs are passed through as trimmed strings. #> [CmdletBinding()] param( [AllowNull()][AllowEmptyCollection()][string[]]$StudentOu, [switch]$EnsureLeadingSlash ) $normalized = foreach ($entry in @($StudentOu)) { $s = "$entry".Trim() if (-not $s) { continue } if ($EnsureLeadingSlash) { if (-not $s.StartsWith('/')) { $s = "/$s" } if ($s.Length -gt 1) { $s = $s.TrimEnd('/') } } $s } @($normalized | Sort-Object -Unique) } function Get-GuerrillaOuScopeString { <# .SYNOPSIS Canonical OU-scope identity string for run-history series matching. .DESCRIPTION A run's comparison series must include its OU scope: a student-scoped run diffed against a whole-tenant run reports scope differences as drift, which is false drift. This helper renders (targetOu, studentOus) canonically, with the defaults old records imply when the fields are absent (targetOu '/', no student OUs), so pre-scope whole-tenant history keeps matching whole-tenant runs and any scoped run starts its own series. #> [CmdletBinding()] param( [AllowNull()][AllowEmptyString()][string]$TargetOu, [AllowNull()][AllowEmptyCollection()][string[]]$StudentOu ) $t = "$TargetOu".Trim() if (-not $t) { $t = '/' } $s = @(ConvertTo-GuerrillaStudentOuList -StudentOu $StudentOu) -join ',' "targetOu=$t;studentOus=$s" } function Get-StudentScopeNotAssessed { <# .SYNOPSIS Returns the honest Not Assessed finding when an OU-scoped check has no student OU scope to work with; $null when the scope is usable. .DESCRIPTION Input absence renders as Not Assessed, never as a fabricated PASS and never as a silent whole-tenant assessment. Call this FIRST in every OU-scoped check. #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$CheckDefinition, [Parameter(Mandatory)][hashtable]$AuditData ) if (@($AuditData.StudentOUs).Count -eq 0 -or -not ($AuditData.StudentOUs | Where-Object { "$_".Trim() })) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue ('Not Assessed: no student OU scope provided; this check requires it. ' + 'Pass -StudentOU with the organizational unit path(s) that contain student accounts ' + '(or set the Student OUs field in Show-Guerrilla). This control was not evaluated; ' + 'absence of evidence is not compliance.') ` -Details @{ NotAssessed = $true MissingInput = 'StudentOU' } } return $null } function New-StudentOuAbsentSkip { <# .SYNOPSIS The Not Assessed finding for one named student OU that is not present in the tenant's collected OU tree (typo, renamed OU, wrong tenant). #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$CheckDefinition, [Parameter(Mandatory)][string]$OrgUnitPath ) New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -OrgUnitPath $OrgUnitPath ` -CurrentValue ("Not Assessed: student OU '$OrgUnitPath' was not found in the tenant's OU tree. " + 'Check the path for typos or renames. This control was not evaluated for that OU; ' + 'absence of evidence is not compliance.') ` -Details @{ NotAssessed = $true MissingOu = $OrgUnitPath } } function Test-GwsOrgUnitPresent { <# .SYNOPSIS Three-way answer for "does this OU path exist in the collected OU tree?" .DESCRIPTION $true the OU path is in $AuditData.Tenant.OrgUnits $false the tree was collected and the path is not in it $null the tree is unavailable (collection failed or never ran), so presence is UNKNOWN. The caller must go Not Assessed, not guess. The root '/' is always present when the tree is available (orgunits.list does not return the root node itself). #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][string]$OrgUnitPath ) $errors = $AuditData.Errors if ($errors -is [System.Collections.IDictionary] -and $errors.Contains('OrgUnits')) { return $null } $tree = $AuditData.Tenant.OrgUnits if ($null -eq $tree) { return $null } if ($OrgUnitPath -eq '/') { return $true } foreach ($ou in @($tree)) { if ("$($ou.orgUnitPath)" -eq $OrgUnitPath) { return $true } } return $false } function Test-GwsValueHasField { <# .SYNOPSIS Shape-immune "does this value object carry this field": live collector objects are PSCustomObjects, fixture data may be hashtables. Both count. #> [CmdletBinding()] param($Object, [Parameter(Mandatory)][string]$Field) if ($null -eq $Object) { return $false } if ($Object -is [System.Collections.IDictionary]) { return [bool]$Object.Contains($Field) } return $Object.PSObject.Properties.Name -contains $Field } function Resolve-GwsStudentOuPolicy { <# .SYNOPSIS Resolves the EFFECTIVE Cloud Identity policy value for one specific OU, by nearest-ancestor, from the tenant-wide policies.list data. .DESCRIPTION policies.list returns one policy per (setting type, targeted OU/group); each carries policyQuery naming its target. The effective setting for an OU is the policy on its nearest ancestor (including itself). This walks the OU's ancestor chain from deepest to shallowest and returns the first match; a policy whose target OU id cannot be mapped to a collected OU path is the customer-root default (orgunits.list does not return the root node), used when no ancestor carries a policy. At the same level an ADMIN-authored policy wins over the SYSTEM default. Returns $null when the Policy API was unavailable (caller SKIPs), else: Found a policy of this type resolved for this OU Value the requested field value (or whole value object without -Field) SourceOuPath which OU the winning policy targets ('(root)' for the default) Inherited the winning policy targets an ancestor, not the OU itself AdminAuthored ADMIN-authored (vs the SYSTEM default) GroupOverride a group-targeted policy of this type exists in the tenant; group membership cannot be resolved to an OU, so some students may receive a different value. Callers surface this in evidence. #> [CmdletBinding()] param( $Policies, [Parameter(Mandatory)][string]$Type, [Parameter(Mandatory)][string]$OrgUnitPath, [AllowNull()]$OrgUnits, [string]$Field ) if (-not $Policies -or -not $Policies.ByType) { return $null } $candidates = @($Policies.ByType[$Type]) $result = [pscustomobject]@{ Found = $false Value = $null SourceOuPath = $null Inherited = $false AdminAuthored = $false GroupOverride = $false } if ($candidates.Count -eq 0) { return $result } # Map collected OU ids to paths ('id:03x...' and 'orgUnits/03x...' both -> bare id). $idToPath = @{} foreach ($ou in @($OrgUnits)) { if ($null -eq $ou) { continue } $bare = "$($ou.orgUnitId)" -replace '^(id:|orgUnits/)', '' if ($bare) { $idToPath[$bare] = "$($ou.orgUnitPath)" } } # Ancestor chain, deepest first: '/Students/HS' -> '/Students/HS', '/Students'. $chain = [System.Collections.Generic.List[string]]::new() if ($OrgUnitPath -ne '/') { $parts = @($OrgUnitPath.Trim('/') -split '/') for ($i = $parts.Count; $i -ge 1; $i--) { $chain.Add('/' + (($parts[0..($i - 1)]) -join '/')) } } # Attribute each candidate policy to an OU path (or the root default). $attributed = foreach ($p in $candidates) { if ($null -eq $p) { continue } $q = $p.policyQuery if ($q -and (Test-GwsValueHasField $q 'group') -and "$($q.group)") { $result.GroupOverride = $true continue } $ouRef = if ($q) { "$($q.orgUnit)" -replace '^orgUnits/', '' } else { '' } $path = if ($ouRef -and $idToPath.ContainsKey($ouRef)) { $idToPath[$ouRef] } else { '(root)' } $sortOrder = -1.0 if ($q -and (Test-GwsValueHasField $q 'sortOrder') -and $null -ne $q.sortOrder) { $sortOrder = [double]$q.sortOrder } [pscustomobject]@{ Policy = $p; Path = $path; Admin = ("$($p.type)" -eq 'ADMIN'); SortOrder = $sortOrder } } $attributed = @($attributed) # Precedence order: nearest ancestor first, root default last; within one OU # the highest sortOrder wins (the API's documented tiebreak), then ADMIN over # SYSTEM. Field values can be SPLIT across policies (the API's merge # semantics), so -Field extraction walks the whole precedence list and takes # the field from the first policy that carries it. $precedence = [System.Collections.Generic.List[object]]::new() foreach ($ancestor in $chain) { foreach ($c in @($attributed | Where-Object { $_.Path -eq $ancestor } | Sort-Object @{ e = { $_.SortOrder }; Descending = $true }, @{ e = { -not $_.Admin } })) { $precedence.Add($c) } } foreach ($c in @($attributed | Where-Object { $_.Path -eq '(root)' } | Sort-Object @{ e = { $_.SortOrder }; Descending = $true }, @{ e = { -not $_.Admin } })) { $precedence.Add($c) } if ($precedence.Count -eq 0) { return $result } $winner = $null $value = $null if ($PSBoundParameters.ContainsKey('Field') -and $Field) { foreach ($c in $precedence) { $v = $c.Policy.setting.value if (Test-GwsValueHasField $v $Field) { $winner = $c $value = $v.$Field break } } # No policy carries the field: unresolved, never invent a PASS/FAIL. if (-not $winner) { return $result } } else { $winner = $precedence[0] $value = $winner.Policy.setting.value } $result.Found = $true $result.Value = $value $result.SourceOuPath = $winner.Path $result.Inherited = ($winner.Path -ne $OrgUnitPath) $result.AdminAuthored = $winner.Admin return $result } |