Private/Audit/Resolve-GooglePolicyValue.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Resolve-GooglePolicyValue {
    <#
    .SYNOPSIS
        Reads a Cloud Identity policy setting for a Fortification check, returning per-OU
        field values in a shape that is immune to how Get-GooglePolicySetting hands them back.
    .DESCRIPTION
        The GWS-1 placeholder->real-check conversions all need the same three-way answer:

          $null the Cloud Identity Policy API was unavailable (scope not delegated / API
                  disabled -> the collector returned $null). The check should SKIP with a
                  "scope not delegated" message — it is NOT a pass or a fail.
          @() the API was available but returned no policy of this type for the tenant
                  (or the requested field is absent from the value shape). The check should
                  SKIP — never invent a PASS/FAIL from a missing value.
          @(...) one entry per targeting OU / group. With -Field, each entry is that field's
                  value; without -Field, each entry is the whole setting.value object.

        Shape immunity: the committed Get-GooglePolicySetting returns setting.value objects,
        but a live-validation note described callers reading .setting.value off a returned
        *policy* object. Rather than bet 60 checks on one shape, this normalizer accepts
        either — if an entry still looks like a policy (has a .setting.value), it unwraps it;
        otherwise it treats the entry as the value object directly.
    .PARAMETER Policies
        $AuditData.CloudIdentityPolicies (the object from Get-GoogleCloudIdentityPolicies).
    .PARAMETER Type
        Bare setting type, e.g. 'security.less_secure_apps'.
    .PARAMETER Field
        Optional field name to extract from each value object, e.g. 'allowLessSecureApps'.
        Entries missing the field are dropped (so the result can be @() -> SKIP).
    #>

    [CmdletBinding()]
    param(
        $Policies,
        [Parameter(Mandatory)][string]$Type,
        [string]$Field
    )

    # API unavailable -> $null (caller SKIPs distinctly from "type absent").
    if (-not $Policies -or -not $Policies.ByType) { return $null }

    $raw = Get-GooglePolicySetting -Policies $Policies -Type $Type

    # Normalize each entry to its value object regardless of which shape we were handed.
    $values = foreach ($item in @($raw)) {
        if ($null -eq $item) { continue }
        $props = $item.PSObject.Properties.Name
        if (($props -contains 'setting') -and $item.setting -and $item.setting.value) {
            $item.setting.value
        } else {
            $item
        }
    }
    $values = @($values | Where-Object { $null -ne $_ })

    if (-not $PSBoundParameters.ContainsKey('Field') -or [string]::IsNullOrEmpty($Field)) {
        return @($values)
    }

    $fieldVals = foreach ($v in $values) {
        if ($v.PSObject.Properties.Name -contains $Field) { $v.$Field }
    }
    return @($fieldVals)
}

function ConvertFrom-GoogleDurationSeconds {
    <#
    .SYNOPSIS
        Parses a Cloud Identity duration string ("1209600s", "0s") to an integer of seconds.
        Returns $null if the value isn't a recognizable "<n>s" duration.
    #>

    [CmdletBinding()]
    param([Parameter(ValueFromPipeline)]$Duration)
    process {
        if ($null -eq $Duration) { return $null }
        $s = [string]$Duration
        if ($s -match '^\s*(\d+(?:\.\d+)?)s\s*$') { return [double]$Matches[1] }
        # Some shapes already deserialize to a number of seconds.
        $n = 0.0
        if ([double]::TryParse($s, [ref]$n)) { return $n }
        return $null
    }
}