- Add trigger phrases to frontmatter (M365 tenant, Office 365 admin, etc.) - Add TOC to SKILL.md with proper section navigation - Delete duplicate HOW_TO_USE.md (70% content overlap) - Create references/ directory with deep-dive content: - powershell-templates.md: Ready-to-use script templates - security-policies.md: Conditional Access, MFA, DLP guide - troubleshooting.md: Common issues and solutions - Move Python tools to scripts/ directory (standard pattern) - Consolidate best practices into actionable workflows - Fix second-person voice throughout Addresses Progressive Disclosure Architecture feedback. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
21 KiB
21 KiB
PowerShell Script Templates
Ready-to-use PowerShell scripts for Microsoft 365 administration with error handling and best practices.
Table of Contents
- Prerequisites
- Security Audit Script
- Conditional Access Policy
- Bulk User Provisioning
- User Offboarding
- License Management
- DNS Records Configuration
Prerequisites
Install required modules before running scripts:
# Install Microsoft Graph module (recommended)
Install-Module Microsoft.Graph -Scope CurrentUser -Force
# Install Exchange Online module
Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force
# Install Teams module
Install-Module MicrosoftTeams -Scope CurrentUser -Force
# Verify installations
Get-InstalledModule Microsoft.Graph, ExchangeOnlineManagement, MicrosoftTeams
Security Audit Script
Comprehensive security audit for MFA status, admin accounts, inactive users, and permissions.
<#
.SYNOPSIS
Microsoft 365 Security Audit Report
.DESCRIPTION
Performs comprehensive security audit and generates CSV reports.
Checks: MFA status, admin accounts, inactive users, guest access, licenses
.OUTPUTS
CSV reports in SecurityAudit_[timestamp] directory
#>
#Requires -Modules Microsoft.Graph, ExchangeOnlineManagement
param(
[int]$InactiveDays = 90,
[string]$OutputPath = "."
)
# Connect to services
Connect-MgGraph -Scopes "Directory.Read.All", "User.Read.All", "AuditLog.Read.All"
Connect-ExchangeOnline
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$reportPath = Join-Path $OutputPath "SecurityAudit_$timestamp"
New-Item -ItemType Directory -Path $reportPath -Force | Out-Null
Write-Host "Starting Security Audit..." -ForegroundColor Cyan
# 1. MFA Status Check
Write-Host "[1/5] Checking MFA status..." -ForegroundColor Yellow
$users = Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,AccountEnabled
$mfaReport = @()
foreach ($user in $users) {
$authMethods = Get-MgUserAuthenticationMethod -UserId $user.Id -ErrorAction SilentlyContinue
$hasMFA = ($authMethods | Where-Object { $_.AdditionalProperties.'@odata.type' -ne '#microsoft.graph.passwordAuthenticationMethod' }).Count -gt 0
$mfaReport += [PSCustomObject]@{
UserPrincipalName = $user.UserPrincipalName
DisplayName = $user.DisplayName
AccountEnabled = $user.AccountEnabled
MFAEnabled = $hasMFA
AuthMethodsCount = $authMethods.Count
}
}
$mfaReport | Export-Csv -Path "$reportPath/MFA_Status.csv" -NoTypeInformation
$usersWithoutMFA = ($mfaReport | Where-Object { -not $_.MFAEnabled -and $_.AccountEnabled }).Count
Write-Host " Users without MFA: $usersWithoutMFA" -ForegroundColor $(if($usersWithoutMFA -gt 0){'Red'}else{'Green'})
# 2. Admin Roles Audit
Write-Host "[2/5] Auditing admin roles..." -ForegroundColor Yellow
$adminRoles = Get-MgDirectoryRole -All
$adminReport = @()
foreach ($role in $adminRoles) {
$members = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All
foreach ($member in $members) {
$memberUser = Get-MgUser -UserId $member.Id -ErrorAction SilentlyContinue
if ($memberUser) {
$adminReport += [PSCustomObject]@{
UserPrincipalName = $memberUser.UserPrincipalName
DisplayName = $memberUser.DisplayName
Role = $role.DisplayName
AccountEnabled = $memberUser.AccountEnabled
}
}
}
}
$adminReport | Export-Csv -Path "$reportPath/Admin_Roles.csv" -NoTypeInformation
Write-Host " Admin assignments: $($adminReport.Count)" -ForegroundColor Cyan
# 3. Inactive Users
Write-Host "[3/5] Finding inactive users ($InactiveDays+ days)..." -ForegroundColor Yellow
$inactiveDate = (Get-Date).AddDays(-$InactiveDays)
$inactiveUsers = Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,SignInActivity,AccountEnabled |
Where-Object {
$_.AccountEnabled -and
$_.SignInActivity.LastSignInDateTime -and
$_.SignInActivity.LastSignInDateTime -lt $inactiveDate
} |
Select-Object UserPrincipalName, DisplayName,
@{N='LastSignIn';E={$_.SignInActivity.LastSignInDateTime}},
@{N='DaysSinceSignIn';E={((Get-Date) - $_.SignInActivity.LastSignInDateTime).Days}}
$inactiveUsers | Export-Csv -Path "$reportPath/Inactive_Users.csv" -NoTypeInformation
Write-Host " Inactive users: $($inactiveUsers.Count)" -ForegroundColor $(if($inactiveUsers.Count -gt 0){'Yellow'}else{'Green'})
# 4. Guest Users
Write-Host "[4/5] Reviewing guest access..." -ForegroundColor Yellow
$guestUsers = Get-MgUser -Filter "userType eq 'Guest'" -All -Property UserPrincipalName,DisplayName,AccountEnabled,CreatedDateTime
$guestUsers | Select-Object UserPrincipalName, DisplayName, AccountEnabled, CreatedDateTime |
Export-Csv -Path "$reportPath/Guest_Users.csv" -NoTypeInformation
Write-Host " Guest users: $($guestUsers.Count)" -ForegroundColor Cyan
# 5. License Usage
Write-Host "[5/5] Analyzing licenses..." -ForegroundColor Yellow
$licenses = Get-MgSubscribedSku -All
$licenseReport = foreach ($lic in $licenses) {
[PSCustomObject]@{
ProductName = $lic.SkuPartNumber
TotalLicenses = $lic.PrepaidUnits.Enabled
AssignedLicenses = $lic.ConsumedUnits
AvailableLicenses = $lic.PrepaidUnits.Enabled - $lic.ConsumedUnits
Utilization = [math]::Round(($lic.ConsumedUnits / [math]::Max($lic.PrepaidUnits.Enabled, 1)) * 100, 1)
}
}
$licenseReport | Export-Csv -Path "$reportPath/License_Usage.csv" -NoTypeInformation
Write-Host " License SKUs: $($licenses.Count)" -ForegroundColor Cyan
# Summary
Write-Host "`n=== Security Audit Summary ===" -ForegroundColor Green
Write-Host "Total Users: $($users.Count)"
Write-Host "Users without MFA: $usersWithoutMFA $(if($usersWithoutMFA -gt 0){'[ACTION REQUIRED]'})"
Write-Host "Inactive Users: $($inactiveUsers.Count)"
Write-Host "Guest Users: $($guestUsers.Count)"
Write-Host "Admin Assignments: $($adminReport.Count)"
Write-Host "`nReports saved to: $reportPath" -ForegroundColor Green
# Disconnect
Disconnect-MgGraph
Disconnect-ExchangeOnline -Confirm:$false
Conditional Access Policy
Create Conditional Access policy requiring MFA for administrators.
<#
.SYNOPSIS
Create Conditional Access Policy for MFA
.DESCRIPTION
Creates a Conditional Access policy requiring MFA.
Policy is created in report-only mode for safe testing.
.PARAMETER PolicyName
Name for the policy
.PARAMETER IncludeAllUsers
Apply to all users (default: false, admins only)
#>
#Requires -Modules Microsoft.Graph
param(
[string]$PolicyName = "Require MFA for Administrators",
[switch]$IncludeAllUsers,
[switch]$Enforce
)
Connect-MgGraph -Scopes "Policy.ReadWrite.ConditionalAccess", "Directory.Read.All"
# Get admin role IDs
$adminRoles = @(
"62e90394-69f5-4237-9190-012177145e10" # Global Administrator
"194ae4cb-b126-40b2-bd5b-6091b380977d" # Security Administrator
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c" # SharePoint Administrator
"29232cdf-9323-42fd-ade2-1d097af3e4de" # Exchange Administrator
"fe930be7-5e62-47db-91af-98c3a49a38b1" # User Administrator
)
# Build conditions
$conditions = @{
Users = @{
IncludeRoles = if ($IncludeAllUsers) { $null } else { $adminRoles }
IncludeUsers = if ($IncludeAllUsers) { @("All") } else { $null }
ExcludeUsers = @("GuestsOrExternalUsers")
}
Applications = @{
IncludeApplications = @("All")
}
ClientAppTypes = @("browser", "mobileAppsAndDesktopClients")
}
# Remove null entries
if ($IncludeAllUsers) {
$conditions.Users.Remove("IncludeRoles")
} else {
$conditions.Users.Remove("IncludeUsers")
}
$grantControls = @{
BuiltInControls = @("mfa")
Operator = "OR"
}
$state = if ($Enforce) { "enabled" } else { "enabledForReportingButNotEnforced" }
$policyParams = @{
DisplayName = $PolicyName
State = $state
Conditions = $conditions
GrantControls = $grantControls
}
try {
$policy = New-MgIdentityConditionalAccessPolicy -BodyParameter $policyParams
Write-Host "Policy created successfully" -ForegroundColor Green
Write-Host " Name: $($policy.DisplayName)"
Write-Host " ID: $($policy.Id)"
Write-Host " State: $state"
if (-not $Enforce) {
Write-Host "`nPolicy is in REPORT-ONLY mode." -ForegroundColor Yellow
Write-Host "Monitor sign-in logs before enforcing."
Write-Host "To enforce: Update policy state to 'enabled' in Azure AD portal"
}
} catch {
Write-Host "Error creating policy: $_" -ForegroundColor Red
}
Disconnect-MgGraph
Bulk User Provisioning
Create users from CSV with license assignment.
<#
.SYNOPSIS
Bulk User Provisioning from CSV
.DESCRIPTION
Creates users from CSV file with automatic license assignment.
.PARAMETER CsvPath
Path to CSV file with columns: DisplayName, UserPrincipalName, Department, JobTitle
.PARAMETER LicenseSku
License SKU to assign (e.g., ENTERPRISEPACK for E3)
.PARAMETER Password
Initial password (auto-generated if not provided)
#>
#Requires -Modules Microsoft.Graph
param(
[Parameter(Mandatory)]
[string]$CsvPath,
[string]$LicenseSku = "ENTERPRISEPACK",
[string]$Password,
[switch]$WhatIf
)
Connect-MgGraph -Scopes "User.ReadWrite.All", "Directory.ReadWrite.All"
# Validate CSV
if (-not (Test-Path $CsvPath)) {
Write-Host "CSV file not found: $CsvPath" -ForegroundColor Red
exit 1
}
$users = Import-Csv $CsvPath
Write-Host "Found $($users.Count) users in CSV" -ForegroundColor Cyan
# Get license SKU ID
$license = Get-MgSubscribedSku -All | Where-Object { $_.SkuPartNumber -eq $LicenseSku }
if (-not $license) {
Write-Host "License SKU not found: $LicenseSku" -ForegroundColor Red
Write-Host "Available SKUs:"
Get-MgSubscribedSku -All | ForEach-Object { Write-Host " $($_.SkuPartNumber)" }
exit 1
}
$results = @()
$successCount = 0
$errorCount = 0
foreach ($user in $users) {
$upn = $user.UserPrincipalName
if ($WhatIf) {
Write-Host "[WhatIf] Would create: $upn" -ForegroundColor Yellow
continue
}
# Generate password if not provided
$userPassword = if ($Password) { $Password } else {
-join ((65..90) + (97..122) + (48..57) + (33,35,36,37) | Get-Random -Count 16 | ForEach-Object { [char]$_ })
}
$userParams = @{
DisplayName = $user.DisplayName
UserPrincipalName = $upn
MailNickname = $upn.Split("@")[0]
AccountEnabled = $true
Department = $user.Department
JobTitle = $user.JobTitle
UsageLocation = "US" # Required for license assignment
PasswordProfile = @{
Password = $userPassword
ForceChangePasswordNextSignIn = $true
ForceChangePasswordNextSignInWithMfa = $true
}
}
try {
# Create user
$newUser = New-MgUser -BodyParameter $userParams
Write-Host "Created: $upn" -ForegroundColor Green
# Assign license
$licenseParams = @{
AddLicenses = @(@{ SkuId = $license.SkuId })
RemoveLicenses = @()
}
Set-MgUserLicense -UserId $newUser.Id -BodyParameter $licenseParams
Write-Host " License assigned: $LicenseSku" -ForegroundColor Cyan
$successCount++
$results += [PSCustomObject]@{
UserPrincipalName = $upn
Status = "Success"
Password = $userPassword
Message = "Created and licensed"
}
} catch {
Write-Host "Error for $upn : $_" -ForegroundColor Red
$errorCount++
$results += [PSCustomObject]@{
UserPrincipalName = $upn
Status = "Failed"
Password = ""
Message = $_.Exception.Message
}
}
}
# Export results
if (-not $WhatIf) {
$resultsPath = "UserProvisioning_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$results | Export-Csv -Path $resultsPath -NoTypeInformation
Write-Host "`nResults saved to: $resultsPath" -ForegroundColor Green
Write-Host "Success: $successCount | Errors: $errorCount"
}
Disconnect-MgGraph
CSV Format:
DisplayName,UserPrincipalName,Department,JobTitle
John Smith,john.smith@contoso.com,Engineering,Developer
Jane Doe,jane.doe@contoso.com,Marketing,Manager
User Offboarding
Secure user offboarding with mailbox conversion and access removal.
<#
.SYNOPSIS
Secure User Offboarding
.DESCRIPTION
Performs secure offboarding: disables account, revokes sessions,
converts mailbox to shared, removes licenses, sets forwarding.
.PARAMETER UserPrincipalName
UPN of user to offboard
.PARAMETER ForwardTo
Email to forward messages to (optional)
.PARAMETER RetainMailbox
Keep mailbox as shared (default: true)
#>
#Requires -Modules Microsoft.Graph, ExchangeOnlineManagement
param(
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[string]$ForwardTo,
[switch]$RetainMailbox = $true,
[switch]$WhatIf
)
Connect-MgGraph -Scopes "User.ReadWrite.All", "Directory.ReadWrite.All"
Connect-ExchangeOnline
Write-Host "Starting offboarding for: $UserPrincipalName" -ForegroundColor Cyan
$user = Get-MgUser -UserId $UserPrincipalName -ErrorAction SilentlyContinue
if (-not $user) {
Write-Host "User not found: $UserPrincipalName" -ForegroundColor Red
exit 1
}
$actions = @()
# 1. Disable account
if (-not $WhatIf) {
Update-MgUser -UserId $user.Id -AccountEnabled:$false
}
$actions += "Disabled account"
Write-Host "[1/6] Account disabled" -ForegroundColor Green
# 2. Revoke all sessions
if (-not $WhatIf) {
Revoke-MgUserSignInSession -UserId $user.Id
}
$actions += "Revoked all sessions"
Write-Host "[2/6] Sessions revoked" -ForegroundColor Green
# 3. Reset password
$newPassword = -join ((65..90) + (97..122) + (48..57) | Get-Random -Count 32 | ForEach-Object { [char]$_ })
if (-not $WhatIf) {
$passwordProfile = @{
Password = $newPassword
ForceChangePasswordNextSignIn = $true
}
Update-MgUser -UserId $user.Id -PasswordProfile $passwordProfile
}
$actions += "Reset password"
Write-Host "[3/6] Password reset" -ForegroundColor Green
# 4. Remove from groups
$groups = Get-MgUserMemberOf -UserId $user.Id -All
$groupCount = 0
foreach ($group in $groups) {
if ($group.AdditionalProperties.'@odata.type' -eq '#microsoft.graph.group') {
if (-not $WhatIf) {
Remove-MgGroupMemberByRef -GroupId $group.Id -DirectoryObjectId $user.Id -ErrorAction SilentlyContinue
}
$groupCount++
}
}
$actions += "Removed from $groupCount groups"
Write-Host "[4/6] Removed from $groupCount groups" -ForegroundColor Green
# 5. Convert mailbox to shared (if retaining)
if ($RetainMailbox) {
if (-not $WhatIf) {
Set-Mailbox -Identity $UserPrincipalName -Type Shared
}
$actions += "Converted mailbox to shared"
Write-Host "[5/6] Mailbox converted to shared" -ForegroundColor Green
# Set forwarding if specified
if ($ForwardTo) {
if (-not $WhatIf) {
Set-Mailbox -Identity $UserPrincipalName -ForwardingAddress $ForwardTo
}
$actions += "Mail forwarding set to $ForwardTo"
Write-Host " Forwarding to: $ForwardTo" -ForegroundColor Cyan
}
} else {
Write-Host "[5/6] Mailbox retention skipped" -ForegroundColor Yellow
}
# 6. Remove licenses
$licenses = Get-MgUserLicenseDetail -UserId $user.Id
if ($licenses -and -not $WhatIf) {
$licenseParams = @{
AddLicenses = @()
RemoveLicenses = $licenses.SkuId
}
Set-MgUserLicense -UserId $user.Id -BodyParameter $licenseParams
}
$actions += "Removed $($licenses.Count) licenses"
Write-Host "[6/6] Removed $($licenses.Count) licenses" -ForegroundColor Green
# Summary
Write-Host "`n=== Offboarding Complete ===" -ForegroundColor Green
Write-Host "User: $UserPrincipalName"
Write-Host "Actions taken:"
$actions | ForEach-Object { Write-Host " - $_" }
if ($WhatIf) {
Write-Host "`n[WhatIf] No changes were made" -ForegroundColor Yellow
}
Disconnect-MgGraph
Disconnect-ExchangeOnline -Confirm:$false
License Management
Analyze license usage and optimize allocation.
<#
.SYNOPSIS
License Usage Analysis and Optimization
.DESCRIPTION
Analyzes current license usage and identifies optimization opportunities.
#>
#Requires -Modules Microsoft.Graph
Connect-MgGraph -Scopes "Directory.Read.All", "User.Read.All"
Write-Host "Analyzing License Usage..." -ForegroundColor Cyan
$licenses = Get-MgSubscribedSku -All
$report = foreach ($lic in $licenses) {
$available = $lic.PrepaidUnits.Enabled - $lic.ConsumedUnits
$utilization = [math]::Round(($lic.ConsumedUnits / [math]::Max($lic.PrepaidUnits.Enabled, 1)) * 100, 1)
[PSCustomObject]@{
ProductName = $lic.SkuPartNumber
Total = $lic.PrepaidUnits.Enabled
Assigned = $lic.ConsumedUnits
Available = $available
Utilization = "$utilization%"
Status = if ($utilization -gt 90) { "Critical" }
elseif ($utilization -gt 75) { "Warning" }
elseif ($utilization -lt 50) { "Underutilized" }
else { "Healthy" }
}
}
$report | Format-Table -AutoSize
# Find users with unused licenses
Write-Host "`nChecking for inactive licensed users..." -ForegroundColor Yellow
$inactiveDate = (Get-Date).AddDays(-90)
$inactiveLicensed = Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,SignInActivity,AssignedLicenses |
Where-Object {
$_.AssignedLicenses.Count -gt 0 -and
$_.SignInActivity.LastSignInDateTime -and
$_.SignInActivity.LastSignInDateTime -lt $inactiveDate
} |
Select-Object DisplayName, UserPrincipalName,
@{N='LastSignIn';E={$_.SignInActivity.LastSignInDateTime}},
@{N='LicenseCount';E={$_.AssignedLicenses.Count}}
if ($inactiveLicensed) {
Write-Host "Found $($inactiveLicensed.Count) inactive users with licenses:" -ForegroundColor Yellow
$inactiveLicensed | Format-Table -AutoSize
} else {
Write-Host "No inactive licensed users found" -ForegroundColor Green
}
# Export
$report | Export-Csv -Path "LicenseAnalysis_$(Get-Date -Format 'yyyyMMdd').csv" -NoTypeInformation
Disconnect-MgGraph
DNS Records Configuration
Generate DNS records for custom domain setup.
<#
.SYNOPSIS
Generate DNS Records for Microsoft 365
.DESCRIPTION
Outputs required DNS records for custom domain verification and services.
.PARAMETER Domain
Custom domain name
#>
param(
[Parameter(Mandatory)]
[string]$Domain
)
Write-Host "DNS Records for: $Domain" -ForegroundColor Cyan
Write-Host "=" * 60
Write-Host "`n### MX Record (Email)" -ForegroundColor Yellow
Write-Host "Type: MX"
Write-Host "Host: @"
Write-Host "Points to: $Domain.mail.protection.outlook.com"
Write-Host "Priority: 0"
Write-Host "`n### SPF Record (Email Authentication)" -ForegroundColor Yellow
Write-Host "Type: TXT"
Write-Host "Host: @"
Write-Host "Value: v=spf1 include:spf.protection.outlook.com -all"
Write-Host "`n### Autodiscover (Outlook Configuration)" -ForegroundColor Yellow
Write-Host "Type: CNAME"
Write-Host "Host: autodiscover"
Write-Host "Points to: autodiscover.outlook.com"
Write-Host "`n### DKIM Records (Email Signing)" -ForegroundColor Yellow
$domainKey = $Domain.Replace(".", "-")
Write-Host "Type: CNAME"
Write-Host "Host: selector1._domainkey"
Write-Host "Points to: selector1-$domainKey._domainkey.{tenant}.onmicrosoft.com"
Write-Host ""
Write-Host "Type: CNAME"
Write-Host "Host: selector2._domainkey"
Write-Host "Points to: selector2-$domainKey._domainkey.{tenant}.onmicrosoft.com"
Write-Host "`n### DMARC Record (Email Policy)" -ForegroundColor Yellow
Write-Host "Type: TXT"
Write-Host "Host: _dmarc"
Write-Host "Value: v=DMARC1; p=quarantine; rua=mailto:dmarc@$Domain"
Write-Host "`n### Teams/Skype Records" -ForegroundColor Yellow
Write-Host "Type: CNAME"
Write-Host "Host: sip"
Write-Host "Points to: sipdir.online.lync.com"
Write-Host ""
Write-Host "Type: CNAME"
Write-Host "Host: lyncdiscover"
Write-Host "Points to: webdir.online.lync.com"
Write-Host ""
Write-Host "Type: SRV"
Write-Host "Service: _sip._tls"
Write-Host "Port: 443"
Write-Host "Target: sipdir.online.lync.com"
Write-Host ""
Write-Host "Type: SRV"
Write-Host "Service: _sipfederationtls._tcp"
Write-Host "Port: 5061"
Write-Host "Target: sipfed.online.lync.com"
Write-Host "`n### MDM Enrollment (Intune)" -ForegroundColor Yellow
Write-Host "Type: CNAME"
Write-Host "Host: enterpriseregistration"
Write-Host "Points to: enterpriseregistration.windows.net"
Write-Host ""
Write-Host "Type: CNAME"
Write-Host "Host: enterpriseenrollment"
Write-Host "Points to: enterpriseenrollment.manage.microsoft.com"
Write-Host "`n" + "=" * 60 -ForegroundColor Cyan
Write-Host "Verify DNS propagation: nslookup -type=mx $Domain"
Write-Host "Note: DNS changes may take 24-48 hours to propagate"