#Requires -Version 5.1 [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][guid]$SubscriptionId, [Parameter(Mandatory)][string]$ResourceGroupName, [Parameter(Mandatory)] [ValidatePattern('^[a-z0-9]{3,24}$')] [string]$StorageAccountName, [ValidatePattern('^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$')] [string]$FsLogixProfilesShareName = 'profiles', [ValidatePattern('^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$')] [string]$RedirectedFoldersShareName = 'redirected', [string]$DomainController = $env:COMPUTERNAME, [string]$AzureFilesOuName = 'AzureFilesConfig', [string]$StudentOuName = 'Alumnos', [string]$ProfessorOuName = 'Docentes', [string]$AdministrativeOuName = 'Administrativos', [string]$LaboratoryOuName = 'Laboratorio', [string]$StudentGroupName = 'SGU-Alumnos', [string]$ProfessorGroupName = 'SGU-Docentes', [string]$AdministrativeGroupName = 'SGU-Administrativos', [string]$StudentGpoName = 'SGU - AL redirected folders', [string]$StaffGpoName = 'SGU - AD-DO FSLogix profiles', [ValidateRange(1024, 1048576)] [int]$FsLogixProfileSizeMiB = 30000, [string]$AzFilesHybridModulePath, [switch]$UseDeviceAuthentication, [switch]$DeleteExistingStaffLocalProfiles ) $ErrorActionPreference = 'Stop' function Assert-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this script from an elevated Windows PowerShell 5.1 session on the SGU domain controller.' } } function Import-SguAzFilesHybrid { param([string]$ModulePath) if ($ModulePath) { if (-not (Test-Path -LiteralPath $ModulePath)) { throw "AzFilesHybridModulePath does not exist: $ModulePath" } $resolvedModule = if (Test-Path -LiteralPath $ModulePath -PathType Container) { Get-ChildItem -LiteralPath $ModulePath -Recurse -File | Where-Object Name -in @('AzFilesHybrid.psd1', 'AzFilesHybrid.psm1') | Sort-Object @{ Expression = { $_.Extension -eq '.psd1' }; Descending = $true }, FullName | Select-Object -First 1 } else { Get-Item -LiteralPath $ModulePath } if (-not $resolvedModule) { throw "AzFilesHybrid.psd1 or AzFilesHybrid.psm1 was not found beneath $ModulePath." } Import-Module -Name $resolvedModule.FullName -Force -ErrorAction Stop } else { Import-Module -Name AzFilesHybrid -Force -ErrorAction Stop } $joinCommand = Get-Command Join-AzStorageAccount -ErrorAction SilentlyContinue if (-not $joinCommand) { $joinCommand = Get-Command Join-AzStorageAccountForAuth -ErrorAction SilentlyContinue } if (-not $joinCommand) { throw 'AzFilesHybrid did not expose Join-AzStorageAccount. Install the current Microsoft AzFilesHybrid module and retry.' } return $joinCommand } function Get-SguStorageSamAccountName { param([Parameter(Mandatory)][string]$StorageName) if ($StorageName.Length -le 20) { return $StorageName } $sha256 = [Security.Cryptography.SHA256]::Create() try { $hash = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($StorageName)) $suffix = ([BitConverter]::ToString($hash) -replace '-', '').Substring(0, 15).ToLowerInvariant() return "sgufs$suffix" } finally { $sha256.Dispose() } } function Ensure-SguOrganizationalUnit { param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Server ) $escapedName = $Name.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29') $ou = Get-ADOrganizationalUnit -LDAPFilter "(ou=$escapedName)" -SearchBase $Path ` -SearchScope OneLevel -Server $Server -ErrorAction Stop | Select-Object -First 1 if (-not $ou -and $PSCmdlet.ShouldProcess("OU=$Name,$Path", 'Create Azure Files identity OU')) { New-ADOrganizationalUnit -Name $Name -Path $Path -ProtectedFromAccidentalDeletion $true ` -Server $Server | Out-Null $ou = Get-ADOrganizationalUnit -Identity "OU=$Name,$Path" -Server $Server } if (-not $ou) { throw "The organizational unit OU=$Name,$Path does not exist." } return $ou } function Ensure-SguGpoLink { param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$TargetOuDn, [Parameter(Mandatory)][string]$DomainName, [Parameter(Mandatory)][string]$Server ) $gpo = Get-GPO -Name $Name -Domain $DomainName -Server $Server -ErrorAction SilentlyContinue if (-not $gpo -and $PSCmdlet.ShouldProcess($Name, 'Create user-roaming GPO')) { $gpo = New-GPO -Name $Name -Domain $DomainName -Server $Server } if (-not $gpo) { throw "The GPO '$Name' does not exist and was not created." } $link = @(Get-GPInheritance -Target $TargetOuDn -Domain $DomainName -Server $Server).GpoLinks | Where-Object DisplayName -eq $Name | Select-Object -First 1 $linkEnabled = $link -and ($link.Enabled -eq $true -or [string]$link.Enabled -eq 'Yes') if (-not $link -and $PSCmdlet.ShouldProcess($TargetOuDn, "Link and enable '$Name'")) { New-GPLink -Name $Name -Target $TargetOuDn -Domain $DomainName -Server $Server ` -LinkEnabled Yes | Out-Null } elseif ($link -and -not $linkEnabled -and $PSCmdlet.ShouldProcess($TargetOuDn, "Enable the '$Name' link")) { Set-GPLink -Name $Name -Target $TargetOuDn -Domain $DomainName -Server $Server ` -LinkEnabled Yes | Out-Null } return $gpo } function Set-SguGpoRegistryValue { param( [Parameter(Mandatory)][string]$GpoName, [Parameter(Mandatory)][string]$DomainName, [Parameter(Mandatory)][string]$Server, [Parameter(Mandatory)][string]$Key, [Parameter(Mandatory)][string]$ValueName, [Parameter(Mandatory)][ValidateSet('DWord', 'String', 'ExpandString')][string]$Type, [Parameter(Mandatory)]$Value ) if ($PSCmdlet.ShouldProcess("$GpoName :: $Key\\$ValueName", "Set $Type policy value")) { Set-GPRegistryValue -Name $GpoName -Domain $DomainName -Server $Server ` -Key $Key -ValueName $ValueName -Type $Type -Value $Value | Out-Null } } function Get-SguUnusedDriveName { $used = @(Get-PSDrive -PSProvider FileSystem | Select-Object -ExpandProperty Name) foreach ($name in @('Z', 'Y', 'X', 'W', 'V')) { if ($used -notcontains $name) { return $name } } throw 'No temporary drive letter is available for configuring Azure Files ACLs.' } function Set-SguAzureFileRootAcl { param( [Parameter(Mandatory)][string]$UncPath, [Parameter(Mandatory)][PSCredential]$Credential, [Parameter(Mandatory)][Security.Principal.SecurityIdentifier]$DomainAdminsSid, [Parameter(Mandatory)][Security.Principal.SecurityIdentifier[]]$ContributorSids, [Security.AccessControl.FileSystemRights]$ContributorRights = [Security.AccessControl.FileSystemRights]::Modify ) $driveName = Get-SguUnusedDriveName try { New-PSDrive -Name $driveName -PSProvider FileSystem -Root $UncPath ` -Credential $Credential -Scope Script -ErrorAction Stop | Out-Null $rootPath = "${driveName}:\" $acl = [Security.AccessControl.DirectorySecurity]::new() $acl.SetAccessRuleProtection($true, $false) $acl.SetOwner($DomainAdminsSid) $allow = [Security.AccessControl.AccessControlType]::Allow $containerAndObject = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' $noneInheritance = [Security.AccessControl.InheritanceFlags]::None $nonePropagation = [Security.AccessControl.PropagationFlags]::None $inheritOnly = [Security.AccessControl.PropagationFlags]::InheritOnly $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') $creatorOwnerSid = [Security.Principal.SecurityIdentifier]::new('S-1-3-0') foreach ($administratorSid in @($systemSid, $DomainAdminsSid)) { $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( $administratorSid, [Security.AccessControl.FileSystemRights]::FullControl, $containerAndObject, $nonePropagation, $allow)) } $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( $creatorOwnerSid, [Security.AccessControl.FileSystemRights]::Modify, $containerAndObject, $inheritOnly, $allow)) foreach ($contributorSid in $ContributorSids) { $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( $contributorSid, $ContributorRights, $noneInheritance, $nonePropagation, $allow)) } Set-Acl -LiteralPath $rootPath -AclObject $acl -ErrorAction Stop } finally { Remove-PSDrive -Name $driveName -Scope Script -Force -ErrorAction SilentlyContinue } } Assert-Administrator if ($FsLogixProfilesShareName.Contains('--') -or $RedirectedFoldersShareName.Contains('--')) { throw 'Azure Files share names cannot contain consecutive hyphens.' } if ($FsLogixProfilesShareName -eq $RedirectedFoldersShareName) { throw 'The profile-container and redirected-folder shares must have different names.' } Import-Module ActiveDirectory -ErrorAction Stop Import-Module GroupPolicy -ErrorAction Stop foreach ($azureModule in @('Az.Accounts', 'Az.Storage')) { try { Import-Module $azureModule -ErrorAction Stop } catch { throw "The current $azureModule module is required on the domain controller. Install Azure PowerShell and retry. $($_.Exception.Message)" } } $joinStorageCommand = Import-SguAzFilesHybrid -ModulePath $AzFilesHybridModulePath $domain = Get-ADDomain -Server $DomainController $baseDn = $domain.DistinguishedName $domainName = $domain.DNSRoot $usersOuDn = "OU=Usuarios-SGU,$baseDn" $studentOuDn = "OU=$StudentOuName,$usersOuDn" $professorOuDn = "OU=$ProfessorOuName,$usersOuDn" $administrativeOuDn = "OU=$AdministrativeOuName,$usersOuDn" $laboratoryOuDn = "OU=$LaboratoryOuName,$baseDn" foreach ($requiredOu in @($studentOuDn, $professorOuDn, $administrativeOuDn, $laboratoryOuDn)) { Get-ADOrganizationalUnit -Identity $requiredOu -Server $DomainController -ErrorAction Stop | Out-Null } $studentGroup = Get-ADGroup -Identity "CN=$StudentGroupName,$studentOuDn" -Server $DomainController $professorGroup = Get-ADGroup -Identity "CN=$ProfessorGroupName,$professorOuDn" -Server $DomainController $administrativeGroup = Get-ADGroup -Identity "CN=$AdministrativeGroupName,$administrativeOuDn" ` -Server $DomainController $domainAdminsSid = [Security.Principal.SecurityIdentifier]::new("$($domain.DomainSID.Value)-512") $azureFilesOu = Ensure-SguOrganizationalUnit -Name $AzureFilesOuName -Path $baseDn -Server $DomainController $azureContext = Get-AzContext -ErrorAction SilentlyContinue if (-not $azureContext -or $azureContext.Subscription.Id -ne $SubscriptionId.Guid) { $connectParameters = @{} if ($UseDeviceAuthentication) { $connectParameters.UseDeviceAuthentication = $true } Connect-AzAccount @connectParameters | Out-Null } Set-AzContext -SubscriptionId $SubscriptionId.Guid | Out-Null $storageAccount = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName ` -Name $StorageAccountName -ErrorAction Stop $fileEndpointHost = ([uri]$storageAccount.PrimaryEndpoints.File).Host if (-not $fileEndpointHost) { throw "Azure did not return a file endpoint for $StorageAccountName." } $directoryService = [string]$storageAccount.AzureFilesIdentityBasedAuth.DirectoryServiceOptions if ($directoryService -and $directoryService -ne 'None' -and $directoryService -ne 'AD') { throw "Storage account $StorageAccountName already uses the incompatible Azure Files identity source '$directoryService'." } if ($directoryService -ne 'AD') { if ($PSCmdlet.ShouldProcess($StorageAccountName, "Join Azure Files to $domainName with AES-256 Kerberos")) { $requestedSamAccountName = Get-SguStorageSamAccountName -StorageName $StorageAccountName $joinParameters = @{ ResourceGroupName = $ResourceGroupName StorageAccountName = $StorageAccountName SamAccountName = $requestedSamAccountName DomainAccountType = 'ComputerAccount' OrganizationalUnitDistinguishedName = $azureFilesOu.DistinguishedName } & $joinStorageCommand @joinParameters $storageAccount = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName ` -Name $StorageAccountName -ErrorAction Stop } } $directoryService = [string]$storageAccount.AzureFilesIdentityBasedAuth.DirectoryServiceOptions if ($directoryService -ne 'AD') { throw "Azure Files identity authentication is '$directoryService', not AD. The domain join did not complete." } $activeDirectoryProperties = $storageAccount.AzureFilesIdentityBasedAuth.ActiveDirectoryProperties if ([string]$activeDirectoryProperties.DomainName -ne $domainName) { throw "Storage account $StorageAccountName is joined to $($activeDirectoryProperties.DomainName), not $domainName." } $storageSamAccountName = [string]$activeDirectoryProperties.SamAccountName if (-not $storageSamAccountName) { $storageSamAccountName = $StorageAccountName } $storageComputer = Get-ADComputer -Identity "${storageSamAccountName}$" ` -Server $DomainController -ErrorAction Stop if ($PSCmdlet.ShouldProcess($storageComputer.DistinguishedName, 'Require AES-256 Kerberos and prevent an unattended storage identity password expiry')) { Set-ADComputer -Identity $storageComputer -Server $DomainController ` -KerberosEncryptionType AES256 -PasswordNeverExpires $true } if ($PSCmdlet.ShouldProcess($StorageAccountName, 'Grant authenticated AD identities the Azure Files SMB contributor default share permission')) { $storageAccount = Set-AzStorageAccount -ResourceGroupName $ResourceGroupName ` -Name $StorageAccountName ` -DefaultSharePermission StorageFileDataSmbShareContributor } $privateAddresses = @(Resolve-DnsName -Name $fileEndpointHost -Type A -ErrorAction Stop | Where-Object IPAddress | Select-Object -ExpandProperty IPAddress) if ($privateAddresses.Count -eq 0 -or @($privateAddresses | Where-Object { $_ -match '^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)' }).Count -eq 0) { throw "$fileEndpointHost did not resolve to a private endpoint. Verify the privatelink.file.core.windows.net VNet link and the DC Azure DNS forwarder." } if (-not (Test-NetConnection -ComputerName $fileEndpointHost -Port 445 -InformationLevel Quiet)) { throw "The domain controller cannot reach $fileEndpointHost on TCP 445 through the private endpoint." } $storageKey = @(Get-AzStorageAccountKey -ResourceGroupName $ResourceGroupName ` -Name $StorageAccountName -ErrorAction Stop | Where-Object KeyName -eq 'key1' | Select-Object -First 1).Value if (-not $storageKey) { throw "Azure did not return key1 for $StorageAccountName; it is required only to set the initial root ACLs." } $storageCredential = [PSCredential]::new( "Azure\$StorageAccountName", (ConvertTo-SecureString -String $storageKey -AsPlainText -Force)) $profilesSharePath = "\\$fileEndpointHost\$FsLogixProfilesShareName" $redirectedFoldersSharePath = "\\$fileEndpointHost\$RedirectedFoldersShareName" try { if ($PSCmdlet.ShouldProcess($profilesSharePath, 'Apply isolated FSLogix root ACLs')) { Set-SguAzureFileRootAcl -UncPath $profilesSharePath -Credential $storageCredential ` -DomainAdminsSid $domainAdminsSid ` -ContributorSids @($professorGroup.SID, $administrativeGroup.SID) } if ($PSCmdlet.ShouldProcess($redirectedFoldersSharePath, 'Apply isolated student-folder root ACLs')) { $studentRootRights = [Security.AccessControl.FileSystemRights]::CreateDirectories -bor [Security.AccessControl.FileSystemRights]::ListDirectory -bor [Security.AccessControl.FileSystemRights]::ReadAttributes -bor [Security.AccessControl.FileSystemRights]::ReadExtendedAttributes -bor [Security.AccessControl.FileSystemRights]::ReadPermissions -bor [Security.AccessControl.FileSystemRights]::Traverse -bor [Security.AccessControl.FileSystemRights]::Synchronize Set-SguAzureFileRootAcl -UncPath $redirectedFoldersSharePath -Credential $storageCredential ` -DomainAdminsSid $domainAdminsSid -ContributorSids @($studentGroup.SID) ` -ContributorRights $studentRootRights } } finally { $storageKey = $null $storageCredential = $null } $studentGpo = Ensure-SguGpoLink -Name $StudentGpoName -TargetOuDn $studentOuDn ` -DomainName $domainName -Server $DomainController $userShellFoldersKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders' Set-SguGpoRegistryValue -GpoName $studentGpo.DisplayName -DomainName $domainName ` -Server $DomainController -Key $userShellFoldersKey -ValueName 'Desktop' ` -Type ExpandString -Value "$redirectedFoldersSharePath\%USERNAME%\Desktop" Set-SguGpoRegistryValue -GpoName $studentGpo.DisplayName -DomainName $domainName ` -Server $DomainController -Key $userShellFoldersKey -ValueName 'Personal' ` -Type ExpandString -Value "$redirectedFoldersSharePath\%USERNAME%\Documents" Set-SguGpoRegistryValue -GpoName $studentGpo.DisplayName -DomainName $domainName ` -Server $DomainController -Key 'HKCU\Software\Policies\Microsoft\Windows\NetCache' ` -ValueName 'DisableFRAdminPin' -Type DWord -Value 1 $staffGpo = Ensure-SguGpoLink -Name $StaffGpoName -TargetOuDn $laboratoryOuDn ` -DomainName $domainName -Server $DomainController $fsLogixRoot = 'HKLM\SOFTWARE\FSLogix\Profiles' Set-SguGpoRegistryValue -GpoName $staffGpo.DisplayName -DomainName $domainName ` -Server $DomainController -Key $fsLogixRoot -ValueName 'Enabled' -Type DWord -Value 0 $fsLogixValues = [ordered]@{ Enabled = @{ Type = 'DWord'; Value = 1 } DeleteLocalProfileWhenVHDShouldApply = @{ Type = 'DWord' Value = if ($DeleteExistingStaffLocalProfiles) { 1 } else { 0 } } FlipFlopProfileDirectoryName = @{ Type = 'DWord'; Value = 1 } IsDynamic = @{ Type = 'DWord'; Value = 1 } LockedRetryCount = @{ Type = 'DWord'; Value = 3 } LockedRetryInterval = @{ Type = 'DWord'; Value = 15 } ProfileType = @{ Type = 'DWord'; Value = 0 } ReAttachIntervalSeconds = @{ Type = 'DWord'; Value = 15 } ReAttachRetryCount = @{ Type = 'DWord'; Value = 3 } SizeInMBs = @{ Type = 'DWord'; Value = $FsLogixProfileSizeMiB } VHDLocations = @{ Type = 'String'; Value = $profilesSharePath } VolumeType = @{ Type = 'String'; Value = 'VHDX' } } foreach ($staffGroup in @($professorGroup, $administrativeGroup)) { $objectSpecificKey = "$fsLogixRoot\ObjectSpecific\$($staffGroup.SID.Value)" foreach ($setting in $fsLogixValues.GetEnumerator()) { Set-SguGpoRegistryValue -GpoName $staffGpo.DisplayName -DomainName $domainName ` -Server $DomainController -Key $objectSpecificKey -ValueName $setting.Key ` -Type $setting.Value.Type -Value $setting.Value.Value } } [pscustomobject]@{ StorageAccountName = $StorageAccountName FileEndpoint = $fileEndpointHost PrivateEndpointAddresses = $privateAddresses DirectoryService = $directoryService KerberosEncryption = 'AES256' StorageIdentity = $storageComputer.DistinguishedName StorageIdentityPasswordNeverExpires = $true ProfilesSharePath = $profilesSharePath RedirectedFoldersSharePath = $redirectedFoldersSharePath StudentPolicy = $studentGpo.DisplayName StaffPolicy = $staffGpo.DisplayName StudentBehavior = 'Local non-authoritative profile; Documents and Desktop redirected without Offline Files pinning.' StaffBehavior = 'FSLogix VHDX profile container for SGU-Docentes and SGU-Administrativos only.' ExistingStaffLocalProfilesDeleted = [bool]$DeleteExistingStaffLocalProfiles }