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

Function Get-AzDoProjectPermission
{
    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSObject[]])]
    param (
        [Parameter(Mandatory = $true)][string]$ProjectName,
        [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-AzDoProjectPermission] Started."

    $SecurityNamespace = 'Project'
    $OrganizationName  = (Get-AzDoOrganizationName)

    $getResult = @{
        Ensure            = [Ensure]::Absent
        propertiesChanged = @()
        project           = $ProjectName
        groupName         = $GroupName
        status            = $null
        reason            = $null
    }

    $projectCache = Get-CacheItem -Key $ProjectName -Type 'LiveProjects'
    if (-not $projectCache)
    {
        Write-Verbose "[Get-AzDoProjectPermission] 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' }
    }
    if (-not $projectCache)
    {
        $getResult.status = [DSCGetSummaryState]::Error
        $getResult.reason = "Project not found: $ProjectName"
        return $getResult
    }

    # We build the ACL token from the project GUID. If the id is missing we CANNOT build a valid
    # token — querying with a malformed token would silently return empty and be misread as
    # "no permissions". Fail loudly here instead of guessing.
    if (-not $projectCache.id)
    {
        $getResult.status = [DSCGetSummaryState]::Error
        $getResult.reason = "Could not resolve project id for '$ProjectName'; cannot build ACL token."
        return $getResult
    }

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

    $getResult.namespace = $namespace

    # Build the project's ACL token once from the resolved GUID; reused below for both the
    # client-side filter and the reference ACL (single source of truth).
    # NOTE: server-side filtering via Get-DevOpsACL -Token was attempted but the accesscontrollists
    # '?token=' query returns empty for this namespace (exact token-encoding format unverified), so
    # we filter client-side. The expensive part — per-ACE identity resolution in ConvertTo-FormattedACL
    # — is still skipped for non-target ACLs, which is where the ~40x speed-up comes from.
    $projectToken = '$PROJECT:vstfs:///Classification/TeamProject/{0}' -f $projectCache.id

    $DevOpsACLs = Get-DevOpsACL -OrganizationName $OrganizationName -SecurityDescriptorId $namespace.namespaceId
    if (-not $DevOpsACLs)
    {
        $getResult.status = [DSCGetSummaryState]::Error
        $getResult.reason = "No ACLs found."
        return $getResult
    }

    # Filter the raw ACLs to just the target project's token BEFORE the expensive formatting.
    # Use exact-match (-eq) so child tokens (e.g. BoardGroup sub-paths) are not included.
    $DevOpsACLs = $DevOpsACLs | Where-Object { $_.token -eq $projectToken }

    # 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.
    $DifferenceACLs = @($DevOpsACLs | ConvertTo-FormattedACL -SecurityNamespace $SecurityNamespace -OrganizationName $OrganizationName)

    # The reference token must use the project GUID, exactly like the live ACL token. Using the
    # project *name* fails the project token regex (it does not allow underscores) and resolves to
    # 'ProjectUnknown', so reference and difference never match.
    $params = @{
        Permissions       = $Permissions
        SecurityNamespace = $SecurityNamespace
        isInherited       = $isInherited
        OrganizationName  = $OrganizationName
        TokenName         = $projectToken
    }

    # 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 | Where-Object { $_.token.Type -ne 'ProjectUnknown' })

    # The Project namespace has protected system-group ACEs (Project Admins, Contributors, etc.)
    # that Azure DevOps auto-creates and cannot remove. Comparing the full ACL count would always
    # fail. Instead, filter DifferenceACLs.aces to only the identities we are managing so that
    # Test-ACLListforChanges compares only the relevant ACE(s).
    if ($ReferenceACLs.Count -gt 0 -and $DifferenceACLs.Count -gt 0) {
        $desiredOriginIds = @($ReferenceACLs[0].aces | ForEach-Object { $_.Identity.value.originId } | Where-Object { $_ })
        if ($desiredOriginIds.Count -gt 0) {
            $DifferenceACLs[0]['aces'] = @($DifferenceACLs[0].aces | Where-Object { $_.Identity.value.originId -in $desiredOriginIds })
        } else {
            # No desired identities (empty permissions) — treat live as having no relevant ACEs.
            $DifferenceACLs[0]['aces'] = @()
        }
    }

    # The Project namespace's START_BUILD (bit 32) Deny is platform-managed for at least one identity
    # here, not something our Set ever requests either way: confirmed across three independent live
    # Set runs that a byte-correct payload (wire-captured), even preceded by an explicit DELETE of the
    # whole ACE, always results in a live Deny that includes this extra bit a few seconds later -
    # despite our payload never mentioning it. Identities that explicitly want it (e.g. Release
    # Managers requests START_BUILD: Allow) are unaffected since Allow/Deny are compared separately.
    # Strip it from the live side only, so we stop fighting a bit we don't manage - same rationale as
    # ConvertTo-ACETokenList's reserved-bit stripping for Process/ReadProcessPermissions.
    foreach ($ace in $DifferenceACLs[0].aces)
    {
        if ($ace.Permissions.Deny)
        {
            $ace.Permissions.Deny = @($ace.Permissions.Deny | Where-Object { $_.bit -ne 32 })
        }
    }

    $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
}