diff --git a/scripts/New-M365Employee.ps1 b/scripts/New-M365Employee.ps1 index 8928008..56d8956 100644 --- a/scripts/New-M365Employee.ps1 +++ b/scripts/New-M365Employee.ps1 @@ -1,211 +1,927 @@ #requires -Version 7.2 + <# .SYNOPSIS Creates a cloud-only Microsoft 365 employee account. + .DESCRIPTION -Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Install-Module ExchangeOnlineManagement -Scope CurrentUser # For distribution/mail-enabled security groups -Run with an administrator authorized to create users, assign licenses and manage the target groups. -Do not run with a transcript: the temporary password is displayed on screen. +Creates a Microsoft 365 employee account, assigns a Business Premium +license, and adds the employee to designated security and distribution +groups. + +Required PowerShell modules: +- Microsoft.Graph.Authentication +- ExchangeOnlineManagement + +Missing modules are installed automatically for the current user. + +The script validates that Microsoft Graph and Exchange Online are +connected to the same tenant before creating the employee. + +Do not run with a transcript: the temporary password is displayed +on screen. + +.PARAMETER TenantId +Optional Microsoft Entra tenant ID. Recommended when administering +multiple Microsoft 365 tenants. + +.PARAMETER UsageLocation +Two-letter country code used for Microsoft 365 license assignment. + .PARAMETER PurchaseLicenseScript -Optional billing-provider adapter (.ps1). Called ONCE when capacity is exhausted, with --TenantId -SkuPartNumber SPB -AdditionalSeats 1. It must purchase one seat in -this tenant's Business Premium subscription, throw on failure, and never print secrets. -Graph itself cannot purchase subscription seats. Without an adapter, the operator is -prompted to purchase a seat in their billing portal and the script checks availability again. +Optional billing-provider adapter (.ps1). + +Called ONCE when license capacity is exhausted, with: +-TenantId -SkuPartNumber SPB -AdditionalSeats 1 + +The adapter must purchase one additional Business Premium seat, +throw on failure, and never print secrets. + +Graph cannot purchase subscription seats. + +Without an adapter, the operator is prompted to purchase a seat +through the Microsoft 365 billing portal. + .EXAMPLE -.\New-M365Employee.ps1 +./New-M365Employee.ps1 + .EXAMPLE -.\New-M365Employee.ps1 -TenantId 'your-tenant-id' -UsageLocation US +./New-M365Employee.ps1 -TenantId 'your-tenant-id' -UsageLocation US #> + [CmdletBinding()] param( [string]$TenantId, - [ValidatePattern('^[A-Za-z]{2}$')][string]$UsageLocation, + + [ValidatePattern('^[A-Za-z]{2}$')] + [string]$UsageLocation, + [string]$PurchaseLicenseScript ) + Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -function Read-Required([string]$Prompt) { - do { $value = (Read-Host $Prompt).Trim() } while ([string]::IsNullOrWhiteSpace($value)) + +# ============================================================ +# FUNCTIONS +# ============================================================ + +function Read-Required { + param( + [string]$Prompt + ) + + do { + $value = (Read-Host $Prompt).Trim() + } + while ([string]::IsNullOrWhiteSpace($value)) + return $value } -function Get-GraphCollection([string]$Uri) { - do { - $page = Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType Hashtable - foreach ($item in $page['value']) { $item } - $Uri = $page['@odata.nextLink'] - } while ($Uri) -} -function Get-PremiumSku { - $matches = @(Get-GraphCollection 'https://graph.microsoft.com/v1.0/subscribedSkus' | - Where-Object { $_['skuPartNumber'] -eq 'SPB' }) - if ($matches.Count -ne 1) { - throw 'Expected one Microsoft 365 Business Premium (SPB) SKU. Check your subscriptions; variants require a reviewed SKU change.' + + +function Install-RequiredModule { + param( + [Parameter(Mandatory)] + [string]$ModuleName + ) + + Write-Host "Checking module: $ModuleName" -ForegroundColor Cyan + + $installed = Get-Module -ListAvailable -Name $ModuleName + + if (-not $installed) { + + Write-Host "Installing missing module: $ModuleName" ` + -ForegroundColor Yellow + + Install-Module ` + -Name $ModuleName ` + -Scope CurrentUser ` + -Repository PSGallery ` + -Force ` + -AllowClobber ` + -ErrorAction Stop } + + Import-Module $ModuleName -ErrorAction Stop + + Write-Host "Module ready: $ModuleName" -ForegroundColor Green +} + + +function Get-GraphCollection { + param( + [string]$Uri + ) + + do { + + $page = Invoke-MgGraphRequest ` + -Method GET ` + -Uri $Uri ` + -OutputType Hashtable + + foreach ($item in $page['value']) { + $item + } + + $Uri = $page['@odata.nextLink'] + + } + while ($Uri) +} + + +function Get-PremiumSku { + + $matches = @( + Get-GraphCollection ` + 'https://graph.microsoft.com/v1.0/subscribedSkus' | + Where-Object { + $_['skuPartNumber'] -eq 'SPB' + } + ) + + if ($matches.Count -ne 1) { + + throw @" +Expected exactly one Microsoft 365 Business Premium (SPB) SKU. + +Check the tenant subscriptions. + +Subscription variants require a reviewed SKU change. +"@ + } + return $matches[0] } -function Test-Capacity($Sku) { - return ($Sku['capabilityStatus'] -eq 'Enabled' -and - ([int]$Sku['prepaidUnits']['enabled'] - [int]$Sku['consumedUnits']) -gt 0) + + +function Test-Capacity { + param( + $Sku + ) + + return ( + $Sku['capabilityStatus'] -eq 'Enabled' -and + ( + [int]$Sku['prepaidUnits']['enabled'] - + [int]$Sku['consumedUnits'] + ) -gt 0 + ) } + + function New-TemporaryPassword { - $sets = @('ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnopqrstuvwxyz', '23456789', '!@#$%*-_+=?') + + $sets = @( + 'ABCDEFGHJKLMNPQRSTUVWXYZ' + 'abcdefghijkmnopqrstuvwxyz' + '23456789' + '!@#$*-_+=?' + ) + $all = $sets -join '' + $chars = [System.Collections.Generic.List[char]]::new() + foreach ($set in $sets) { - $chars.Add($set[[System.Security.Cryptography.RandomNumberGenerator]::GetInt32($set.Length)]) + + $index = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32( + $set.Length + ) + + $chars.Add($set[$index]) } + while ($chars.Count -lt 24) { - $chars.Add($all[[System.Security.Cryptography.RandomNumberGenerator]::GetInt32($all.Length)]) + + $index = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32( + $all.Length + ) + + $chars.Add($all[$index]) } + for ($i = $chars.Count - 1; $i -gt 0; $i--) { - $j = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32($i + 1) - $temp = $chars[$i]; $chars[$i] = $chars[$j]; $chars[$j] = $temp + + $j = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32( + $i + 1 + ) + + $temp = $chars[$i] + $chars[$i] = $chars[$j] + $chars[$j] = $temp } + return -join $chars } -Import-Module Microsoft.Graph.Authentication -ErrorAction Stop -if ($PurchaseLicenseScript) { - $PurchaseLicenseScript = (Resolve-Path -LiteralPath $PurchaseLicenseScript).Path - if ([IO.Path]::GetExtension($PurchaseLicenseScript) -ne '.ps1') { throw 'Purchase adapter must be a .ps1 file.' } + +function Test-ExchangeTenant { + param( + [Parameter(Mandatory)] + [string]$GraphTenantId + ) + + Write-Host "" + Write-Host "Verifying Exchange Online tenant..." ` + -ForegroundColor Cyan + + $connections = @( + Get-ConnectionInformation -ErrorAction Stop | + Where-Object { + $_.State -eq 'Connected' + } + ) + + if ($connections.Count -ne 1) { + + throw @" +Exchange Online tenant validation failed. + +Expected exactly one active Exchange Online connection. +Found: $($connections.Count) + +Disconnect unused Exchange Online sessions and retry. +"@ + } + + $connection = $connections[0] + + $exchangeTenantId = [string]$connection.TenantID + + if ([string]::IsNullOrWhiteSpace($exchangeTenantId)) { + + throw @" +Unable to determine the Exchange Online tenant ID. + +The active Exchange Online connection did not return TenantID. + +Provisioning has been stopped. +"@ + } + + $graphGuid = [guid]::Empty + $exchangeGuid = [guid]::Empty + + if (-not [guid]::TryParse($GraphTenantId, [ref]$graphGuid)) { + throw "Microsoft Graph returned an invalid tenant ID." + } + + if (-not [guid]::TryParse($exchangeTenantId, [ref]$exchangeGuid)) { + throw "Exchange Online returned an invalid tenant ID." + } + + Write-Host "Microsoft Graph tenant: $GraphTenantId" + Write-Host "Exchange Online tenant: $exchangeTenantId" + + if ($exchangeGuid -ne $graphGuid) { + + throw @" +TENANT MISMATCH DETECTED + +Microsoft Graph Tenant: +$GraphTenantId + +Exchange Online Tenant: +$exchangeTenantId + +The connections reference different Microsoft 365 tenants. + +No employee account has been created. +"@ + } + + Write-Host "Tenant verification successful." ` + -ForegroundColor Green } + + +# ============================================================ +# MODULE DEPENDENCIES +# ============================================================ + +Write-Host "" +Write-Host "Microsoft 365 Employee Provisioning" ` + -ForegroundColor Cyan + +Write-Host "-----------------------------------" + +Install-RequiredModule -ModuleName 'Microsoft.Graph.Authentication' + +Install-RequiredModule -ModuleName 'ExchangeOnlineManagement' + + +# ============================================================ +# OPTIONAL BILLING ADAPTER +# ============================================================ + +if ($PurchaseLicenseScript) { + + $PurchaseLicenseScript = ( + Resolve-Path -LiteralPath $PurchaseLicenseScript + ).Path + + if ( + [IO.Path]::GetExtension($PurchaseLicenseScript) -ne '.ps1' + ) { + throw 'Purchase adapter must be a .ps1 file.' + } +} + + +# ============================================================ +# EMPLOYEE INFORMATION +# ============================================================ + +Write-Host "" +Write-Host "Enter employee information" -ForegroundColor Cyan + $firstName = Read-Required 'First name' + $lastName = Read-Required 'Last name' + + do { - $email = Read-Required 'Email address (also used as the sign-in name)' - $validEmail = $email -match '^[A-Za-z0-9][A-Za-z0-9._%+!~''-]*@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' - if (-not $validEmail) { Write-Warning 'Enter a valid email address with an ASCII sign-in name.' } -} while (-not $validEmail) -do { $device = (Read-Required 'Will the employee use a company-owned device? (yes/no)').ToLowerInvariant() } + + $email = Read-Required ` + 'Email address (also used as the sign-in name)' + + $validEmail = $email -match ` + '^[A-Za-z0-9][A-Za-z0-9._%+!~''-]*@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' + + if (-not $validEmail) { + Write-Warning 'Enter a valid email address with an ASCII sign-in name.' + } + +} +while (-not $validEmail) + + +do { + + $device = ( + Read-Required ` + 'Will the employee use a company-owned device? (yes/no)' + ).ToLowerInvariant() + +} while ($device -notin @('y','yes','n','no')) -$departments = @('Professional Services','Placement','Admin / BD') -for ($i = 0; $i -lt $departments.Count; $i++) { Write-Host "$($i + 1). $($departments[$i])" } -do { $choice = Read-Required 'Select department (1-3)' } while ($choice -notin @('1','2','3')) + + +# ============================================================ +# DEPARTMENT SELECTION +# ============================================================ + +$departments = @( + 'Professional Services' + 'Placement' + 'Admin / BD' +) + +Write-Host "" +Write-Host "Select department:" -ForegroundColor Cyan + +for ($i = 0; $i -lt $departments.Count; $i++) { + + Write-Host "$($i + 1). $($departments[$i])" +} + +do { + + $choice = Read-Required 'Select department (1-3)' + +} +while ($choice -notin @('1','2','3')) + $department = $departments[[int]$choice - 1] + + +# ============================================================ +# LICENSE USAGE LOCATION +# ============================================================ + if (-not $UsageLocation) { - do { $UsageLocation = Read-Required 'Two-letter license usage country code (for example US)' } + + do { + + $UsageLocation = Read-Required ` + 'Two-letter license usage country code (for example US)' + + } while ($UsageLocation -notmatch '^[A-Za-z]{2}$') } + $UsageLocation = $UsageLocation.ToUpperInvariant() + + +# ============================================================ +# CONNECT TO MICROSOFT GRAPH +# ============================================================ + +Write-Host "" +Write-Host "Connecting to Microsoft Graph..." ` + -ForegroundColor Cyan + $connect = @{ - Scopes = @('User.ReadWrite.All','GroupMember.ReadWrite.All','Group.Read.All','LicenseAssignment.ReadWrite.All','Domain.Read.All') + + Scopes = @( + 'User.ReadWrite.All' + 'GroupMember.ReadWrite.All' + 'Group.Read.All' + 'LicenseAssignment.ReadWrite.All' + 'Domain.Read.All' + 'Organization.Read.All' + ) + ContextScope = 'Process' + NoWelcome = $true } -if ($TenantId) { $connect.TenantId = $TenantId } + +if ($TenantId) { + $connect.TenantId = $TenantId +} + Connect-MgGraph @connect + + +# ============================================================ +# PROVISIONING STATE +# ============================================================ + $createdUser = $null $password = $null -$exchangeConnected = $false -$completed = [System.Collections.Generic.List[string]]::new() -try { - $actualTenant = (Get-MgContext).TenantId - Write-Host "Connected to tenant: $actualTenant" - $domain = $email.Split('@')[1] - $verified = @(Get-GraphCollection 'https://graph.microsoft.com/v1.0/domains' | - Where-Object { $_['id'] -eq $domain -and $_['isVerified'] }) - if ($verified.Count -ne 1) { throw "The email domain '$domain' is not verified in this tenant." } - $filter = [Uri]::EscapeDataString("userPrincipalName eq '$($email.Replace("'", "''"))'") - $existing = @(Get-GraphCollection "https://graph.microsoft.com/v1.0/users?`$filter=$filter&`$select=id") - if ($existing.Count) { throw 'This sign-in name already exists. No existing account was changed.' } - $names = @('Active Employees','All Employees','IT-Communication') - if ($device -in @('y','yes')) { $names += 'IT-Communication (Company Devices)' } - $groups = foreach ($name in $names) { - $filter = [Uri]::EscapeDataString("displayName eq '$($name.Replace("'", "''"))'") - $found = @(Get-GraphCollection "https://graph.microsoft.com/v1.0/groups?`$filter=$filter&`$select=id,displayName,groupTypes,mailEnabled,mail,onPremisesSyncEnabled,isAssignableToRole") - if ($found.Count -ne 1) { throw "Group '$name' is missing or ambiguous. Resolve this before creating the account." } - $g = $found[0] - if ($g['onPremisesSyncEnabled'] -or $g['isAssignableToRole'] -or $g['groupTypes'] -contains 'DynamicMembership') { - throw "Group '$name' is synchronized, role-assignable, or dynamic; this script cannot directly manage its membership." - } - $g +$exchangeConnected = $false +$exchangeConnectedByScript = $false + +$completed = [System.Collections.Generic.List[string]]::new() + + +try { + + # ======================================================== + # VERIFY GRAPH TENANT + # ======================================================== + + $actualTenant = (Get-MgContext).TenantId + + Write-Host "" + Write-Host "Connected to tenant: $actualTenant" ` + -ForegroundColor Green + + if ( + $TenantId -and + ([guid]$TenantId -ne [guid]$actualTenant) + ) { + throw 'Connected Microsoft Graph tenant does not match the requested tenant.' } - $exchangeGroups = @($groups | Where-Object { $_['mailEnabled'] -and $_['groupTypes'] -notcontains 'Unified' }) + + + # ======================================================== + # VERIFY EMAIL DOMAIN + # ======================================================== + + $domain = $email.Split('@')[1] + + $verified = @( + Get-GraphCollection ` + 'https://graph.microsoft.com/v1.0/domains' | + Where-Object { + $_['id'] -eq $domain -and + $_['isVerified'] + } + ) + + if ($verified.Count -ne 1) { + + throw "The email domain '$domain' is not verified in this tenant." + } + + + # ======================================================== + # CHECK FOR EXISTING USER + # ======================================================== + + $filter = [Uri]::EscapeDataString( + "userPrincipalName eq '$($email.Replace("'", "''"))'" + ) + + $existing = @( + Get-GraphCollection ` + "https://graph.microsoft.com/v1.0/users?`$filter=$filter&`$select=id" + ) + + if ($existing.Count) { + + throw 'This sign-in name already exists. No existing account was changed.' + } + + + # ======================================================== + # RESOLVE REQUIRED GROUPS + # ======================================================== + + $names = @( + 'Active Employees' + 'All Employees' + 'IT-Communication' + ) + + if ($device -in @('y','yes')) { + + $names += 'IT-Communication (Company Devices)' + } + + $groups = @( + foreach ($name in $names) { + + $filter = [Uri]::EscapeDataString( + "displayName eq '$($name.Replace("'", "''"))'" + ) + + $found = @( + Get-GraphCollection ` + "https://graph.microsoft.com/v1.0/groups?`$filter=$filter&`$select=id,displayName,groupTypes,mailEnabled,mail,onPremisesSyncEnabled,isAssignableToRole" + ) + + if ($found.Count -ne 1) { + + throw "Group '$name' is missing or ambiguous. Resolve this before creating the account." + } + + $g = $found[0] + + if ( + $g['onPremisesSyncEnabled'] -or + $g['isAssignableToRole'] -or + $g['groupTypes'] -contains 'DynamicMembership' + ) { + + throw "Group '$name' is synchronized, role-assignable, or dynamic; this script cannot directly manage its membership." + } + + $g + } + ) + + + # ======================================================== + # IDENTIFY EXCHANGE-MANAGED GROUPS + # ======================================================== + + $exchangeGroups = @( + $groups | + Where-Object { + $_['mailEnabled'] -and + $_['groupTypes'] -notcontains 'Unified' + } + ) + + + # ======================================================== + # EXCHANGE ONLINE CONNECTION AND TENANT VALIDATION + # ======================================================== + if ($exchangeGroups.Count) { - Import-Module ExchangeOnlineManagement -ErrorAction Stop - Connect-ExchangeOnline -ShowBanner:$false + + Write-Host "" + Write-Host "Exchange-managed groups detected." ` + -ForegroundColor Cyan + + $existingConnections = @( + Get-ConnectionInformation -ErrorAction SilentlyContinue | + Where-Object { + $_.State -eq 'Connected' + } + ) + + if ($existingConnections.Count -eq 0) { + + Write-Host "Connecting to Exchange Online..." ` + -ForegroundColor Cyan + + Connect-ExchangeOnline -ShowBanner:$false ` + -ErrorAction Stop + + $exchangeConnectedByScript = $true + } + $exchangeConnected = $true - # Verify the Exchange session addresses the same tenant before any writes. - $org = Get-OrganizationConfig - if ([string]$org.ExternalDirectoryOrganizationId -ne [string]$actualTenant) { - throw 'Exchange Online and Microsoft Graph are connected to different tenants.' + + # Verify Exchange Online uses the same tenant as Graph. + Test-ExchangeTenant -GraphTenantId $actualTenant + + # Verify Exchange groups exist before creating employee. + foreach ($g in $exchangeGroups) { + + $null = Get-DistributionGroup ` + -Identity $g['mail'] ` + -ErrorAction Stop } - foreach ($g in $exchangeGroups) { $null = Get-DistributionGroup -Identity $g['mail'] } } + + # ======================================================== + # CHECK BUSINESS PREMIUM LICENSE CAPACITY + # ======================================================== + + Write-Host "" + Write-Host "Checking Microsoft 365 Business Premium licenses..." ` + -ForegroundColor Cyan + $sku = Get-PremiumSku + if (-not (Test-Capacity $sku)) { + if ($PurchaseLicenseScript) { - Write-Host 'Requesting one additional Business Premium seat through the configured billing adapter...' - & $PurchaseLicenseScript -TenantId $actualTenant -SkuPartNumber 'SPB' -AdditionalSeats 1 | Out-Null - # Never retry a purchase automatically: an ambiguous response could double-charge. - } else { - Write-Warning 'No available Business Premium license. Graph cannot purchase seats.' - Write-Host 'Buy one additional seat at https://admin.microsoft.com (Billing > Your products), or through your reseller.' - $null = Read-Host 'Press Enter after the purchase is complete, or Ctrl+C to cancel' + + Write-Host "Requesting one additional Business Premium seat..." + + & $PurchaseLicenseScript ` + -TenantId $actualTenant ` + -SkuPartNumber 'SPB' ` + -AdditionalSeats 1 | + Out-Null + + # Do not automatically retry the purchase. + } + else { + + Write-Warning 'No available Business Premium license.' + + Write-Host 'Purchase an additional seat at:' + Write-Host 'https://admin.microsoft.com' + Write-Host 'Billing > Your products' + + $null = Read-Host ` + 'Press Enter after purchasing the license, or Ctrl+C to cancel' + } + $deadline = [DateTime]::UtcNow.AddMinutes(10) + do { + $sku = Get-PremiumSku - if (Test-Capacity $sku) { break } - Write-Host 'Waiting for license capacity to appear...' + + if (Test-Capacity $sku) { + break + } + + Write-Host 'Waiting for license capacity...' + Start-Sleep -Seconds 15 - } while ([DateTime]::UtcNow -lt $deadline) - if (-not (Test-Capacity $sku)) { throw 'No available license after 10 minutes. No user was created; check billing before retrying.' } + + } + while ([DateTime]::UtcNow -lt $deadline) + + if (-not (Test-Capacity $sku)) { + + throw 'No available Business Premium license after 10 minutes. No account was created.' + } } + Write-Host "Business Premium license available." ` + -ForegroundColor Green + + + # ======================================================== + # CREATE EMPLOYEE ACCOUNT + # ======================================================== + + Write-Host "" + Write-Host "Creating Microsoft 365 employee..." ` + -ForegroundColor Cyan + $password = New-TemporaryPassword + $body = @{ + accountEnabled = $true + displayName = "$firstName $lastName" + givenName = $firstName + surname = $lastName + userPrincipalName = $email + mailNickname = $email.Split('@')[0] + department = $department + usageLocation = $UsageLocation - passwordProfile = @{ password = $password; forceChangePasswordNextSignIn = $true } + + passwordProfile = @{ + + password = $password + + forceChangePasswordNextSignIn = $true + } } - $createdUser = Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/users' -Body ($body | ConvertTo-Json -Depth 5) -ContentType 'application/json' -OutputType Hashtable + + $createdUser = Invoke-MgGraphRequest ` + -Method POST ` + -Uri 'https://graph.microsoft.com/v1.0/users' ` + -Body ($body | ConvertTo-Json -Depth 5) ` + -ContentType 'application/json' ` + -OutputType Hashtable + $id = $createdUser['id'] - # Show immediately so later licensing/group failures cannot hide the initial password. - Write-Host "`nAccount created: $email" - Write-Host "Temporary password: $password" - Write-Host 'Password change is required at first sign-in. Store/share it securely.' - $licenseBody = @{ addLicenses = @(@{ skuId = $sku['skuId'] }); removeLicenses = @() } - $null = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/users/$id/assignLicense" -Body ($licenseBody | ConvertTo-Json -Depth 5) -ContentType 'application/json' + + $completed.Add('Employee account created') + + + # ======================================================== + # DISPLAY INITIAL ACCOUNT INFORMATION + # ======================================================== + + Write-Host "" + Write-Host "Account created: $email" ` + -ForegroundColor Green + + Write-Host "Temporary password: $password" ` + -ForegroundColor Yellow + + Write-Host 'Password change is required at first sign-in.' + Write-Host 'Store and share the password securely.' + + + # ======================================================== + # ASSIGN BUSINESS PREMIUM LICENSE + # ======================================================== + + Write-Host "" + Write-Host "Assigning Business Premium license..." ` + -ForegroundColor Cyan + + $licenseBody = @{ + + addLicenses = @( + @{ + skuId = $sku['skuId'] + } + ) + + removeLicenses = @() + } + + $null = Invoke-MgGraphRequest ` + -Method POST ` + -Uri "https://graph.microsoft.com/v1.0/users/$id/assignLicense" ` + -Body ($licenseBody | ConvertTo-Json -Depth 5) ` + -ContentType 'application/json' + $completed.Add('Business Premium license assigned') + + Write-Host "Business Premium license assigned." ` + -ForegroundColor Green + + + # ======================================================== + # ADD EMPLOYEE TO GROUPS + # ======================================================== + foreach ($g in $groups) { - if ($g['mailEnabled'] -and $g['groupTypes'] -notcontains 'Unified') { - # Exchange recipient provisioning can lag behind Graph user creation. + + Write-Host "" + Write-Host "Adding employee to: $($g['displayName'])" ` + -ForegroundColor Cyan + + if ( + $g['mailEnabled'] -and + $g['groupTypes'] -notcontains 'Unified' + ) { + + # Exchange recipient provisioning can take time. + $recipient = $null + for ($attempt = 0; $attempt -lt 40; $attempt++) { - $recipient = Get-Recipient -Identity $email -ErrorAction SilentlyContinue - if ($recipient) { break } + + $recipient = Get-Recipient ` + -Identity $email ` + -ErrorAction SilentlyContinue + + if ($recipient) { + break + } + Start-Sleep -Seconds 15 } - if (-not $recipient) { throw 'Exchange recipient was not ready after 10 minutes. Complete distribution group membership after provisioning.' } - Add-DistributionGroupMember -Identity $g['mail'] -Member $email -BypassSecurityGroupManagerCheck - } else { - $memberBody = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$id" } - $null = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/groups/$($g['id'])/members/`$ref" -Body ($memberBody | ConvertTo-Json) -ContentType 'application/json' + + if (-not $recipient) { + + throw 'Exchange recipient was not ready after 10 minutes. Complete distribution group membership manually.' + } + + Add-DistributionGroupMember ` + -Identity $g['mail'] ` + -Member $email ` + -BypassSecurityGroupManagerCheck ` + -ErrorAction Stop + } + else { + + $memberBody = @{ + + '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$id" + } + + $null = Invoke-MgGraphRequest ` + -Method POST ` + -Uri "https://graph.microsoft.com/v1.0/groups/$($g['id'])/members/`$ref" ` + -Body ($memberBody | ConvertTo-Json) ` + -ContentType 'application/json' + } + $completed.Add("Joined $($g['displayName'])") + + Write-Host "Group membership completed." ` + -ForegroundColor Green } - Write-Host "`nProvisioning complete: $email | Department: $department" - $completed | ForEach-Object { Write-Host " $_" } -} catch { - if ($createdUser) { - Write-Warning "PARTIAL COMPLETION: $email exists (ID $($createdUser['id'])). Do not rerun to recreate it. Resolve remaining steps on this account." - $completed | ForEach-Object { Write-Host "Completed: $_" } + + + # ======================================================== + # PROVISIONING SUMMARY + # ======================================================== + + Write-Host "" + Write-Host "============================================" ` + -ForegroundColor Green + + Write-Host "EMPLOYEE PROVISIONING COMPLETE" ` + -ForegroundColor Green + + Write-Host "============================================" ` + -ForegroundColor Green + + Write-Host "Employee: $firstName $lastName" + Write-Host "Email: $email" + Write-Host "Department: $department" + Write-Host "Tenant: $actualTenant" + + Write-Host "" + Write-Host "Completed tasks:" + + $completed | ForEach-Object { + + Write-Host " - $_" -ForegroundColor Green } - throw -} finally { - $password = $null - if ($exchangeConnected) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } - Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + } +catch { + + Write-Host "" + Write-Host "PROVISIONING ERROR" -ForegroundColor Red + + if ($createdUser) { + + Write-Warning @" +PARTIAL COMPLETION + +The employee account exists. + +Email: $email +User ID: $($createdUser['id']) + +Do not rerun the script to recreate this employee. + +Resolve the remaining provisioning steps on the +existing account. +"@ + + $completed | ForEach-Object { + + Write-Host "Completed: $_" ` + -ForegroundColor Yellow + } + } + + throw + +} +finally { + + # Clear the local password variable. + $password = $null + + # Only disconnect an Exchange session created by this script. + if ($exchangeConnectedByScript) { + + Disconnect-ExchangeOnline ` + -Confirm:$false ` + -ErrorAction SilentlyContinue + } + + Disconnect-MgGraph ` + -ErrorAction SilentlyContinue | + Out-Null +} \ No newline at end of file