Files
asg/scripts/New-M365Employee.ps1

927 lines
21 KiB
PowerShell

#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 <guid> -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
}