Scripts/Sync-NFSv3ExtendedGroupsFromAD.ps1

<#
    .SYNOPSIS
    Synchronizes NFSv3 extended groups from Active Directory (AD).
 
    .DESCRIPTION
    This command syncs NFSv3 extended groups with the user and group data fetched from an AD server.
 
    .PARAMETER ResourceGroupName
    The name of the Resource Group in Azure where the storage account resides.
 
    .PARAMETER StorageAccountName
    The name of the Azure storage account.
 
    .PARAMETER ADServerIP
    The IP address of the AD server.
 
    .PARAMETER ADSearchBase
    The search base for querying Active Directory.
 
    .PARAMETER UserFilter
    Optional filter for selecting users in Active Directory.
 
    .PARAMETER GroupFilter
    Optional filter for selecting groups in Active Directory.
 
    .PARAMETER UidFilePath
    Optional file path for specifying UIDs of users which needs to be synced.
    The file should contain one UID per line.
 
    .PARAMETER ADUserName
    Optional username for authenticating with Active Directory.
 
    .PARAMETER ADCredential
    Optional PSCredential object for authenticating with Active Directory.
 
    .EXAMPLE
    Sync-NFSv3ExtendedGroupsFromAD -ResourceGroupName "MyRG" -StorageAccountName "MyStorage" -ADServerIP "192.168.1.1" -ADSearchBase "dc=mydomain,dc=com"
 
    .NOTES
    Written by: [Azure Blob NFS]
    Date: [October 10, 2024]
#>

function Sync-NFSv3ExtendedGroupsFromAD {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string]$ResourceGroupName,

        [Parameter(Mandatory=$true)]
        [string]$StorageAccountName,

        [Parameter(Mandatory=$true)]
        [string]$ADServerIP,

        [Parameter(Mandatory=$true)]
        [string]$ADSearchBase,

        [Parameter(Mandatory=$false)]
        [string]$UserFilter = "uidNumber -ge 0",

        [Parameter(Mandatory=$false)]
        [string]$GroupFilter = "gidNumber -ge 0",

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

        [Parameter(Mandatory=$false, ParameterSetName = 'UserNameSet')]
        [string]$ADUserName,

        [Parameter(Mandatory=$false, ParameterSetName = 'PsCredSet')]
        [PSCredential]$ADCredential
    )

    # logMessage is written in this way to keep the indentation correct in log file.
    $logMessage = @"
    Sync-NFSv3ExtendedGroupsFromAD started,
        ResourceGroupName : $ResourceGroupName,
        StorageAccountName : $StorageAccountName,
        ADServerIP : $ADServerIP,
        ADSearchBase : $ADSearchBase,
        UserFilter : $UserFilter,
        GroupFilter : $GroupFilter
"@


    if ($UidFilePath)     { $logMessage += ",`n UidFilePath : $UidFilePath" }
    if ($ADUserName)     { $logMessage += ",`n ADUserName : $ADUserName" }
    if ($ADCredential)     { $logMessage += ",`n ADUserName from PSCredential: $($ADCredential.UserName)" }

    Write-NFSv3ExtendedGroupsLog $logMessage
    
    if ($null -eq $ADCredential) {
        # Get admin username and password of AD server.
        if ($null -eq $ADUserName) {
            $ADUserName = Read-Host "Enter User Name for Active Directory Administrator [$ADServerIP]:"
        }
        $adPassword = Read-Host "Password:" -AsSecureString

        # Create a PSCredential object
        $ADCredential = New-Object System.Management.Automation.PSCredential($ADUserName, $adPassword)
    }

    # Get all the users from Active Directory.
    $adUsers = Get-ADUser -Credential $ADCredential -Filter $UserFilter -SearchBase $ADSearchBase -Properties uidNumber,gidNumber,memberOf -Server $ADServerIP

    # Get all the groups from Active Directory.
    $adGroups = Get-ADGroup -Credential $ADCredential -Filter $GroupFilter -SearchBase $ADSearchBase -Properties gidNumber -Server $ADServerIP

    # Initialize hashtable to store gidNumber of AD groups with DistinguishedName as key.
    $groupDNToGidMap = @{}
    
    #
    # Populate groupDNToGidMap hashtable.
    #
    # TODO: Currently $group.gidNumber is making subsequent calls to AD server to fetch gidNumber.
    # Fix this to improve performance.
    #
    foreach ( $group in $adGroups ) {
        if ( $null -ne $group.gidNumber ) {
            $groupDNToGidMap[$group.DistinguishedName] = $group.gidNumber
        }
    }

    # Get all the existing LocalUsers for this account.
    $localUsers = Get-AzStorageLocalUser -ResourceGroupName $ResourceGroupName -StorageAccountName $StorageAccountName -IncludeNFSv3

    # Initialize hashtable to store LocalUser object with name as key.
    $localUserMap = @{}

    # Populate localUserMap hashtable.
    foreach ( $user in $localUsers ) {
        $localUserMap[$user.Name] = $user
    }

    # Get the uids of localusers which needs to be updated from provided file.
    [int[]]$uidsFromFile = @()
    
    if (-not [string]::IsNullOrEmpty($UidFilePath)) {
        $uidsFromFile = Get-Content -Path $UidFilePath | ForEach-Object {
            try {
                [int]$_ 
            }
            catch {
                Write-NFSv3ExtendedGroupsLog -Message "Invalid Uid entry [$_] in $UidFilePath" -LogLevel "ERROR"
            }
        } | Where-Object { $_ -is [int] } # Ensure only integers are kept.
    }

    $logScript = "$PSScriptRoot\Write-NFSv3ExtendedGroupsLog.ps1"
    $jobList = [System.Collections.Generic.List[System.Management.Automation.Job]]::new()
    
    #
    # There is a limit of 10 Write Ops per second by using ARM with Azure Storage. To avoid requests getting
    # throttled because of this limit, keep maxParallelJobs less than or equal to 10.
    #
    # https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/request-limits-and-throttling#storage-throttling
    #
    $maxParallelJobs = 10
    $usersSize = $adUsers.Length
    $chunkUsersSize = [Math]::Ceiling($usersSize / $maxParallelJobs)
    $numOfIterations = [Math]::Min($maxParallelJobs, $usersSize)

    for ( $i = 0; $i -lt $numOfIterations; $i++ ) {
        # Slice the adUsers array into equal size of chunks each of which will be handled by separate processes.
        $startIndex = $i * $chunkUsersSize
        $endIndex = [Math]::Min($startIndex + $chunkUsersSize - 1, $usersSize - 1)
        $chunkUsers = $adUsers[$startIndex..$endIndex]
        
        # Start a new background job for each chunk of adUsers.
        $job = Start-Job -ScriptBlock {
            param (
                $chunkUsers,
                $groupDNToGidMap,
                $localUserMap,
                $ResourceGroupName,
                $StorageAccountName,
                $logScript,
                $uidsFromFile
            )
            
            # Dot-Source internal logging function cause this background job will create different runspace.
            $Global:NFSv3ExtendedGroupsLogFile = $using:NFSv3ExtendedGroupsLogFile
            . $logScript

            #
            # Convert uidsFromFile to HashMap for O(1) search. Can not pass complex HashMap objects to background
            # jobs, therefore it was initially stored in an array.
            #
            $uidsFromFileSet = [System.Collections.Generic.HashSet[int]]::new()
            foreach ( $uid in $uidsFromFile ) {
                $uidsFromFileSet.Add($uid)
            }

            foreach ($user in $chunkUsers) {
                # Don't process if uidNumber of user is null.
                if ( $null -eq $user.uidNumber ) {
                    Write-NFSv3ExtendedGroupsLog "UidNumber is null for $user"
                    continue
                }

                # Get uidNumber, gidNumber and group membership info from $user object.
                $userGroups = $user.memberOf
                $uidNumber = $user.uidNumber
                $gidNumber = $user.gidNumber
                $gidArray = New-Object System.Collections.ArrayList

                #
                # If user has provided file to tell which Uids to upload,
                # Verify this Uid is present in that list and skip if not.
                #
                if ( $uidsFromFileSet.Count -gt 0 ) {
                    if ( -not $uidsFromFileSet.Contains($uidNumber) ) {
                        # If this uid is not part of list provided by user, skip.
                        Write-NFSv3ExtendedGroupsLog "$uidNumber is not present in the file, skipping" | Out-Null
                        continue
                    }
                }

                # Get the gidNumber of these groups.
                foreach ($group in $userGroups) {
                    $groupGidNumber = $groupDNToGidMap[$group]
                    $gidArray.Add($groupGidNumber[0]) | Out-Null
                }

                # Convert ArrayList to Int32 array.
                [int32[]]$extendedGroupsArray = $gidArray.ToArray([int32]) | Sort-Object

                # NFSv3 localuser have name in "nfsv3_<uid>" format.
                $localUserName = "nfsv3_$($uidNumber)"

                # Check if local user with same uidNumber exist in localuser database.
                if ( $localUserMap.ContainsKey($localUserName) ) {
                    # Sort extended group array before comparing.
                    $existingLocalUser = $localUserMap[$localUserName]
                    $existingExtendedGroups = $existingLocalUser.ExtendedGroups | Sort-Object
                    $difference = Compare-Object -ReferenceObject $existingExtendedGroups -DifferenceObject $extendedGroupsArray

                    # Don't PUT local user if database is already updated.
                    if ($difference.Count -eq 0) {
                        Write-NFSv3ExtendedGroupsLog "ExtendedGroups already up to date for Uid $uidNumber" | Out-Null
                        continue
                    }
                }

                # Create localuser.
                Set-AzStorageLocalUser -ResourceGroupName $ResourceGroupName -StorageAccountName $StorageAccountName -UserName $localUserName -IsNfSv3Enabled $true -ExtendedGroups $extendedGroupsArray
            }
        } -ArgumentList $chunkUsers, $groupDNToGidMap, $localUserMap, $ResourceGroupName, $StorageAccountName, $logScript, $uidsFromFile
        
        $jobList.Add($job)
    }

    # Wait for all jobs to complete.
    $jobList | ForEach-Object { $_ | Wait-Job } | Out-Null

    # Clean up completed jobs
    $jobList | ForEach-Object { $_ | Remove-Job }

    Write-NFSv3ExtendedGroupsLog "Sync-NFSv3ExtendedGroupsFromAD completed"
}