Files
asg/scripts/New-M365Employee.ps1
T

212 lines
11 KiB
PowerShell

#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.
.PARAMETER PurchaseLicenseScript
Optional billing-provider adapter (.ps1). Called ONCE when capacity is exhausted, with
-TenantId <guid> -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.
.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'
function Read-Required([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.'
}
return $matches[0]
}
function Test-Capacity($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) {
$chars.Add($set[[System.Security.Cryptography.RandomNumberGenerator]::GetInt32($set.Length)])
}
while ($chars.Count -lt 24) {
$chars.Add($all[[System.Security.Cryptography.RandomNumberGenerator]::GetInt32($all.Length)])
}
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
}
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.' }
}
$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'))
$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 = $departments[[int]$choice - 1]
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 = @{
Scopes = @('User.ReadWrite.All','GroupMember.ReadWrite.All','Group.Read.All','LicenseAssignment.ReadWrite.All','Domain.Read.All')
ContextScope = 'Process'
NoWelcome = $true
}
if ($TenantId) { $connect.TenantId = $TenantId }
Connect-MgGraph @connect
$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
}
$exchangeGroups = @($groups | Where-Object { $_['mailEnabled'] -and $_['groupTypes'] -notcontains 'Unified' })
if ($exchangeGroups.Count) {
Import-Module ExchangeOnlineManagement -ErrorAction Stop
Connect-ExchangeOnline -ShowBanner:$false
$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.'
}
foreach ($g in $exchangeGroups) { $null = Get-DistributionGroup -Identity $g['mail'] }
}
$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'
}
$deadline = [DateTime]::UtcNow.AddMinutes(10)
do {
$sku = Get-PremiumSku
if (Test-Capacity $sku) { break }
Write-Host 'Waiting for license capacity to appear...'
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.' }
}
$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']
# 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('Business Premium license assigned')
foreach ($g in $groups) {
if ($g['mailEnabled'] -and $g['groupTypes'] -notcontains 'Unified') {
# Exchange recipient provisioning can lag behind Graph user creation.
$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 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'
}
$completed.Add("Joined $($g['displayName'])")
}
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: $_" }
}
throw
} finally {
$password = $null
if ($exchangeConnected) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue }
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}