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

<#
.SYNOPSIS
Retrieves the current ACL state of an Azure DevOps work item query folder.
 
.DESCRIPTION
Builds the 'WorkItemQueryFolders' ACL token for the folder, fetches the matching ACLs and
compares them against the desired permissions.
 
The token addresses folders by GUID - '$/{projectId}/{folderId}/{subfolderId}' - so the
readable path is resolved to its chain of ids first. Omitting QueryPath targets the project's
query root, whose token is '$/{projectId}'.
 
.PARAMETER ProjectName
The name of the Azure DevOps project.
 
.PARAMETER QueryPath
The full path of the query folder. Omit to target the project's query root.
 
.PARAMETER isInherited
Whether the ACL inherits permissions from its parent.
 
.PARAMETER Permissions
The desired access control entries.
 
.PARAMETER LookupResult
The lookup result from a previous call, supplied by the DSC base class.
 
.PARAMETER Ensure
The desired state, supplied by the DSC base class.
 
.PARAMETER Force
Forces the operation, supplied by the DSC base class.
 
.EXAMPLE
Get-AzDoQueryPermission -ProjectName 'Contoso' -QueryPath 'Shared Queries/Platform' -isInherited $true
#>

Function Get-AzDoQueryPermission
{
    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSObject[]])]
    param
    (
        [Parameter(Mandatory = $true)]
        [Alias('Name')]
        [System.String]$ProjectName,

        [Parameter(Mandatory = $false)]
        [Alias('Path')]
        [System.String]$QueryPath,

        [Parameter(Mandatory = $true)]
        [System.Boolean]$isInherited,

        [Parameter()]
        [HashTable[]]$Permissions,

        [Parameter()]
        [HashTable]$LookupResult,

        [Parameter()]
        [Ensure]$Ensure,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]$Force
    )

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

    # https://learn.microsoft.com/en-us/azure/devops/organizations/security/namespace-reference
    $SecurityNamespace = 'WorkItemQueryFolders'
    $OrganizationName  = Get-AzDoOrganizationName

    $results = @{
        Ensure            = [Ensure]::Absent
        propertiesChanged = @()
        project           = $ProjectName
        queryPath         = (Format-AzDoQueryPath -Path $QueryPath)
        status            = $null
        reason            = $null
        identifiers       = $null
    }

    $normalizedPath = $results.queryPath

    if ([String]::IsNullOrWhiteSpace($normalizedPath))
    {
        Write-Verbose "[Get-AzDoQueryPermission] QueryPath not specified. Defaulting to the project's query root."
    }

    #
    # Resolve the project

    $projectCache = Resolve-AzDoProject -ProjectName $ProjectName

    if ($null -eq $projectCache)
    {
        Write-Warning "[Get-AzDoQueryPermission] Project not found: $ProjectName"
        $results.status = [DSCGetSummaryState]::Error
        $results.reason = "Project not found: $ProjectName"
        return $results
    }

    #
    # Resolve the query folder path to its chain of folder ids

    $identifierArr = @()

    if (-not [String]::IsNullOrWhiteSpace($normalizedPath))
    {
        $resolved = Resolve-AzDoQueryPath -Organization $OrganizationName -ProjectName $ProjectName -Path $normalizedPath

        if (-not $resolved.Exists)
        {
            # Name the segment that could not be resolved - "query folder not found" on a deep
            # path is otherwise very hard to act on.
            $failedAt = ($resolved.Segments[0..$resolved.Resolved]) -join '/'
            Write-Warning "[Get-AzDoQueryPermission] Query folder '$normalizedPath' not found in project '$ProjectName'. Path resolution stopped at '$failedAt'."
            $results.status = [DSCGetSummaryState]::NotFound
            $results.reason = "Query folder not found: $normalizedPath"
            return $results
        }

        if ($null -ne $resolved.Item -and (-not $resolved.Item.isFolder))
        {
            Write-Error "[Get-AzDoQueryPermission] '$normalizedPath' in project '$ProjectName' is a query, not a folder. Permissions in the WorkItemQueryFolders namespace are set on folders."
            $results.status = [DSCGetSummaryState]::Error
            $results.reason = 'PathIsNotAFolder'
            return $results
        }

        $identifierArr = @($resolved.IdChain)
    }

    $results.identifiers = $identifierArr

    #
    # Build the ACL token

    $aclToken = '$/{0}' -f $projectCache.id
    if ($identifierArr.Count -gt 0)
    {
        $aclToken += ($identifierArr | ForEach-Object { '/{0}' -f $_ }) -join ''
    }

    Write-Verbose "[Get-AzDoQueryPermission] ACL Token: $aclToken"

    #
    # Perform the lookup of the permissions

    $namespace = Get-CacheItem -Key $SecurityNamespace -Type 'SecurityNamespaces'

    if ($null -eq $namespace)
    {
        Write-Error "[Get-AzDoQueryPermission] Security namespace '$SecurityNamespace' was not found in the cache."
        $results.status = [DSCGetSummaryState]::Error
        $results.reason = "Security namespace not found: $SecurityNamespace"
        return $results
    }

    $results.namespace = $namespace

    # Scope the ACL fetch to this token rather than reading the whole namespace, falling back to
    # the full fetch if the scoped query returns nothing - so behaviour is never worse than an
    # unscoped scan.
    $ACLLookupParams = @{
        OrganizationName     = $OrganizationName
        SecurityDescriptorId = $namespace.namespaceId
        Token                = $aclToken
    }

    $DevOpsACLs = Get-DevOpsACL @ACLLookupParams

    if ($null -eq $DevOpsACLs)
    {
        $DevOpsACLs = Get-DevOpsACL -OrganizationName $OrganizationName -SecurityDescriptorId $namespace.namespaceId
    }

    if ($null -eq $DevOpsACLs)
    {
        Write-Error "[Get-AzDoQueryPermission] No ACLs were found within the Security Namespace."
        $results.status = [DSCGetSummaryState]::Error
        $results.reason = "No ACLs were found within the Security Namespace."
        return $results
    }

    # Drop the ACLs this lookup cannot be interested in BEFORE formatting them, exactly as
    # Get-AzDoProjectPermission and Get-AzDoProcessPermission already do. Formatting resolves every
    # ACE through Find-Identity, which costs an API round trip for each descriptor that is not
    # already cached, so formatting a whole namespace only to keep one token is where the time goes.
    # The fallback above fires whenever the query folder has no explicit ACL - the normal state
    # once permissions revert to inherited - so the full namespace is the common path, not a rare
    # one. The folder ids are matched unordered here, a superset of the ordered filter
    # below, which still decides what is kept. With no ids (the project query root) there is nothing
    # to narrow on, so the list is left as it is.
    $DevOpsACLs = @($DevOpsACLs | Where-Object {
        $rawToken = $_.token
        if ([String]::IsNullOrEmpty($rawToken)) { return $false }
        foreach ($identifier in $identifierArr)
        {
            if ($rawToken -notlike ('*{0}*' -f $identifier)) { return $false }
        }
        return $true
    })

    $DifferenceACLs = @($DevOpsACLs | ConvertTo-FormattedACL -SecurityNamespace $SecurityNamespace -OrganizationName $OrganizationName)

    # No ACL for this token is a valid state, not a missing resource - it is what the API returns
    # once permissions revert to inherited. NotFound here would tell the base class Ensure is Absent
    # and skip Set, so the empty list goes to Test-ACLListforChanges instead, which reads "none
    # desired, none present" as Unchanged and "some desired, none present" as Changed.

    # Keep only the ACL for this exact folder. The comparison is order-sensitive: the token is a
    # path, so the same ids in a different order describe a different folder.
    $DifferenceACLs = $DifferenceACLs | Where-Object { $_.Token.Type -eq 'QueryPermission' } | Where-Object {

        if ($_.token.Identifiers.Count -ne $identifierArr.Count) { return $false }

        for ($i = 0; $i -lt $identifierArr.Count; $i++)
        {
            if ($_.token.Identifiers[$i].identifier -ne $identifierArr[$i]) { return $false }
        }

        return $true
    }

    Write-Verbose "[Get-AzDoQueryPermission] ACL List retrieved and formatted."

    #
    # Convert the desired permissions into an ACL and compare

    $params = @{
        Permissions       = $Permissions
        SecurityNamespace = $SecurityNamespace
        isInherited       = $isInherited
        OrganizationName  = $OrganizationName
        TokenName         = $aclToken
    }

    $ReferenceACLs = ConvertTo-ACL @params

    $compareResult = Test-ACLListforChanges -ReferenceACLs $ReferenceACLs -DifferenceACLs $DifferenceACLs

    $results.propertiesChanged = $compareResult.propertiesChanged
    $results.status            = [DSCGetSummaryState]::"$($compareResult.status)"
    $results.reason            = $compareResult.reason
    $results.ReferenceACLs     = $ReferenceACLs
    $results.DifferenceACLs    = $DifferenceACLs
    $results.aclToken          = $aclToken

    Write-Verbose "[Get-AzDoQueryPermission] Result Status: $($results.status)"

    return $results
}