• Skip to primary navigation
  • Skip to main content
  • Skip to primary sidebar

Clatent

Technology | Fitness | Food

  • About
  • Resources
  • Contact

Learning

Learning ValidateSet in PowerShell: Valid Values Only

March 26, 2026 by ClaytonT Leave a Comment

Introduction

I know your wondering, why isn’t he writing about AI, and just talking about ValidateSet? If your in the early stages of your PowerShell journey, you may not know about ValidateSet and it is a great feature to know and to make sure your functions have it that you create. Have you ever written or used a PowerShell function where users could enter any value, only to have your script fail because they typed “prod” instead of “Production”? Or maybe they entered “DEvelopment” with creative capitalization? This is where ValidateSet becomes your best friend.

ValidateSet is a parameter attribute that restricts parameter values to a predefined set of options. Think of it as a dropdown menu for your PowerShell functions as users can only choose from the options you provide. Also it can save you big time, if you put an invalid value in, and it partially runs while not having the right checks and balances in, it could do a lot of damage. If you have ValidateSet in place, it won’t let an invalid value be inputted.

Why Use ValidateSet?

Before we dive into the how, let’s understand the why:

  1. Error Prevention: Stop invalid values before they cause problems
  2. User Guidance: Users see exactly what options are available
  3. Tab Completion: PowerShell automatically provides tab completion for your valid options
  4. Self-Documenting Code: The valid options are visible in your function definition
  5. Consistency: Ensures everyone uses the same values across your organization

Now, let’s build your ValidateSet skills step by step!


Level 1: Basic ValidateSet

Let’s start with the simplest example. Imagine you’re creating a function to restart a service, but only for specific environments:

function Restart-AppService {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('Development', 'Testing', 'Production')]
        [string]$Environment
    )

    Write-Host "Restarting service in $Environment environment..." -ForegroundColor Green
    # Your restart logic here
}

What’s happening here?

  • The [ValidateSet()] attribute limits $Environment to exactly three values
  • If someone tries Restart-AppService -Environment "Staging", PowerShell will throw an error
  • Users can press TAB after typing -Environment to cycle through valid options

Try it yourself:

# This works ✓
Restart-AppService -Environment Development

# This fails ✗
Restart-AppService -Environment Staging
# Error: Cannot validate argument on parameter 'Environment'

Level 2: Case Sensitivity and Tab Completion

One beautiful feature of ValidateSet is that it’s case-insensitive by default:

function Set-LogLevel {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('Verbose', 'Info', 'Warning', 'Error', 'Critical')]
        [string]$Level
    )

    Write-Host "Log level set to: $Level"
}

# All of these work!
Set-LogLevel -Level verbose      # ✓
Set-LogLevel -Level VERBOSE      # ✓
Set-LogLevel -Level VeRbOsE      # ✓
Set-LogLevel -Level Verbose      # ✓

The Magic of Tab Completion:

When you type Set-LogLevel -Level and press TAB, PowerShell cycles through:

  • Verbose → Info → Warning → Error → Critical → Verbose → …

This significantly improves user experience and reduces typing errors!


Level 3: Multiple Parameters with ValidateSet

Real-world functions often need multiple validated parameters. Let’s build upon our knowledge:

function Deploy-Application {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('Development', 'Testing', 'Staging', 'Production')]
        [string]$Environment,

        [Parameter(Mandatory)]
        [ValidateSet('East', 'West', 'Central', 'Europe', 'Asia')]
        [string]$Region,

        [Parameter()]
        [ValidateSet('IIS', 'Apache', 'Nginx')]
        [string]$WebServer = 'IIS'
    )

    Write-Host "Deploying to $Environment environment in $Region region using $WebServer" -ForegroundColor Cyan
}

Key Points:

  • Each parameter has its own ValidateSet
  • You can combine ValidateSet with default values ($WebServer = 'IIS')
  • The default value must be one of the valid set values
# Example usage
Deploy-Application -Environment Production -Region East
# Output: Deploying to Production environment in East region using IIS

Level 4: Dynamic ValidateSet

Here’s where things get exciting! What if your valid options need to change based on your environment or context? Enter dynamic ValidateSet using classes. Make sure your .txt file only has 1 value per row with no commas, or it won’t read it correctly.

# First, create a class that implements IValidateSetValuesGenerator
class EnvironmentNamesGenerator : System.Management.Automation.IValidateSetValuesGenerator {
    [string[]] GetValidValues() {
        # This could read from a config file, database, or API
        $environments = Get-Content "C:\Config\environments.txt" -ErrorAction SilentlyContinue
        if ($environments) {
            return $environments
        }
        # Fallback to default values
        return @('Development', 'Testing', 'Production')
    }
}

function Connect-ToEnvironment {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet([EnvironmentNamesGenerator])]
        [string]$Environment
    )

    Write-Host "Connecting to $Environment..." -ForegroundColor Green
}

Why is this powerful?

  • Valid values can come from files, databases, APIs, or Active Directory
  • Values update without modifying the function
  • Tab completion still works!

Level 5: ValidateSet with Arrays

Sometimes users need to select multiple values. ValidateSet works beautifully with arrays:

function Install-Features {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('WebServer', 'Database', 'MessageQueue', 'Cache', 'Monitoring')]
        [string[]]$Features
    )

    foreach ($feature in $Features) {
        Write-Host "Installing $feature..." -ForegroundColor Yellow
        # Installation logic here
    }
}

# Usage - multiple valid values
Install-Features -Features WebServer, Database, Cache

# Each value is still validated
Install-Features -Features WebServer, InvalidFeature  # ✗ Will fail

Level 6: Combining ValidateSet with Other Validators

You can stack multiple validation attributes for robust parameter validation:

function New-ServerName {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('DEV', 'TEST', 'PROD')]
        [string]$Environment,

        [Parameter(Mandatory)]
        [ValidateSet('WEB', 'DB', 'APP', 'API')]
        [string]$Type,

        [Parameter(Mandatory)]
        [ValidateRange(1, 999)]
        [int]$Number
    )

    $serverName = "{0}-{1}-{2:D3}" -f $Environment, $Type, $Number
    Write-Host "Server name: $serverName" -ForegroundColor Green
    return $serverName
}

# Usage
New-ServerName -Environment PROD -Type WEB -Number 42
# Output: PROD-WEB-042

Level 7: Real-World Enterprise Example

Let’s combine everything we’ve learned into a practical, enterprise-ready function:

# Dynamic validator for Azure regions
class AzureRegionsGenerator : System.Management.Automation.IValidateSetValuesGenerator {
    [string[]] GetValidValues() {
        # Get actual Azure locations (in real scenario)
        # For demo, we'll use static list
        return @('eastus', 'westus', 'centralus', 'northeurope', 'westeurope', 'southeastasia')
    }
}

function New-AzureResource {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory)]
        [ValidateSet('Development', 'Testing', 'Staging', 'Production')]
        [string]$Environment,

        [Parameter(Mandatory)]
        [ValidateSet([AzureRegionsGenerator])]
        [string]$Location,

        [Parameter(Mandatory)]
        [ValidateSet('Basic', 'Standard', 'Premium')]
        [string]$Tier,

        [Parameter()]
        [ValidateSet('Windows', 'Linux')]
        [string]$OS = 'Windows',

        [Parameter()]
        [ValidateSet('Small', 'Medium', 'Large')]
        [string[]]$Features = @('Small')
    )

    # Generate resource name
    $resourceName = "rg-$Environment-$Location-$(Get-Date -Format 'yyyyMMdd')".ToLower()

    if ($PSCmdlet.ShouldProcess($resourceName, "Create Azure Resource")) {
        Write-Host "`nCreating Azure Resource:" -ForegroundColor Cyan
        Write-Host "  Name: $resourceName" -ForegroundColor White
        Write-Host "  Location: $Location" -ForegroundColor White
        Write-Host "  Tier: $Tier" -ForegroundColor White
        Write-Host "  OS: $OS" -ForegroundColor White
        Write-Host "  Features: $($Features -join ', ')" -ForegroundColor White

        # Your actual Azure creation logic here
        return $resourceName
    }
}

# Usage examples
New-AzureResource -Environment Production -Location eastus -Tier Premium
New-AzureResource -Environment Development -Location westeurope -Tier Basic -OS Linux -Features Small, Medium

Common Mistake to Avoid

Forgetting ValidateSet Creates Strings

# The parameter is always a string, even if it looks like a number
function Set-Priority {
    param (
        [ValidateSet('1', '2', '3')]
        [string]$Priority  # Note: string type
    )

    # Need to convert if doing numeric operations
    $numPriority = [int]$Priority
}

Conclusion

ValidateSet is a powerful, user-friendly feature that makes your PowerShell functions more robust and easier to use. Start with basic static sets, and as your needs grow, use dynamic ValidateSet with classes. I will say I haven’t used dynamic ValidateSet a much more than a few with no issues, but have heard to only use it when necessary. Maybe I’ll do a deep dive into it at sompoint.

Remember:

  • ✓ Always use ValidateSet when there’s a limited, known set of valid values
  • ✓ Leverage tab completion to improve user experience
  • ✓ Use dynamic ValidateSet for values that change frequently
  • ✓ Combine with other validators for bulletproof parameters
  • ✓ Document your choices with comments

Happy scripting! 🚀


Practice Exercise

Try creating this function yourself:

# Create a function that:
# 1. Takes a ComputerName parameter (mandatory)
# 2. Takes an Action parameter that validates against: 'Restart', 'Shutdown', 'LogOff'
# 3. Takes an optional Force parameter (boolean)
# 4. Writes what action would be performed

Below is one way to do it, but try yourself first!

```powershell
# Solution:
function Invoke-RemoteAction {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$ComputerName,

        [Parameter(Mandatory)]
        [ValidateSet('Restart', 'Shutdown', 'LogOff')]
        [string]$Action,

        [Parameter()]
        [bool]$Force = $false
    )

    $forceText = if ($Force) { "forcefully" } else { "gracefully" }
    Write-Host "Would $Action $ComputerName $forceText" -ForegroundColor Yellow
}

Now take what you’ve learned here and make those functions more secure and easier to use!

What do you think of this style of blog post for learning concepts? Are you strongly for or against it? Let me know and hope you learned something from this blog post and please keep learning and sharing.

Tagged With: Automation, Basics, PowerShell, ValidateSet

Teams Chat and PowerShell – How to add value!

March 23, 2026 by ClaytonT Leave a Comment

It’s been a bit for a technical blog post, but I’ve missed it and finally found some time to do one. What we are learning today is how to send messages, photos, urls, and information from APIs to Teams’ chat using PowerShell. This only covers for Teams chat’s as using Teams’ channels use slightly different authentication and cmdlets, but the concepts are the same. I know it’s not as exciting as Agents, Identity, or zero trust, but this will hopefully make your work day easier and bring more value to your and your company.

To start this off, we will first need a chat ID. I recommend at first using your chat with yourself, or use a chat that only you and people that won’t mind being bombed with test messages will mind!

Best way to get your chat ID

  1. Go to the web version of Teams and go to the chat you want to Read and/or write to
    1. We use to be able to copy the url and pull the ChatID out of it, but I’m not seeing that option anymore.
  2. Then click on the 3 dots in the top right and click “Copy Link”
  3. Here we will need to use everything after “chat/” or “channel” and before “/conversations” or “/General”
    1. It usually starts with “19:” or “19%” right after the “chat/”
  4. Once we have that I’d save it to a variable so you don’t have to keep remembering it and or copying and pasting that large link. Here I saved it to the variable $MyTestChatID so it was easy to remember. If this was prod, I’d use something on the lines of $TeamsChatId
    $MyTestChatID = "19:6h632y7d-nmx8-1yno-325h-77en2mx84x83_s78hy6e7-n6g3-7 9j0-36hf-86njyio53053@unq.gbl.spaces"
  5. Now we need to connect to Microsoft Graph. If your just adding information to a Teams Chat, all you will need is ChatMessage.Send and Chat.ReadWrite, but if you are creating a new chat, then you will also need Chat.Create.Connect-MgGraph -Scopes "Chat.ReadWrite", "ChatMessage.Send"

Before we start digging in, one thing to know is there are 2 different types of “Content Type.” The first is “text” which only allows text formatting, and there is “html” which you guessed it, allows html formatting. I haven’t gone crazy with the html formatting but you can make some nice charts and such with it and use images.

Simple Text:

Here is a simple text example, that just puts “This is my test” in the chat that you earlier added.

$Content = "This is my test"
	 
$ChatMessageBody = @{
   contentType = 'text'
   content     = $Content
}
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

Using Emojis and Text:

You can add some flare to the text contentType by using Emojis.This will add the caution icon to your message

$Content = "⚠️ ALERT: Production Server DB-01 is offline!"

	 
$ChatMessageBody = @{
   contentType = 'text'
   content     = $Content
}
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
	

Using bold and italicize:

If we want to add anymore style we need to change the contentType to html. Now we can bold and italicize the alert.

$Content = "<b><i>⚠️ ALERT:</b></i> Production Server DB-01 is offline!<p>"

	 
$ChatMessageBody = @{
   contentType = 'html'
   content     = $Content
} 
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

Using html:

Now lets add on to it and make the alert a bit more useful, like giving a link to the change log request form to see if there was any scheduled maintenance for this time.

$Content = "<b><i>⚠️ ALERT:</b></i> Production Server DB-01 is offline!<p>"
$Content = $Content + "Please confim if this is an issue"
$Content = $Content + "<p>For more information, see <a href='<https://corporate.com/changelog>'>Corporate Change Log Requests</a>"
	 
$ChatMessageBody = @{
  contentType = 'html'
  content     = $Content
} 
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

Piping functions with html:

Lets go a little more advanced and pipe in a function and put the results in teams. In this example we get the results from Get-Process, and only show the Name, Id, and CPU usage. How this works is by running Get-Process, selecting those objects, then converting the output to fragmented HTML. This is where instead of showing all of the html code, it only shows it for the table. Then pipe it to Out-String to turn the string array into a single string so that Teams can read it. If you don’t it will output but it will output System.Object[].

$FunctionTest = Get-Process | Select-Object Name, Id, CPU
$Content = ($FunctionTest | ConvertTo-Html -Fragment) | Out-String

$ChatMessageBody = @{
    contentType = 'html'
    content     = $Content
}
	 
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

Yes, here is a one-liner for it, but wanted to show you could have functions before the actual “$Content” if you wanted.

$Content = Get-Process |
Select Name, Id, CPU |
ConvertTo-Html -Fragment |
Out-String

$ChatMessageBody = @{
    contentType = 'html'
    content     = $Content
}
	 
$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody

APIs and html:

Now seeing text, html, and being able to show results from functions is great, but what about APIs? We can integrate them too, I mean who doesn’t want a Dad Joke every day right in there teams chat?? I tried to get Steven’s Judd API, but he wouldn’t give it to me. This is just scratching the surface, but wanted to show you it is possible. Full example right after the explaination.

A quick breakdown of this is it will first try what’s in the “try” block and if there is a terminating error at any part of it, it goes directly to the “catch” block and will run that code. Since we want to get information from icanhazdadjoke api, we have to first put the headers in, then call or “Invoke-RestMethod” the api with its Uri, and this grabs the information and saves it to the $response variable. Now we can use $($response.joke) to grab the actual joke for the example which we will see in the next step.

# Setting up headers and calling API for joke information
try {
    # Get a random dad joke from the API
    $headers = @{
        'Accept' = 'application/json'
        'User-Agent' = 'PowerShell Script (<https://github.com/DevClate/>)'
    }
    
    $response = Invoke-RestMethod -Uri "<https://icanhazdadjoke.com/>" -Headers $headers -Method Get
 

Here we create the Body content with “$jokeContent” as a Here-String(everything between @” and “@) which is a multi-line string literal that preserves all whitespace and line breaks. This makes the design of html easy and ensures it looks like we want in the teams chat. You can see we used a subexpresssion $($response.joke) to pull the actual joke, because if we just used $response.joke it won’t interpolate correctly inside the string.

 $jokeContent = @"
🤣 <b>Daily Dad Joke</b> 🤣<br><br>
<img src="<https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif>" alt="Dad Joke GIF" style="max-width: 200px; height: auto;"><br><br>
$($response.joke)<br><br>
<small><i>Joke ID: $($response.id)</i></small><br>
<small>Source: icanhazdadjoke.com | $(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>
"@

If your not familiar with html formatting, you can see the <b> that starts the bolding of “Daily Dad Joke” and stops bolding with </b>. Since this is a string we are using <br><br> to create line breaks.

🤣 <b>Daily Dad Joke</b> 🤣<br><br>

Here we will put in the code to show the image we want to show with the joke with the width and height.

<img src="<https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif>" alt="Dad Joke GIF" style="max-width: 200px; height: auto;"><br><br>

Also you can see with $(Get-Date -Format ‘MMM dd, yyyy h:mm tt’) to pull the date and time the joke was from.

<small>Source: icanhazdadjoke.com | $(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>

Then as long as there are no terminating errors you will get to the send your new daily joke to teams part, where you will add your $ChatMessageBody and for the content you would use $jokeContent unless you changed the variable for the joke content. Basically this makes it readable to Graph and Teams.

# Send to Teams body
    $ChatMessageBody = @{
    contentType = 'html'
    content     = $jokeContent
    }

After you’ve built the body, now we send it to Teams and output status to our terminal. Make sure you are using the correct ChatId, as you don’t want to send your message to the wrong person, so in this case, lets use our variable we created earlier $MyTestChatID. Then we will add our Body variable to make sure we get the right joke sent with $ChatMesssageBody. As long as all went smoothly we will see the joke in teams, and in our terminal we will see that it was successfully sent and the text version of the joke.

Now if it has a terminating error at any point, we move straight to the “Catch” so this is what the user will see if there is an terminating error. This is basically the same as above, except we add the “Write-Error” with the exception message, and put it a static joke in that gets sent to Teams. As you can see we changed the joke variable to $fallbackJoke and changed the content to $fallbackContent and everything else remains the same.

$ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
    
    Write-Host "✅ Dad joke sent to Teams chat successfully!"
    Write-Host "Joke: $($response.joke)"
catch {
    Write-Error "❌ Failed to get dad joke: $($_.Exception.Message)"
    
    # Fallback joke if API fails
    $fallbackJoke = "Why don't scientists trust atoms? Because they make up everything!"
    
    $fallbackContent = @"
🤣 <b>Dad Joke (Backup)</b> 🤣<br><br>
<img src="<https://media.giphy.com/media/xT9IgG50Fb7Mi0prBC/giphy.gif>" alt="Classic Dad Joke" style="max-width: 200px; height: auto;"><br><br>
$fallbackJoke<br><br>
<small><i>API was unavailable, so here's a classic!</i></small><br>
<small>$(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>
"@
    
    $ChatMessageBody = @{
    contentType = 'html'
    content     = $fallbackContent
    }

    $ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
    
    Write-Host "✅ Fallback dad joke sent to Teams chat!"
}

And here is the full script!

try {
    # Get a random dad joke from the API
    $headers = @{
        'Accept' = 'application/json'
        'User-Agent' = 'PowerShell Script (<https://github.com/DevClate/>)'
    }
    
    $response = Invoke-RestMethod -Uri "<https://icanhazdadjoke.com/>" -Headers $headers -Method Get
    
    # Using HTML img tag with external URL
    $jokeContent = @"
🤣 <b>Daily Dad Joke</b> 🤣<br><br>
<img src="<https://media.giphy.com/media/3oriO0OEd9QIDdllqo/giphy.gif>" alt="Dad Joke GIF" style="max-width: 200px; height: auto;"><br><br>
$($response.joke)<br><br>
<small><i>Joke ID: $($response.id)</i></small><br>
<small>Source: icanhazdadjoke.com | $(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>
"@

    # Send to Teams
    $ChatMessageBody = @{
    contentType = 'html'
    content     = $jokeContent
    }
    
    $ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
    
    Write-Host "✅ Dad joke sent to Teams chat successfully!"
    Write-Host "Joke: $($response.joke)"
}
catch {
    Write-Error "❌ Failed to get dad joke: $($_.Exception.Message)"
    
    # Fallback joke if API fails
    $fallbackJoke = "Why don't scientists trust atoms? Because they make up everything!"
    
    $fallbackContent = @"
🤣 <b>Dad Joke (Backup)</b> 🤣<br><br>
<img src="<https://media.giphy.com/media/xT9IgG50Fb7Mi0prBC/giphy.gif>" alt="Classic Dad Joke" style="max-width: 200px; height: auto;"><br><br>
$fallbackJoke<br><br>
<small><i>API was unavailable, so here's a classic!</i></small><br>
<small>$(Get-Date -Format 'MMM dd, yyyy h:mm tt')</small>
"@
    
    $ChatMessageBody = @{
    contentType = 'html'
    content     = $fallbackContent
    }

    $ChatMessage = New-MgChatMessage -ChatId $MyTestChatID -Body $ChatMessageBody
    
    Write-Host "✅ Fallback dad joke sent to Teams chat!"
}

Advanced Example:

This script below is to show what is possible, and I won’t be going into detail on it, but wanted to give you a good starting point for a mini project. If you have any questions on it or want to work through it, I’d be glad to help out.

It will allow you to show completed and non completed migration status that you can sort by migration status and/or location status. If you wanted to, you could add a filter for department too. The only thing out of the box you need to do is put your ChatID in there and make sure you have the csv file saved.

I may have put in there a way to only have to put the users UPN to send them a message…

<#
.SYNOPSIS
    Get migration status from CSV and optionally send to Teams.

.DESCRIPTION
    Reads a CSV file containing user migration data and filters by location
    and migration status. Optionally sends the results to a Teams chat.

.EXAMPLE
    # All migrated users, all locations
    .\Get-MigrationStatus.ps1 -MigrationStatus migrated

.EXAMPLE
    # Not migrated users, specific location
    .\Get-MigrationStatus.ps1 -MigrationStatus notmigrated -Location Location1

.EXAMPLE
    # Both status, specific location, send to Teams
    .\Get-MigrationStatus.ps1 -MigrationStatus both -Location Location2 -RecipientUpn "manager@contoso.com"

.EXAMPLE
    # All users, all locations, to default chat
    .\Get-MigrationStatus.ps1 -MigrationStatus both

.NOTES
    Business Use Case: Migration status reporting, IT compliance tracking
    Required Scopes (if sending to Teams): Chat.Create, ChatMessage.Send
#>

param(
    [Parameter(Mandatory = $true)]
    [ValidateSet("Migrated", "NotMigrated", "Both")]
    [string]$MigrationStatus,

    [Parameter(Mandatory = $false)]
    [ValidateSet("Location1", "Location2", "Location3", "All")]
    [string]$Location = "All",

    [Parameter(Mandatory = $false)]
    [string]$RecipientUpn,

    [Parameter(Mandatory = $false)]
    [string]$ChatId = "default-chat-id",

    [Parameter(Mandatory = $false)]
    [string]$CsvPath = "$PSScriptRoot\\users.csv"
)

function Get-MigrationData {
    param(
        [string]$Path,
        [string]$Location,
        [string]$MigrationStatus
    )

    if (-not (Test-Path $Path)) {
        throw "CSV file not found: $Path"
    }

    $users = Import-Csv $Path

    # Filter by location
    if ($Location -ne "All") {
        $users = $users | Where-Object { $_.Location -eq $Location }
    }

    # Filter by migration status
    switch ($MigrationStatus) {
        "migrated" {
            $filtered = $users | Where-Object { $_.Migrated -ne "" -and $null -ne $_.Migrated }
        }
        "notmigrated" {
            $filtered = $users | Where-Object { $_.Migrated -eq "" -or $null -eq $_.Migrated }
        }
        "both" {
            $filtered = $users
        }
    }

    # Calculate counts
    $totalCount = ($users | Measure-Object).Count
    $migratedCount = ($users | Where-Object { $_.Migrated -ne "" -and $null -ne $_.Migrated } | Measure-Object).Count
    $pendingCount = $totalCount - $migratedCount

    return @{
        Users = $filtered
        TotalCount = $totalCount
        MigratedCount = $migratedCount
        PendingCount = $pendingCount
    }
}

function Send-MigrationStatusToTeams {
    param(
        [object]$Data,
        [string]$Location,
        [string]$MigrationStatus,
        [string]$RecipientUpn,
        [string]$ChatId
    )

    # Build HTML table
    $tableRows = ""
    foreach ($user in $Data.Users) {
        $migratedDisplay = if ($user.Migrated) { $user.Migrated } else { "<em>Pending</em>" }
        $tableRows += @"
<tr>
    <td>$($user.Name)</td>
    <td>$($user.Email)</td>
    <td>$($user.Location)</td>
    <td>$($user.Department)</td>
    <td>$migratedDisplay</td>
</tr>
"@
    }

    # Build title based on status and location
    $statusTitle = switch ($MigrationStatus) {
        "Migrated" { "Migrated" }
        "NotMigrated" { "Not Migrated" }
        "Both" { "All" }
    }

    $locationTitle = if ($Location -eq "All") { "All Locations" } else { $Location }

    $htmlContent = @"
<h2>📊 Migration Status - $statusTitle ($locationTitle)</h2>
<table border="1" cellpadding="5">
<tr>
    <th>Name</th>
    <th>Email</th>
    <th>Location</th>
    <th>Department</th>
    <th>Migrated</th>
</tr>
$tableRows
</table>
<h3>📈 Summary</h3>
<ul>
<li><strong>Total:</strong> $($Data.TotalCount)</li>
<li><strong>Migrated:</strong> $($Data.MigratedCount)</li>
<li><strong>Pending:</strong> $($Data.PendingCount)</li>
</ul>
<p><em>Report generated: $(Get-Date -Format "MMMM dd, yyyy HH:mm")</em></p>
"@

    # Check if already connected to Graph
    $graphContext = Get-MgContext -ErrorAction SilentlyContinue
    if ($graphContext) {
        Write-Host "Already connected to Graph as $($graphContext.Account)" -ForegroundColor Gray
        $connected = $true
    }
    else {
        # Connect to Graph
        Connect-MgGraph -Scopes "Chat.Create", "ChatMessage.Send" -ErrorAction Stop
        $connected = $true
    }

    # Send to appropriate chat
    if ($RecipientUpn) {
        # Create new 1:1 chat
        Write-Host "Creating chat with $RecipientUpn..."
        $chat = New-MgChat -ChatType OneOnOne -Members @(
            @{
                "@odata.type" = "#microsoft.graph.aadUserConversationMember"
                "roles" = @("owner")
                "user@odata.bind" = "<https://graph.microsoft.com/v1.0/users('$((Get-MgContext).Account>)')"
            },
            @{
                "@odata.type" = "#microsoft.graph.aadUserConversationMember"
                "roles" = @("owner")
                "user@odata.bind" = "<https://graph.microsoft.com/v1.0/users('$RecipientUpn')>"
            }
        )
        $targetChatId = $chat.Id
    }
    else {
        # Use default chat
        Write-Host "Using default chat: $ChatId"
        $targetChatId = $ChatId
    }

    # Send message
    $message = New-MgChatMessage -ChatId $targetChatId -Body @{
        contentType = "html"
        content = $htmlContent
    }

    # Only disconnect if we initiated the connection
    if (-not $graphContext) {
        Disconnect-MgGraph -ErrorAction SilentlyContinue
    }

    return $message.Id
}

function Show-MigrationStatus {
    param(
        [object]$Data,
        [string]$Location,
        [string]$MigrationStatus
    )

    $statusTitle = switch ($MigrationStatus) {
        "Migrated" { "Migrated" }
        "NotMigrated" { "Not Migrated" }
        "Both" { "All" }
    }

    $locationTitle = if ($Location -eq "All") { "All Locations" } else { $Location }

    Write-Host "`n=== Migration Status - $statusTitle ($locationTitle) ===" -ForegroundColor Cyan

    # Display table
    $Data.Users | Format-Table -AutoSize

    # Summary
    Write-Host "`n📊 Summary:" -ForegroundColor Green
    Write-Host "  Total:    $($Data.TotalCount)"
    Write-Host "  Migrated: $($Data.MigratedCount)"
    Write-Host "  Pending:  $($Data.PendingCount)"
}

# Main execution
Write-Host "Reading migration data from: $CsvPath" -ForegroundColor Gray

# Get data
$migrationData = Get-MigrationData -Path $CsvPath -Location $Location -MigrationStatus $MigrationStatus

# Display locally
Show-MigrationStatus -Data $migrationData -Location $Location -MigrationStatus $MigrationStatus

# Send to Teams if requested
if ($RecipientUpn -or $ChatId -ne "default-chat-id") {
    Write-Host "`nSending to Teams..." -ForegroundColor Yellow

    try {
        $messageId = Send-MigrationStatusToTeams -Data $migrationData -Location $Location -MigrationStatus $MigrationStatus -RecipientUpn $RecipientUpn -ChatId $ChatId
        Write-Host "✓ Message sent to Teams! (ID: $messageId)" -ForegroundColor Green
    }
    catch {
        Write-Warning "Failed to send to Teams: $_"
    }
}
else {
    Write-Host "`n(No Teams notification - add -RecipientUpn or -ChatId to send)" -ForegroundColor Gray
}

And here is an example for showing Migrated Devices only at Location2.

The full script is also located at Migration Example

There we have it, hope you enjoyed this blog post and learned something from it that will make your life easier. Think of any repetitive tasks, if you want to know when a long script is finished have it notify you chat, or have an agent pick what you and/or your team is having for lunch and put it in the team chat! Would love to hear how you used this blog post or how you incorporate PowerShell and Teams chat/channels.

I created a Github repository for these scripts and for using PowerShell and Teams with other examples and learning as well – Teams GitHub Repo

Have a great day and keep learning and sharing!

Tagged With: 365, Automation, PowerShell, Reporting, Teams

EntraFIDOFinder: New Web UI and Over 70 New Authenticators

January 26, 2026 by ClaytonT Leave a Comment

You read that right, over 70 new authenticators are now approved for Entra Attestation and have been add to the web ui and the PowerShell module! I knew they had to be holding back after these last few updates. Also I’ve updated the web UI and curious of your thoughts. I wanted to make it more modern and easier to view, especially the details window.

Here are a few of the new authenticators, but check the change log for the full list.

Android Authenticator

AAGUID: b93fd961-f2e6-462f-b122-82002247de78

Supported Interfaces:

InterfaceSupported
Biometric✅
USB❌
NFC❌
BLE❌

ATLKey Authenticator

AAGUID: 019614a3-2703-7e35-a453-285fd06c5d24

Supported Interfaces:

InterfaceSupported
Biometric❌
USB✅
NFC❌
BLE❌

Dapple Authenticator from Dapple Security Inc.

AAGUID: 6dae43be-af9c-417b-8b9f-1b611168ec60

Supported Interfaces:

InterfaceSupported
Biometric❌
USB❌
NFC❌
BLE❌

Deepnet SafeKey/Classic (FP)

AAGUID: e41b42a3-60ac-4afb-8757-a98f2d7f6c9f

Supported Interfaces:

InterfaceSupported
Biometric✅
USB❌
NFC❌
BLE❌

Deepnet SafeKey/Classic (USB)

AAGUID: b9f6b7b6-f929-4189-bca9-dd951240c132

Supported Interfaces:

InterfaceSupported
Biometric❌
USB❌
NFC❌
BLE❌

ellipticSecure MIRkey USB Authenticator

AAGUID: eb3b131e-59dc-536a-d176-cb7306da10f5

Supported Interfaces:

InterfaceSupported
Biometric❌
USB✅
NFC❌
BLE❌

Ensurity AUTH BioPro Desktop

AAGUID: 9eb85bb6-9625-4a72-815d-0487830ccab2

Supported Interfaces:

InterfaceSupported
Biometric✅
USB✅
NFC❌
BLE❌

Ensurity AUTH TouchPro

AAGUID: 50cbf15a-238c-4457-8f16-812c43bf3c49

Supported Interfaces:

InterfaceSupported
Biometric❌
USB✅
NFC❌
BLE❌

I’ve been working on better ways to see what keys have been added, removed, or modified, as well as approving valid vendors. It’s not perfected yet, but when I get closer, I’ll do a demo of it.

Let me know what you think of the new design and what functionality you wish it had. Also are there any keys you wish were attestation approved for Entra?

Where to get:
PowerShell Gallery: https://www.powershellgallery.com/packages/EntraFIDOFinder/0.0.22
Github: https://github.com/DevClate/EntraFIDOFinder/tree/main
Web UI: https://devclate.github.io/EntraFIDOFinder/Explorer/

Appreciate you taking the time and stay safe out there!

Tagged With: 365, Automation, Entra, EntraFIDOFinder, PowerShell, Reporting, Security

First Snowfall of the Season

November 22, 2024 by ClaytonT Leave a Comment

First snowfall in NY! Tell me about the first time PowerShell just clicked… what was that moment?

Mine was adding new users to AD(it’s usually AD or Exchange, right?) and remembering the standard fields needed to be filled out with their default values. Also when we had multiple new hires it was so time consuming clicking through the GUI. That’s when I learned how to create an AD user with a CSV. It was life changing and realized I needed to do more of this.

Now I have a module that is meant to create Microsoft 365 test environments but can be used in production to create users, groups, and much more from an excel file without even having excel on the computer! You can check out the module below.

We all start somewhere and love hearing that light bulb moment that triggers the snowball effect!

365AutomatedLab
Powershell Gallery: https://www.powershellgallery.com/packages/365AutomatedLab/2.11.0
GitHub: https://github.com/DevClate/365AutomatedLab

Tagged With: 365, 365AutomatedLab, AD, Automation, PowerShell

GitHub Actions and PowerShell: The Underdog

November 15, 2024 by ClaytonT Leave a Comment

Remember how I mentioned how GitHub actions are underrated? I’m going to show at a high level how GitHub Actions with PowerShell can save you time and be more efficient.

What does it do?

  • Web scrapes website into PowerShell Object
  • Compares the web scrape to the json “database” file(FidoKeys.json) of all the keys
    • Matches by AAGUID
      • Adds to FidoKeys.json if doesn’t exit
      • Removes from FidoKeys.json if not in the web scrape anymore
    • If New key
      • Checks the first word in the description to see if that matches with the Valid Vendor List(Valid_Vendors.json) and if it matches adds the Vendor
        • If it doesn’t have a valid vendor it will create a GitHub issue for that vendor and key
    • If Existing key
      • Checks to see if any of the properties have changed and updates FidoKeys.json
    • If Missing key
      • If key is no longer in the web scrape, it removes it from FidoKeys.json
  • Updates Merge dates on FidoKeys.json
    • If it checks to see if there are any changes and there are no changes, it only updates databaseLastChecked
    • If it checks to see if there are any changes and there are changes, it updates databaseLastChecked and databaseLastUpdated
  • Creates GitHub Issues for Invalid Vendors
    • If a vendor isn’t in the valid_vendors.json list or if the vendor name is blank, it will automatically create a GitHub issue for that key and invalid vendor name
    • Assigns myself at the owner of the issue
  • Closes GitHub Issues for Valid Vendors
    • If a vendor now matches with a vendor name in valid_vendors.json, then it will automatically close the issue for the now valid vendor
  • Updates merge_log.md
    • It only updates the merge_log.md when a new change occurs from the previous check
  • Updates detailed_log.txt
    • This is written to every time, but if it is the same as previous check it will write “No changes detected during this run”

It does that automatically once every day, I could do it more, but didn’t think it was necessary. Best of all, this is all done for free. Since it is a public repository all GitHub actions are free. Today, I’ll go over the GitHub Action, but I’ll do another post to go into detail on the PowerShell script side.

Let’s start from the beginning. We first have to name the GitHub Action so we will use “Main Entra Merge” in this case as this is for the Main branch and is merging keys for Entra.

name: Main Entra Merge

Then we have to determine when it will run. What I like to do in the beginning is always have a “workflow_dispatch:” as this will always allow you to test it manually and you don’t have to wait for any other triggers. Then in this case I have it run at midnight, and anytime there is a push or pull request to the main branch

on:
  workflow_dispatch:
  schedule:
    - cron: '0 0 * * *'
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

Next, we have to define what OS do we want to run on. I usually only use ubuntu-latest unless I have a real use to use Mac or Windows, as if I remember right, Windows is 3 times the cost to run in Actions, and Mac is 9 times. I know it’s free for me, but why use resources that aren’t needed. You can as well uses different versions of Ubuntu too (GitHub Runners). Also you need to have “jobs:” and then the name of the job or it won’t work. Also spacing is very important with Yaml. It has burned me a few times.

jobs:
  merge-fido-data:
    runs-on: ubuntu-latest

The workflow begins by checking out the repository to the runner using the actions/checkout@v4 action. This step ensures that all necessary files and scripts are available for subsequent steps.

- name: Checkout repository
  uses: actions/checkout@v4
  with:
    fetch-depth: 0
    ref: main

Next, it installs the PSParseHTML PowerShell module, which is essential for parsing HTML content in the scripts that follow.

- name: Install PSParseHTML Module
  shell: pwsh
  run: Install-Module -Name PSParseHTML -Force -Scope CurrentUser

The workflow runs a series of custom PowerShell scripts that perform data validation and merging:

  • Validation Scripts: Test-GHValidVendor.ps1 and Test-GHAAGUIDExists.ps1 ensure that the vendor information and AAGUIDs are valid.
  • Data Export and Merge: Export-GHEntraFido.ps1 exports data from Microsoft Entra, and Merge-GHFidoData.ps1 merges it with existing data.
- name: Run Merge-GHFidoData Script
  shell: pwsh
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    GITHUB_REPOSITORY: ${{ github.repository }}
  run: |
    Import-Module PSParseHTML
    . ./Scripts/Test-GHValidVendor.ps1
    . ./Scripts/Test-GHAAGUIDExists.ps1
    . ./Scripts/Export-GHEntraFido.ps1
    . ./Scripts/Merge-GHFidoData.ps1
- name: Read Environment Variables
 shell: bash
 run: |
 if [ -f ./Scripts/env_vars.txt ]; then
 echo "Setting environment variables from env_vars.txt"
 cat ./Scripts/env_vars.txt >> $GITHUB_ENV
 else
 echo "env_vars.txt not found."
 fi

For transparency, the workflow outputs the values of key environment variables, aiding in debugging and verification. This could be removed, but leaving for now for testing.

- name: Debug - Display ISSUE_ENTRIES, KEYS_NOW_VALID, and VENDORS_NOW_VALID Environment Variables
 shell: bash
 run: |
 echo "ISSUE_ENTRIES: $ISSUE_ENTRIES"
 echo "KEYS_NOW_VALID: $KEYS_NOW_VALID"
 echo "VENDORS_NOW_VALID: $VENDORS_NOW_VALID"

Utilizing actions/github-script@v6, the workflow runs a JavaScript script that automates issue creation and closure based on validation results.

  • Creates Issues: For any data discrepancies found.
  • Closes Issues: If previously reported issues are now resolved.
  • Assigns Issues: Automatically assigns issues to DevClate for certain labels.
- name: Close Fixed Issues and Create New Issues
      uses: actions/github-script@v6
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        script: |
          const issueEntriesRaw = process.env.ISSUE_ENTRIES || '';
          const issueEntries = issueEntriesRaw.split('%0A').map(entry => decodeURIComponent(entry)).filter(entry => entry.trim() !== '');
          if (issueEntries.length === 0) {
            console.log('No new issue entries found.');
          } else {
            for (const entry of issueEntries) {
              const parts = entry.split('|');
              if (parts.length < 2) {
                console.error(`Invalid entry format: ${entry}`);
                continue;
              }
              const [issueTitle, issueBody, issueLabel] = parts;
              console.log(`Processing issue: ${issueTitle}`);
              const { data: issues } = await github.rest.issues.listForRepo({
                owner: context.repo.owner,
                repo: context.repo.repo,
                state: 'open',
                labels: 'auto-generated',
              });
              const existingIssue = issues.find(issue => issue.title === issueTitle);
              if (!existingIssue) {
                const assignees = [];
                if (issueLabel === 'InvalidVendor' || issueLabel === 'DuplicateEntry') {
                  assignees.push('DevClate');
                }
                await github.rest.issues.create({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  title: issueTitle,
                  body: issueBody,
                  labels: issueLabel ? ['auto-generated', issueLabel] : ['auto-generated'],
                  assignees: assignees,
                });
                console.log(`Issue created: ${issueTitle}`);
              } else {
                console.log(`Issue already exists: ${issueTitle}`);
              }
            }
          }

          // Close issues for keys (AAGUIDs) that are now valid
          const keysNowValidRaw = process.env.KEYS_NOW_VALID || '';
          const keysNowValid = keysNowValidRaw.split('%0A').map(entry => decodeURIComponent(entry)).filter(entry => entry.trim() !== '');
          if (keysNowValid.length === 0) {
            console.log('No keys have become valid.');
          } else {
            console.log('Keys that are now valid:', keysNowValid);
            for (const aaguid of keysNowValid) {
              const { data: issues } = await github.rest.issues.listForRepo({
                owner: context.repo.owner,
                repo: context.repo.repo,
                state: 'open',
                labels: ['auto-generated', 'InvalidVendor'],
                per_page: 100,
              });
              for (const issue of issues) {
                if (issue.title.includes(aaguid)) {
                  await github.rest.issues.update({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    issue_number: issue.number,
                    state: 'closed',
                    state_reason: 'completed',
                  });
                  await github.rest.issues.createComment({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    issue_number: issue.number,
                    body: `The vendor for key with AAGUID '${aaguid}' is now valid. This issue is being closed automatically.`,
                  });
                  console.log(`Closed issue for key with AAGUID: ${aaguid}`);
                }
              }
            }
          }

The workflow extracts the newest entries from merge_log.md and detailed_log.txt and appends them to the GitHub Actions summary for easy access.

- name: Display Merge Log
  shell: bash
  run: |
    # Extract and format logs

Configuring Git ensures that any commits made by the workflow are properly attributed.

- name: Configure Git
  run: |
    git config --global user.name 'D--ate'
    git config --global user.email 'c---@--t.com'

Finally, the workflow commits the changes made to the data and logs, pushing them back to the main branch.

- name: Commit changes
 run: |
 git add Assets/FidoKeys.json merge_log.md detailed_log.txt
 git commit -m "Update Fidokeys.json, merge_log.md, and detailed_log.txt" || echo "No changes to commit"

- name: Push changes
 uses: ad-m/github-push-action@v0.6.0
 with:
 github_token: ${{ secrets.GITHUB_TOKEN }}
 branch: main

And that’s it! It’s completely ok to not fully understand it, but wanted to give you a quick breakdown on how it works in case you have a project that you are working on or have been holding off because you didn’t know this is possible. If you have any tips, I’d be glad to talk as well as I’m always open for improvement and learning new ideas.

If you want to see this in action check out https://github.com/DevClate/EntraFIDOFinder

I do have a PowerShell module that works with this and allows you to find/filter which FIDO2 Keys are Entra Attestation approved, that can be downloaded there or on the PowerShell Gallery

And I even made an interactive website as well at https://devclate.github.io/EntraFIDOFinder/Explorer/

I will be doing a breakdown of the PowerShell of this in part 2!

Hope this was helpful and have a great day!

Tagged With: 365, Automation, Entra, FIDO2, GitHub Actions, PowerShell, Reporting

GitHub Copilot Password Warning

October 4, 2024 by ClaytonT Leave a Comment

Did you know that GitHub Copilot is now sensing hard coded credentials and giving you a warning? It’s not perfect, but even if something looks like hard coded creds it will flag it, as on another script I had, it contained numbers that looked like they could be private, and it gave me a warning about it. Honestly, I rather find more potentials credentials then not. That’s it for today, hope you have a great day!

Tagged With: Automation, Copilot, GitHub, Passwords, Security

  • Page 1
  • Page 2
  • Page 3
  • Go to Next Page »

Primary Sidebar

Clayton Tyger

Tech enthusiast dad who has lost 100lbs and now sometimes has crazy running/biking ideas. Read More…

Find Me On

  • Email
  • GitHub
  • Instagram
  • LinkedIn
  • Twitter

Recent Posts

  • Learning ValidateSet in PowerShell: Valid Values Only
  • Teams Chat and PowerShell – How to add value!
  • EntraFIDOFinder: New Web UI and Over 70 New Authenticators
  • January 19, 2026 Updates to EntraFIDOFinder
  • v0.0.20 EntraFIDOFinder is out

Categories

  • 365
  • Active Directory
  • AI
  • AzureAD
  • BlueSky
  • Cim
  • Dashboards
  • Documentation
  • Entra
  • Get-WMI
  • Learning
  • Module Monday
  • Nutanix
  • One Liner Wednesday
  • Passwords
  • PDF
  • Planner
  • PowerShell
  • Read-Only Friday
  • Reporting
  • Security
  • Uncategorized
  • Windows
  • WSUS

© 2026 Clatent