Modules/AzureDevOpsDsc.Common/Resources/Functions/Public/AzDoSecurityNamespacePermission/Get-AzDoSecurityNamespacePermission.ps1

Function Get-AzDoSecurityNamespacePermission
{
    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSObject[]])]
    param (
        [Parameter(Mandatory = $true)][string]$SecurityNamespace,
        [Parameter(Mandatory = $true)][string]$Token,
        [Parameter(Mandatory = $true)][string]$GroupName,
        [Parameter(Mandatory = $true)][bool]$isInherited,
        [Parameter()][HashTable[]]$Permissions,
        [Parameter()][HashTable]$LookupResult,
        [Parameter()][Ensure]$Ensure,
        [Parameter()][System.Management.Automation.SwitchParameter]$Force
    )

    Write-Verbose "[Get-AzDoSecurityNamespacePermission] Started."

    $OrganizationName = (Get-AzDoOrganizationName)

    $getResult = @{ Ensure = [Ensure]::Absent; propertiesChanged = @(); status = $null; reason = $null }

    $namespace = Get-CacheItem -Key $SecurityNamespace -Type 'SecurityNamespaces'
    if (-not $namespace)
    {
        Write-Error "[Get-AzDoSecurityNamespacePermission] Security namespace not found." -ErrorAction Continue
        $getResult.status = [DSCGetSummaryState]::Error
        $getResult.reason = "Security namespace '$SecurityNamespace' not found."
        return $getResult
    }

    $getResult.namespace = $namespace

    # New-ACLToken (called by ConvertTo-FormattedACL) strips [ ] from token names.
    # Strip brackets here so the filter matches: "$/[PROJECT]" → "$/PROJECT".
    $strippedToken  = $Token.Replace('[', '').Replace(']', '')

    # Get-DevOpsACL's -Token must be the API's own wire-format token string (e.g. the bare project
    # GUID for 'Build'), NOT the human-readable name in $strippedToken - build it the same way the Set
    # path does, via New-ACLToken + ConvertTo-FormattedToken.
    $queryToken = ConvertTo-FormattedToken -Token (New-ACLToken -SecurityNamespace $SecurityNamespace -TokenName $Token)

    # Token-scope the ACL fetch instead of pulling every ACL in the namespace: for a namespace with
    # a large accumulated ACL list (e.g. Build, shared org-wide), an unscoped fetch has to transfer,
    # parse and Find-Identity-resolve every ACE of every other token too - confirmed via a live wire
    # capture to take several minutes even though this token's own data is tiny.
    #
    # Deliberately NOT falling back to a full-namespace fetch when the scoped query returns $null:
    # Get-DevOpsACL documents $null as a valid "no explicit ACL for this token" answer, not a failure -
    # the most common case is exactly this (creating the first-ever ACE for a token), and falling back
    # to the full scan on $null defeats the whole point of token-scoping for precisely that case.
    $DevOpsACLs = if ($queryToken) { Get-DevOpsACL -OrganizationName $OrganizationName -SecurityDescriptorId $namespace.namespaceId -Token $queryToken }
                  else             { Get-DevOpsACL -OrganizationName $OrganizationName -SecurityDescriptorId $namespace.namespaceId }
    # Wrap in @() so $DifferenceACLs is always an array; ConvertTo-FormattedACL returns a
    # generic List that PowerShell unrolls to a bare hashtable when there is only one entry,
    # making [0] indexing in Test-ACLListforChanges return $null.
    # Use the actual $SecurityNamespace so Format-ACEs and Parse-ACLToken can resolve permission
    # bits against the real namespace descriptor.
    #
    # Filter on $_.ACL.token (the RAW wire-format token string ConvertTo-FormattedACL preserves
    # alongside its parsed form), not $_.Token.TokenValue: Parse-ACLToken only populates .TokenValue
    # for its Generic/unrecognised-namespace fallback - for every explicitly-handled type (Build,
    # Library, ServiceEndpoints, AgentPool, DistributedTask, etc.) .TokenValue is always $null, so this
    # filter always matched nothing for those namespaces. Confirmed live: the Set POST succeeded and the
    # subsequent unfiltered Get-DevOpsACL response DID contain the new ACE, but this filter discarded it
    # every time. $queryToken is already in the same raw wire format, so it compares directly.
    $DifferenceACLs = @($DevOpsACLs | ConvertTo-FormattedACL -SecurityNamespace $SecurityNamespace -OrganizationName $OrganizationName |
        Where-Object { $_.ACL.token -eq $queryToken })

    $params = @{
        Permissions       = $Permissions
        SecurityNamespace = $SecurityNamespace  # use actual namespace so ConvertTo-ACETokenList can resolve permission bits
        isInherited       = $isInherited
        OrganizationName  = $OrganizationName
        TokenName         = $Token
    }

    # Wrap in @() so $ReferenceACLs is always an array; Test-ACLListforChanges uses [0] indexing
    # and a raw hashtable returns $null at index 0.
    $ReferenceACLs = @(ConvertTo-ACL @params)

    $compareResult = Test-ACLListforChanges -ReferenceACLs $ReferenceACLs -DifferenceACLs $DifferenceACLs
    $getResult.propertiesChanged = $compareResult.propertiesChanged
    $getResult.status = [DSCGetSummaryState]::"$($compareResult.status)"
    $getResult.reason = $compareResult.reason
    $getResult.ReferenceACLs  = $ReferenceACLs
    $getResult.DifferenceACLs = $DifferenceACLs

    return $getResult
}