Files
SGU-CredentialProvider/scripts/Enable-SguAzureUserRoaming.ps1
T

440 lines
20 KiB
PowerShell

#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',
[ValidateSet('Private', 'Public')]
[string]$EndpointAccess = 'Private',
[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 15) {
return $StorageName
}
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$hash = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($StorageName))
$suffix = ([BitConverter]::ToString($hash) -replace '-', '').Substring(0, 10).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
}
$endpointAddresses = @(Resolve-DnsName -Name $fileEndpointHost -Type A -ErrorAction Stop |
Where-Object IPAddress | Select-Object -ExpandProperty IPAddress)
if ($endpointAddresses.Count -eq 0) {
throw "$fileEndpointHost did not resolve to an IPv4 address."
}
$privateAddresses = @($endpointAddresses | Where-Object {
$_ -match '^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)'
})
if ($EndpointAccess -eq 'Private' -and $privateAddresses.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 using the selected $EndpointAccess 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"
$perUserRootRights = [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
try {
if ($PSCmdlet.ShouldProcess($profilesSharePath, 'Apply isolated FSLogix root ACLs')) {
Set-SguAzureFileRootAcl -UncPath $profilesSharePath -Credential $storageCredential `
-DomainAdminsSid $domainAdminsSid `
-ContributorSids @($professorGroup.SID, $administrativeGroup.SID) `
-ContributorRights $perUserRootRights
}
if ($PSCmdlet.ShouldProcess($redirectedFoldersSharePath, 'Apply isolated student-folder root ACLs')) {
Set-SguAzureFileRootAcl -UncPath $redirectedFoldersSharePath -Credential $storageCredential `
-DomainAdminsSid $domainAdminsSid -ContributorSids @($studentGroup.SID) `
-ContributorRights $perUserRootRights
}
}
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
EndpointAccess = $EndpointAccess
EndpointAddresses = $endpointAddresses
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
}