From a5e6a18da11e36d4f22b94b347b21233794f253e Mon Sep 17 00:00:00 2001 From: Matt Speer Date: Fri, 9 Oct 2026 17:23:05 -0500 Subject: [PATCH] initial commit for script to add new employee --- scripts/New-M365Employee.ps1 | 211 +++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 scripts/New-M365Employee.ps1 diff --git a/scripts/New-M365Employee.ps1 b/scripts/New-M365Employee.ps1 new file mode 100644 index 0000000..8928008 --- /dev/null +++ b/scripts/New-M365Employee.ps1 @@ -0,0 +1,211 @@ +#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 -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 +}