#requires -Version 7.2 <# .SYNOPSIS Creates a cloud-only Microsoft 365 employee account. .DESCRIPTION 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 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 .EXAMPLE ./New-M365Employee.ps1 -TenantId 'your-tenant-id' -UsageLocation US #> [CmdletBinding()] param( [string]$TenantId, [ValidatePattern('^[A-Za-z]{2}$')] [string]$UsageLocation, [string]$PurchaseLicenseScript ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ============================================================ # FUNCTIONS # ============================================================ function Read-Required { param( [string]$Prompt ) do { $value = (Read-Host $Prompt).Trim() } while ([string]::IsNullOrWhiteSpace($value)) return $value } 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 { param( $Sku ) return ( $Sku['capabilityStatus'] -eq 'Enabled' -and ( [int]$Sku['prepaidUnits']['enabled'] - [int]$Sku['consumedUnits'] ) -gt 0 ) } function New-TemporaryPassword { $sets = @( 'ABCDEFGHJKLMNPQRSTUVWXYZ' 'abcdefghijkmnopqrstuvwxyz' '23456789' '!@#$*-_+=?' ) $all = $sets -join '' $chars = [System.Collections.Generic.List[char]]::new() foreach ($set in $sets) { $index = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32( $set.Length ) $chars.Add($set[$index]) } while ($chars.Count -lt 24) { $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 } return -join $chars } 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() } while ($device -notin @('y','yes','n','no')) # ============================================================ # 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)' } 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' 'Organization.Read.All' ) ContextScope = 'Process' NoWelcome = $true } if ($TenantId) { $connect.TenantId = $TenantId } Connect-MgGraph @connect # ============================================================ # PROVISIONING STATE # ============================================================ $createdUser = $null $password = $null $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.' } # ======================================================== # 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) { 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 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 } } # ======================================================== # 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..." & $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...' Start-Sleep -Seconds 15 } 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 } } $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'] $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) { 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 } Start-Sleep -Seconds 15 } 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 } # ======================================================== # 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 } } 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 }