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


Function Get-AzDoAreaPermission
{
    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSObject[]])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$ProjectName,

        [Parameter(Mandatory = $false)]
        [string]$AreaPath,

        [Parameter(Mandatory = $true)]
        [bool]$isInherited,

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

        [Parameter()]
        [HashTable]$LookupResult,

        [Parameter()]
        [Ensure]$Ensure,

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

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

    # Define the Descriptor Type and Organization Name
    # https://learn.microsoft.com/en-us/azure/devops/organizations/security/namespace-reference?view=azure-devops
    $SecurityNamespace = 'CSS' # Manages area path object-level permissions.
    $OrganizationName = (Get-AzDoOrganizationName)

    Write-Verbose "[Get-AzDoAreaPermission] Security Namespace: $SecurityNamespace"
    Write-Verbose "[Get-AzDoAreaPermission] Organization Name: $OrganizationName"
    Write-Verbose "[Get-AzDoAreaPermission] Project Name: $ProjectName"


    if ([String]::IsNullOrEmpty($AreaPath)) {

        Write-Warning "[Get-AzDoAreaPermission] AreaPath not specified. Defaulting to top-level Project permissions"
        $AreaPath = $null

    } else {
        Write-Verbose "[Get-AzDoAreaPermission] AreaPath: $AreaPath"
    }

    #
    # Construct a hashtable detailing the group

    $results = @{
        Ensure = [Ensure]::Absent
        propertiesChanged = @()
        project = $ProjectName
        areaPath = $AreaPath
        status = $null
        reason = $null
        identifiers = $null
    }

    Write-Verbose "[Get-AzDoAreaPermission] Group result hashtable constructed."
    Write-Verbose "[Get-AzDoAreaPermission] Performing lookup of permissions for the AreaPath."

    # Define the ACL List
    $ACLList = [System.Collections.Generic.List[Hashtable]]::new()

    # Perform a Lookup within the Cache for the Project
    $projectCache = Get-CacheItem -Key $ProjectName -Type 'LiveProjects'

    # If not in cache, fall back to a live API lookup
    if (-not $projectCache)
    {
        Write-Verbose "[Get-AzDoAreaPermission] Project '$ProjectName' not in cache — falling back to live API lookup."
        $projectCache = Invoke-AzDevOpsApiRestMethod -Uri "https://dev.azure.com/$OrganizationName/_apis/projects/${ProjectName}?api-version=7.1-preview.4" -Method Get
        if ($projectCache) { Add-CacheItem -Key $ProjectName -Value $projectCache -Type 'LiveProjects' }
    }

    # Test if the Project was found
    if (-not $projectCache)
    {
        Write-Warning "[Get-AzDoAreaPermission] Project not found: $ProjectName"
        $results.status = [DSCGetSummaryState]::Error
        $results.reason = "Project not found: $ProjectName"

        return $results
    }

    # Test if the AreaPath was specified
    if ($AreaPath) {
        Write-Verbose "[Get-AzDoAreaPermission] AreaPath Name: $AreaPath is not null."
        # Format the AreaPath to retrieve all of the AreaPath nodes
        $FormattedAreaPaths = Format-AzDoAreaPath -AreaPath $AreaPath -ProjectName $ProjectName | Get-AllAzDoClassificationNodePaths
    } else {
        Write-Verbose "[Get-AzDoAreaPermission] AreaPath Name: $AreaPath is null."
        # If AreaPath is not specified, get the top-level area path
        $FormattedAreaPaths = @("\$ProjectName\Area")
    }

    # Perform a Lookup within the Cache for the AreaPath
    [Array]$AreaPaths = $FormattedAreaPaths | ForEach-Object {
        Write-Verbose "[Get-AzDoAreaPermission] AreaPath: $_"
        # Get the cached item for the AreaPath and add it to the list
        Get-CacheItem -Key $_ -Type 'LiveAreaNodes'
    }

    # If area nodes not in cache, fall back to a live API lookup
    if ($AreaPaths.count -ne $FormattedAreaPaths.Count)
    {
        Write-Verbose "[Get-AzDoAreaPermission] Area path nodes not in cache — falling back to live API lookup."
        $liveNodes = List-DevOpsClassificationNodes -OrganizationName $OrganizationName -ProjectName $ProjectName
        foreach ($node in $liveNodes)
        {
            if ($node.structureType -eq 'area')
            {
                Format-ClassificationNode -Node $node -CacheType 'LiveAreaNodes'
            }
        }

        [Array]$AreaPaths = $FormattedAreaPaths | ForEach-Object {
            Get-CacheItem -Key $_ -Type 'LiveAreaNodes'
        }
    }

    # Ensure that the number of cached area path nodes is the same as the formatted nodes.
    if ($AreaPaths.count -ne $FormattedAreaPaths.Count)
    {
        Write-Warning "[Get-AzDoAreaPermission] The area path nodes do not match the formatted area path nodes."
        $results.status = [DSCGetSummaryState]::Error
        $results.reason = "The area path nodes do not match the formatted area path nodes."
        return $results
    }

    # Once the AreaPath is found, we can get the identifiers
    $identifierArr = $AreaPaths | ForEach-Object { $_.identifier }
    # Update the results. This is used to construct regex expressions.
    $results.identifiers = $identifierArr

    #
    # Perform Lookup of the Permissions

    $namespace = Get-CacheItem -Key $SecurityNamespace -Type 'SecurityNamespaces'
    Write-Verbose "[Get-AzDoAreaPermission] Retrieved namespace: $($namespace.namespaceId)"

    # Add to the ACL Lookup Params
    $results.namespace = $namespace

    # Token-scope the ACL fetch to this area path's classification-node token instead of scanning the
    # entire org-wide CSS namespace. Fall back to the full-namespace fetch if the scoped query returns
    # nothing, so behaviour is never worse than the previous full scan.
    $aclToken = ($identifierArr | ForEach-Object { 'vstfs:///Classification/Node/{0}' -f $_ }) -join ':'
    $ACLLookupParams = @{
        OrganizationName        = $OrganizationName
        SecurityDescriptorId    = $namespace.namespaceId
    }
    if ($aclToken) { $ACLLookupParams.Token = $aclToken }

    # Get the ACL List and format the ACLS
    Write-Verbose "[Get-AzDoAreaPermission] ACL Lookup Params: $($ACLLookupParams | Out-String)"

    # Get the ACLs for the AreaPath
    $DevOpsACLs = Get-DevOpsACL @ACLLookupParams
    if (($null -eq $DevOpsACLs) -and $aclToken) { $DevOpsACLs = Get-DevOpsACL -OrganizationName $OrganizationName -SecurityDescriptorId $namespace.namespaceId }

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

    # Convert the ACLs to a formatted ACL
    $DifferenceACLs = $DevOpsACLs | ConvertTo-FormattedACL -SecurityNamespace $SecurityNamespace -OrganizationName $OrganizationName

    # Test if the ACLs were found
    if ($DifferenceACLs -eq $null)
    {
        Write-Warning "[Get-AzDoAreaPermission] No ACLs found for the AreaPath."
        $results.status = [DSCGetSummaryState]::NotFound
        return $results
    }

    # Filter the ACLs to only those matching the specific area path token (always applied).
    $DifferenceACLs = $DifferenceACLs | Where-Object { $_.Token.Type -eq 'AreaPathPermission' } | Where-Object {

        # Check if the current array contains all items in the matching list
        if ($_.token.Identifiers.Count -ne $identifierArr.Count) { return $false }

        # Check if the current array contains all items in the matching list
        foreach ($item in $identifierArr) {
            if ($_.token.Identifiers.identifier -notcontains $item) {
                return $false
            }
        }

        return $true

    }

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

    #
    # Convert the Permissions into an ACL Token

    $params = @{
        Permissions         = $Permissions
        SecurityNamespace   = $SecurityNamespace
        isInherited         = $isInherited
        OrganizationName    = $OrganizationName
        TokenName           = $(($identifierArr | ForEach-Object { "vstfs:///Classification/Node/{0}" -f $_ }) -join ':')
    }

    # Convert the Permissions to an ACL Token
    $ReferenceACLs = ConvertTo-ACL @params

    # Compare the Reference ACLs to the Difference ACLs
    $compareResult = Test-ACLListforChanges -ReferenceACLs $ReferenceACLs -DifferenceACLs $DifferenceACLs
    $results.propertiesChanged = $compareResult.propertiesChanged
    $results.status = [DSCGetSummaryState]::"$($compareResult.status)"
    $results.reason = $compareResult.reason

    Write-Verbose "[Get-AzDoAreaPermission] ACL Token converted."
    Write-Verbose "[Get-AzDoAreaPermission] ACL Token Comparison Result: $($results.status)"

    # Export the ACL List to a file
    $results.ReferenceACLs = $ReferenceACLs
    $results.DifferenceACLs = $DifferenceACLs

    # Write
    Write-Verbose "[Get-AzDoAreaPermission] Result Status: $($results.status)"
    Write-Verbose "[Get-AzDoAreaPermission] Returning Group Result."

    # Return the Group Result
    return $results

}