initial commit for script to add new employee
This commit is contained in:
1 parent
5ef82842f2
commit
a5e6a18da1
1 file changed
+211
@@ -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 <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
|
||||
}
|
||||
Reference in new issue
Block a user