Skip to content

Instantly share code, notes, and snippets.

@noahpeltier
Created February 18, 2026 23:41
Show Gist options
  • Select an option

  • Save noahpeltier/6bb5dd1b1d8341b5b65f0ad7b0f581ff to your computer and use it in GitHub Desktop.

Select an option

Save noahpeltier/6bb5dd1b1d8341b5b65f0ad7b0f581ff to your computer and use it in GitHub Desktop.
if (!(get-module JiraPS -ListAvailable)) {
Write-Host "Hang on, i'm installing the JiraModule..."
Install-Module JiraPS -Force | out-null
}
if (!(get-module NinjaOne -ListAvailable)) {
"Hang on, i'm installing the Ninja module..."
Install-Module NinjaOne -Force | out-null
}
#region Connections
function Connect-365Admin {
"Connecting to Graph and Exchange..."
if (!(Get-mgcontext)) {
Connect-MgGraph -NoWelcome
}
if (!(Get-command Get-mailbox -ErrorAction SilentlyContinue)) {
Connect-ExchangeOnline -UserPrincipalName (Get-mgcontext).Account -ShowBanner:$False
}
}
function Connect-SahNinja {
Import-SahModuleConfig
Connect-NInjaOne -UseClientAuth `
-ClientId $Global:SahModuleConfig.ninja.clientId `
-ClientSecret ($Global:SahModuleConfig.ninja.clientSecret | ConvertFrom-SecureString -AsPlainText) `
-Instance us
}
function Connect-SahJira {
Import-SahModuleConfig
$credential = [pscredential]::new(
$Global:SahModuleConfig.jira.username,
$Global:SahModuleConfig.jira.apiKey
)
New-JiraSession -Credential $credential | Out-Null
}
#endregion
#region Servers
function Get-SahServer {
param(
$Hostname
)
Connect-SahNinja
$orgs = Get-NinjaOneOrganisation | group-object -Property id -AsHashTable -AsString
$Results = get-ninjaonedevice -deviceId (Find-NinjaOneDevice -searchQuery $Hostname -limit 1).devices.id
$servers = @($Results) | where { $_.systemName -Like "$Hostname*" }
foreach ($server in $servers) {
$Function = switch ($server) {
{ $_.systemName -match "SQL" } { "SQL Server" }
{ $_.systemName -match "FILE|FIL|STORAGE" } { "File Server" }
{ $_.systemName -match "R7" } { "Rapid 7 Server" }
{ $_.systemName -match "IIS" } { "IIS Server" }
{ $_.systemName -match "IIS" -and $_.systemName -match "SQL" } { "IIS and SQL Server" }
{ $_.systemName -match "-HVH-|HYPER" } { "Hyper-v Host" }
{ $_.systemName -match "ADCS|CS0" } { "Certificate Server" }
{ $_.systemName -match "Web|App" } { "Web Host" }
{ $_.systemName -match "backup|veeam|vms" } { "Backups Server" }
{ $_.systemName -match "Dev|Test|LAB" } { "Dev or Test Server" }
{ $_.systemName -match "PRN|PRINT" } { "Print Server" }
{ $_.systemName -match "Util|Script|PYT|CSC" } { "General Utilities Server" }
{ $_.systemName -match "DC|AD" -and $_.system.DomainRole -match "Domain Controller" } { "Domain Controler" }
default { "Unknown" }
}
$isCloud = switch ($server.system) {
{ $_.biosSerialNumber -like "0000-*" -or $_.Manufacturer -match "Amazon" } { $true }
default { $false }
}
if ($server.offline) {
$uptime = "N/A"
}
else {
$uptime = ConvertTo-HumanDuration -TimeSpan ((get-date) - ($server.os.lastBootTime | convertfrom-EpocTime -ToLocalTime)) -MaxParts 1
}
[pscustomobject]@{
Systemname = $server.systemName
OS = $server.os.name
Uptime = $uptime
NinjaOnline = !$server.offline
LANOnline = ([System.Net.NetworkInformation.Ping]::new().Send($server.ipAddresses[0]).Status -eq "Success")
LastLoggedInUser = $server.lastLoggedInUser
CompanyTLA = ($orgs[$server.organizationId.ToString()]).Name
Companyname = ($orgs[$server.organizationId.ToString()]).description
DomainRole = $server.system.DomainRole
IPV4 = ($server.ipAddresses | % { [System.Net.IPAddress]::Parse($_) } | where { $_.AddressFamily -eq "InterNetwork" }).IPAddressToString -join ","
BusinessUse = ($Function -join ",")
Type = $server.system.model
isCloud = $isCloud
HyperVHost = (Get-NinjaOneDeviceCustomFields -deviceId $server.id).hypervhost
}
}
}
#endregion
#region User discovery
function Get-SahUser {
[CmdletBinding()]
param(
[string]$UserId,
[Alias('Properties', 'AdditionalProperties')]
[string[]]$Property,
[string]$Filter,
[switch]$SkipADLookup
)
$baseProps = @(
"DisplayName",
"GivenName",
"SurName",
"UserPrincipalName",
"Mail",
"JobTitle",
"Department",
"CompanyName",
"OnPremisesSyncEnabled",
"OnPremisesSamAccountName",
"OnPremisesDomainName",
"OnPremisesDistinguishedName",
"AccountEnabled"
)
# Accept both array input and comma-separated input
$extraProps = @(
$Property |
ForEach-Object { $_ -split '\s*,\s*' } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
$props = @($baseProps + $extraProps | Select-Object -Unique)
# Escape single quotes for OData filter safety
$escapedUserId = $UserId.Replace("'", "''")
if (!$Filter -and $UserId) {
$filter = "onPremisesSamAccountName eq '$escapedUserId' or userPrincipalName eq '$escapedUserId' or startsWith(displayName, '$escapedUserId') or startsWith(userprincipalname, '$UserId@') or proxyAddresses/any(x:x eq 'SMTP:$Userid') or proxyAddresses/any(x:x eq 'smtp:$Userid')"
}
$result = Get-MgUser -Filter $filter -Property $props -ConsistencyLevel eventual -CountVariable count
if (-not $result) { return }
# Promote values from AdditionalProperties so Select-Object can show them
foreach ($u in $result) {
foreach ($p in $props) {
if (-not $u.PSObject.Properties[$p] -and $u.AdditionalProperties.ContainsKey($p)) {
$u | Add-Member -NotePropertyName $p -NotePropertyValue $u.AdditionalProperties[$p] -Force
}
}
if (!$u.OnPremisesSyncEnabled -and !$SkipADLookup) {
$ADAccount = Resolve-ADUser -SearchString ("{0} {1}" -f $u.givenname, $u.surname)
if ($ADAccount -is [array]) {
$ADAccount = ($ADAccount | Out-GridView -PassThru -Title "Multiple results")
}
if ($ADAccount) {
$u.OnPremisesSamAccountName = $ADAccount.samaccountname
$u.OnPremisesDistinguishedName = $ADAccount.DistinguishedName
$u.OnPremisesDomainName = (Get-DomainFromDN -DistinguishedName $ADAccount.DistinguishedName)
}
}
}
$result | Select-Object -Property $props
}
function Get-SahWritableDc {
param(
[Parameter(Mandatory)]
[string]$DnOrDomain,
[pscredential]$Credential
)
$domain = if ($DnOrDomain -like '*,DC=*') {
($DnOrDomain -split ',DC=' | Select-Object -Skip 1) -join '.'
}
else {
$DnOrDomain
}
if ($Credential) {
(Get-ADDomainController -DomainName $domain -Discover -Writable -Credential $Credential).HostName
}
else {
(Get-ADDomainController -DomainName $domain -Discover -Writable).HostName
}
}
function Get-SahUserDisabledOu {
param(
[string]$UserId,
[pscredential]$Credential
)
$mguser = Get-SahUser -UserId $UserId
$dc = if ($Credential) {
Get-SahWritableDc -DnOrDomain $mguser.OnPremisesDistinguishedName -Credential $Credential
}
else {
Get-SahWritableDc -DnOrDomain $mguser.OnPremisesDistinguishedName
}
$ouObj = if ($Credential) {
Get-ADOrganizationalUnit -Filter "name -like '*Users*Disabled*'" -Server $dc -Credential $Credential |
Select-Object -First 1
}
else {
Get-ADOrganizationalUnit -Filter "name -like '*Users*Disabled*'" -Server $dc |
Select-Object -First 1
}
if ($ouObj) {
return $ouObj.DistinguishedName
}
if ($mguser.OnPremisesDistinguishedName) {
return ($mguser.OnPremisesDistinguishedName -replace '^CN=.*?,(?=OU=|DC=)')
}
throw "Could not resolve disabled OU for '$UserId'."
}
function Get-DomainFromDN {
param (
[Parameter(Mandatory)]
[string]$DistinguishedName
)
([regex]::Matches($DistinguishedName, '(?:^|,)DC=([^,]+)') |
ForEach-Object { $_.Groups[1].Value }) -join '.'
}
function Resolve-AdUser {
param(
$SearchString,
$MatchThreshold = 70
)
$Domains = Get-TrustedDomains
$Results = $Domains | Foreach-Object -Parallel {
$ProgressPreference = 'SilentlyContinue'
$part = ((-split $using:SearchString)[-1]).trim()
if (([System.Net.NetworkInformation.Ping]::new().Send($_).Status -eq "Success")) {
Get-ADUser -Filter "anr -eq '$part'" -Server $_
}
}
$Results | where {
(Get-StringSimilarity -A $_.name -B "$SearchString") -ge $MatchThreshold -and
$_.Name -notmatch 'admin' -and
$_.SamAccountName -notmatch 'admin' -and
$_.DistinguishedName -notmatch 'admin'
}
}
#endregion
#region User manage
function Disable-SahUser {
param(
[Parameter(ValueFromPipeline)]
$UserId,
[pscredential]$Credential
)
if ($UserId -is [pscustomobject]) {
$UserAccount = $UserId
}
else {
$UserAccount = Get-SahUser -UserId $UserId
}
if ($UserAccount.OnPremisesSyncEnabled) {
try {
if ($Credential) {
Disable-ADAccount -Identity $UserAccount.OnpremisesSamaccountname -Server $UserAccount.OnpremisesDomainName -Credential $Credential
}
else {
Disable-ADAccount -Identity $UserAccount.OnpremisesSamaccountname -Server $UserAccount.OnpremisesDomainName
}
Write-LogMsg "Disabled $($UserAccount.DisplayName)"
}
catch {
Write-LogMsg "Disabled $($mguser.DisplayName): $_" -LogLevel FAIL
}
}
else {
try {
Update-MgUser -UserId $UserAccount -AccountEnabled $false
Write-LogMsg "Disabled $($UserAccount.DisplayName)"
}
catch {
Write-LogMsg "Disabled $($mguser.DisplayName): $_" -LogLevel FAIL
}
}
(Get-SahUser -UserId $UserAccount.userprincipalname)
}
function Unlock-SahUser {
param(
$UserId
)
$SahUser = Get-SahUser -UserId $UserId
$DCs = (Get-ADDomain).ReplicaDirectoryServers
$DCs | ForEach-Object -Parallel {
$ProgressPreference = 'SilentlyContinue'
Unlock-ADAccount -Identity $using:SahUser.OnPremisesSamAccountName -Server $_
}
}
function Set-SahUserRandomPassword {
param(
$UserId
)
$mguser = Get-SahUser -UserId $UserId
$splat = @{
Identity = $mguser.OnpremisesSamaccountname
Server = $Mguser.OnPremisesDomainName
}
$PW = New-SecureRandomPassword
Set-ADAccountPassword @splat -Reset -NewPassword $PW
}
function Set-PrimarySMTPAddress {
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[Alias('samaccountname', 'OnPremisesSamAccountName')]
[object]$Identity,
[Parameter(Mandatory)]
[string]$NewPrimarySMTPAddress,
[Parameter(Mandatory, ValueFromPipeline)]
[Alias('OnPremisesDomainName')]
[string]$Server,
[pscredential]$Credential
)
begin {
$commonParams = @{}
if ($Server) { $commonParams.Server = $Server }
if ($Credential) { $commonParams.Credential = $Credential }
}
process {
# Normalize identity into an ADUser with ProxyAddresses loaded
$adUser =
if ($Identity -is [Microsoft.ActiveDirectory.Management.ADUser]) {
if (-not $Identity.ProxyAddresses) {
Get-ADUser -Identity $Identity.DistinguishedName -Properties ProxyAddresses @commonParams
}
else {
$Identity
}
}
else {
Get-ADUser -Identity $Identity -Properties ProxyAddresses @commonParams
}
if (-not $adUser) {
Write-Error "User '${Identity}' not found."
return
}
$newPrimary = $NewPrimarySMTPAddress.Trim()
# Build a case-insensitive set to dedupe, preserving order as best we can
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$result = New-Object System.Collections.Generic.List[string]
foreach ($p in ($adUser.ProxyAddresses | ForEach-Object { $_.ToString() })) {
# demote any current primary SMTP
$normalized = $p -creplace '^SMTP:', 'smtp:'
if ($seen.Add($normalized)) {
$null = $result.Add($normalized)
}
}
# Remove any existing entries for the new address (smtp/SMTP) then add as primary
$smtpValue = "smtp:${newPrimary}"
$SMTPValue = "SMTP:${newPrimary}"
for ($i = $result.Count - 1; $i -ge 0; $i--) {
if ($result[$i].Equals($smtpValue, [System.StringComparison]::OrdinalIgnoreCase) -or
$result[$i].Equals($SMTPValue, [System.StringComparison]::OrdinalIgnoreCase)) {
$result.RemoveAt($i)
}
}
# Insert primary SMTP at the front (common convention); you can append if you prefer
$result.Insert(0, $SMTPValue)
if ($PSCmdlet.ShouldProcess($adUser.SamAccountName, "Set primary SMTP to ${newPrimary}")) {
Set-ADUser -Identity $adUser -Replace @{ ProxyAddresses = $result.ToArray() } @commonParams
}
[pscustomobject]@{
SamAccountName = $adUser.SamAccountName
DistinguishedName = $adUser.DistinguishedName
NewPrimarySMTPAddress = $newPrimary
ProxyAddresses = $result
}
}
}
function Add-ProxyAddress {
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[object]$Identity,
[Parameter(Mandatory)]
[string]$ProxyAddress,
[switch]$IsPrimary,
[string]$Server,
[pscredential]$Credential
)
begin {
if (-not $Server) { $Server = (Get-ADDomain).DNSRoot }
$commonParams = @{ Server = $Server }
if ($Credential) { $commonParams.Credential = $Credential }
}
process {
$adUser =
if ($Identity -is [Microsoft.ActiveDirectory.Management.ADUser]) {
if (-not $Identity.ProxyAddresses) {
Get-ADUser -Identity $Identity.DistinguishedName -Properties ProxyAddresses @commonParams
}
else {
$Identity
}
}
else {
Get-ADUser -Identity $Identity -Properties ProxyAddresses @commonParams
}
if (-not $adUser) {
Write-Error "User '${Identity}' not found."
return
}
$addr = $ProxyAddress.Trim()
$smtpValue = "smtp:${addr}"
# Add only if not already present (case-insensitive)
$current = @($adUser.ProxyAddresses | ForEach-Object { $_.ToString() })
$exists = $current | Where-Object { $_.Equals($smtpValue, [System.StringComparison]::OrdinalIgnoreCase) -or
$_.Equals(("SMTP:${addr}"), [System.StringComparison]::OrdinalIgnoreCase) }
if (-not $exists) {
if ($PSCmdlet.ShouldProcess($adUser.SamAccountName, "Add proxy address ${smtpValue}")) {
Set-ADUser -Identity $adUser -Add @{ ProxyAddresses = $smtpValue } @commonParams
}
}
if ($IsPrimary) {
Set-PrimarySMTPAddress -Identity $adUser -NewPrimarySMTPAddress $addr -Server $Server -Credential $Credential
}
else {
$adUser # return user object for pipeline chaining if desired
}
}
}
function New-SAHUserTemplate {
@{
companyTla = ""
manager = ""
title = ""
adGroups = @()
entraGroups = @()
entraLicenses = @()
adServer = ""
}
}
function Get-ADComputerBitLocker {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ComputerName,
$Credential
)
try {
$Computer = Get-ADComputer -Identity $ComputerName -Properties DistinguishedName
}
catch {
Write-Warning "Computer '$ComputerName' not found in Active Directory."
return
}
$RecoveryObjects = Get-ADObject -Filter 'objectClass -eq "msFVE-RecoveryInformation"' `
-SearchBase $Computer.DistinguishedName `
-Properties 'msFVE-RecoveryPassword', 'msFVE-RecoveryGuid', 'whenCreated'
if (-not $RecoveryObjects) {
Write-Output "No BitLocker recovery keys found for '$ComputerName'."
return
}
$RecoveryObjects | Select-Object `
@{Name = 'ComputerName'; Expression = { $ComputerName } },
@{Name = 'RecoveryKeyID'; Expression = { [guid]$_.'msFVE-RecoveryGuid' } },
@{Name = 'RecoveryPassword'; Expression = { $_.'msFVE-RecoveryPassword' } },
@{Name = 'Created'; Expression = { Get-date $_.whenCreated -format "MM/dd/yyy" } } | Sort-Object -Property Created -Descending
}
function New-SAHUser {
param(
$Firstname,
$Lastname,
[Parameter()]
$OnpremServer,
$UPNPrefix,
[Parameter()]
$UPNSuffix,
$Company
)
@{
DisplayName = "{0} ({1})" -f (("${Firstname} ${Lastname}") | Format-ProperNoun),$Company
UserRpincipalName = "{0}@{1}" -f $UPNPrefix,$UPNSuffix
CompanyName = $Company
}
}
#endregion
#region Group management
function New-SAHCompanyDistributionList {
param (
$Company,
$Domain
)
(get-command Get-Mailbox -ErrorAction SilentlyContinue) ? "" :(Connect-365Admin)
$RecipientFilter = "((((((((((((Company -eq '$Company') -and (RecipientType -eq 'UserMailbox'))) -and (-not(RecipientTypeDetailsValue -eq 'RoomMailbox')))) -and (-not(RecipientType -eq 'MailUniversalDistributionGroup')))) -and (-not(RecipientTypeDetailsValue -eq 'SharedMailbox')))) -and (-not(CustomAttribute1 -eq 'exclude-all')))) -and (-not(Name -like 'SystemMailbox{*')) -and (-not(Name -like 'CAS_{*')) -and (-not(RecipientTypeDetailsValue -eq 'MailboxPlan')) -and (-not(RecipientTypeDetailsValue -eq 'DiscoveryMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'PublicFolderMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'ArbitrationMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'AuditLogMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'AuxAuditLogMailbox')) -and (-not(RecipientTypeDetailsValue -eq 'SupervisoryReviewPolicyMailbox')))"
$listName = "[DL] $Company All"
$alias = ("all-{0}" -f $Company).ToLower()
$primarySmtpAddress = ("all-{0}@{1}" -f $Company, $Domain).ToLower()
$Group = New-DynamicDistributionGroup -RecipientFilter $RecipientFilter -Alias $Alias -DisplayName $ListName -Name $ListName -PrimarySmtpAddress $PrimarySMTPAddress
$Group
}
#endregion
#region Domain
function Get-TrustedDomains {
$domainList = @()
try {
$forest = Get-ADForest -ErrorAction Stop
$domainList += $forest.Domains
}
catch {
try { $domainList += (Get-ADDomain -ErrorAction Stop).DNSRoot } catch {}
}
try { $domainList += (Get-ADTrust -Filter * | Select-Object -ExpandProperty Name) } catch {}
$domainList | Sort-Object -Unique
}
#endregion
#region Utilities
function Format-ProperNoun {
param(
[Parameter(ValueFromPipeline)]
[string]$String
)
process {
if ([string]::IsNullOrWhiteSpace($String)) { return $String }
(Get-Culture).TextInfo.ToTitleCase($String.ToLower())
}
}
function Get-StringSimilarity {
<#
.SYNOPSIS
Returns similarity percent (0-100) between two strings using Levenshtein distance.
.PARAMETER A
First string.
.PARAMETER B
Second string.
.PARAMETER IgnoreCase
Compare case-insensitively.
.PARAMETER Trim
Trim inputs before comparing.
.EXAMPLE
Get-StringSimilarity -A "kitten" -B "sitting"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][AllowNull()][string]$A,
[Parameter(Mandatory)][AllowNull()][string]$B,
[switch]$IgnoreCase,
[switch]$Trim
)
if ($null -eq $A) { $A = "" }
if ($null -eq $B) { $B = "" }
if ($Trim) {
$A = $A.Trim()
$B = $B.Trim()
}
if ($IgnoreCase) {
$A = $A.ToLowerInvariant()
$B = $B.ToLowerInvariant()
}
# Fast paths
if ($A -eq $B) { return 100.0 }
if ($A.Length -eq 0 -and $B.Length -eq 0) { return 100.0 }
if ($A.Length -eq 0 -or $B.Length -eq 0) { return 0.0 }
$lenA = $A.Length
$lenB = $B.Length
# Use two rows to reduce memory
$prev = New-Object int[] ($lenB + 1)
$curr = New-Object int[] ($lenB + 1)
for ($j = 0; $j -le $lenB; $j++) { $prev[$j] = $j }
for ($i = 1; $i -le $lenA; $i++) {
$curr[0] = $i
$charA = $A[$i - 1]
for ($j = 1; $j -le $lenB; $j++) {
$cost = if ($charA -eq $B[$j - 1]) { 0 } else { 1 }
$del = $prev[$j] + 1
$ins = $curr[$j - 1] + 1
$sub = $prev[$j - 1] + $cost
$curr[$j] = [Math]::Min($del, [Math]::Min($ins, $sub))
}
# swap rows
$tmp = $prev
$prev = $curr
$curr = $tmp
}
$distance = $prev[$lenB]
$maxLen = [Math]::Max($lenA, $lenB)
# Similarity as (1 - normalized distance)
$similarity = (1.0 - ($distance / [double]$maxLen)) * 100.0
return [Math]::Round($similarity, 2)
}
function Write-LogMsg {
[CmdletBinding()]
param (
[Parameter(Mandatory, ValueFromPipeline, Position = 0)]
[Alias('Message')]
[AllowNull()]
[object]$InputObject,
[ValidateSet('FAIL', 'INFO', 'WARN')]
[string]$LogLevel,
[string]$LogFilePath,
[object]$TextBox
)
process {
if ($env:LogFilePath) { $LogFilePath = $env:LogFilePath }
$autoLevel = 'INFO'
$message = ''
switch ($InputObject) {
{ $_ -is [System.Management.Automation.ErrorRecord] } {
$autoLevel = 'FAIL'
$message = if ($_.Exception?.Message) { $_.Exception.Message } else { $_.ToString() }
break
}
{ $_ -is [System.Management.Automation.WarningRecord] } {
$autoLevel = 'WARN'
$message = $_.Message
break
}
{ $_ -is [System.Management.Automation.InformationRecord] } {
$autoLevel = 'INFO'
$md = $_.MessageData
$message = if ($md -is [string]) { $md } elseif ($null -ne $md) { ($md | Out-String).Trim() } else { $_.ToString() }
break
}
{ $_ -is [System.Management.Automation.VerboseRecord] } {
$autoLevel = 'INFO'
$message = "[VERBOSE] $($_.Message)"
break
}
{ $_ -is [System.Management.Automation.DebugRecord] } {
$autoLevel = 'INFO'
$message = "[DEBUG] $($_.Message)"
break
}
default {
$message = if ($null -eq $InputObject) { '' }
elseif ($InputObject.PSObject.Properties['Message']) { [string]$InputObject.Message }
else { ($InputObject | Out-String).TrimEnd() }
}
}
$effectiveLevel = if ($PSBoundParameters.ContainsKey('LogLevel')) { $LogLevel } else { $autoLevel }
$prefix = switch ($effectiveLevel) {
'FAIL' { '[ FAIL ]' }
'WARN' { '[ WARN ]' }
default { '[ INFO ]' }
}
$timestamp = Get-Date -Format '[ yyyy-MM-dd HH:mm:ss.fff ]'
$logMsg = "$timestamp $prefix $message"
if ($LogFilePath) {
try { Add-Content -LiteralPath $LogFilePath -Value $logMsg -Encoding utf8 -ErrorAction Stop }
catch { Write-Warning "Failed to write to log file '$LogFilePath': $($_.Exception.Message)" }
}
$targetTextBox = if ($TextBox) { $TextBox } elseif ($Global:LogTextBox) { $Global:LogTextBox } else { $null }
if ($targetTextBox) {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
$append = { param($tb, $text) $tb.AppendText("$text`r`n") }
if ($targetTextBox.InvokeRequired) { [void]$targetTextBox.BeginInvoke($append, @($targetTextBox, $logMsg)) }
else { & $append $targetTextBox $logMsg }
}
$logMsg
}
}
function Get-SahFilesSharedWithMe {
[CmdletBinding()]
param(
# Optional: filter to a specific user's OneDrive shares (UPN/email)
[Parameter(ValueFromPipelineByPropertyName)]
[Alias("UserPrincipalName")]
[string]$SharedByUpn,
# Optional: filename filter (PowerShell -like), e.g. "*DNS*" or "*.xlsx"
[Parameter()]
[string]$NameLike,
# Search page size
[Parameter()]
[ValidateRange(1, 500)]
[int]$PageSize = 200,
# Pull all pages (up to MaxPages)
[Parameter()]
[switch]$All,
# Safety cap when -All is used
[Parameter()]
[ValidateRange(1, 200)]
[int]$MaxPages = 20,
# If not already connected, connect with these scopes
[Parameter()]
[string[]]$Scopes = @("Files.Read.All", "Sites.Read.All")
)
if (-not (Get-MgContext)) {
Connect-MgGraph -Scopes $Scopes | Out-Null
}
# Get your OneDrive webUrl so we can exclude your own "personal/<you>/" path
$meDrive = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/me/drive?`$select=webUrl"
$myDriveUrl = $meDrive.webUrl
$uriHost = ([Uri]$myDriveUrl).Host
$personalBase = "https://$uriHost/personal/"
# If filtering by sharer, convert UPN/email to the SPO personal token
# amalinowsky@apcisg.com -> amalinowsky_apcisg_com
$sharedByToken = $null
if ($SharedByUpn) {
$sharedByToken = ($SharedByUpn -replace '[@\.]', '_')
}
$out = New-Object System.Collections.Generic.List[object]
$pagesToFetch = if ($All) { $MaxPages } else { 1 }
for ($page = 0; $page -lt $pagesToFetch; $page++) {
$from = $page * $PageSize
# KQL: look in all personal OneDrives, exclude your own personal drive path
$kql = "path:`"$personalBase`" AND -path:`"$myDriveUrl`""
$body = @{
requests = @(
@{
entityTypes = @("driveItem")
query = @{ queryString = $kql }
from = $from
size = $PageSize
fields = @("id", "name", "webUrl", "parentReference")
}
)
} | ConvertTo-Json -Depth 10
# Microsoft Search API query endpoint
$resp = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/search/query" -Body $body
$hits = @($resp.value[0].hitsContainers[0].hits)
if (-not $hits -or $hits.Count -eq 0) { break }
foreach ($h in $hits) {
$r = $h.resource
if (-not $r) { continue }
# Optional: filter to a specific sharer's OneDrive path
if ($sharedByToken) {
if ($r.webUrl -notmatch "/personal/$([Regex]::Escape($sharedByToken))/" ) { continue }
}
# Optional: -like filter on the name
if ($NameLike) {
if ($r.name -notlike $NameLike) { continue }
}
$out.Add([pscustomobject]@{
Name = $r.name
WebUrl = $r.webUrl
})
}
# Stop early if we got less than a full page (no more results)
if ($hits.Count -lt $PageSize) { break }
}
$out | Sort-Object Name, WebUrl -Unique
}
function New-DicePassphrase {
param (
[int]$WordCount = 3,
[string]$Separator = "-",
[switch]$AsSecureString
)
$WordListPath = "$($Env:LOCALAPPDATA)\eff_large_wordlist.txt"
if (!(Test-Path $WordListPath)) {
Invoke-RestMethod 'https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt' -OutFile $WordListPath
}
$WordList = Get-Content $WordListPath -Raw
$words = 1 .. $WordCount | ForEach-Object {
$Dice = ( -join (1 .. 5 | ForEach-Object {
Get-SecureRandom -Maximum 6 -Minimum 1
})).Tostring()
$StringLower = (((($Wordlist -split "`n") | Where-Object {
$_ -match $Dice
}) -replace "\d").trim())
$StringUpper = $StringLower.Substring(0, 1).ToUpper() + $StringLower.Substring(1)
$StringUpper
}
$RandomWord = ($words | Get-SecureRandom)
$ModWord = $RandomWord -replace "$", (Get-SecureRandom -Maximum 9 -Minimum 1)
$Words = $words -replace $RandomWord, $ModWord
if (!$AsSecureString) {
return $words -join $Separator
}
else {
return (($words -join $Separator) | ConvertTo-SecureString -AsPlainText -Force)
}
}
function Convertfrom-EpocTime {
[CmdletBinding()]
param (
[Parameter(Mandatory, ValueFromPipeline)]
[double]$EpochTime,
[switch]$ToLocalTime
)
$DateTime = [datetime]::UnixEpoch.AddSeconds($EpochTime)
switch ($ToLocalTime) {
$true {
$DateTime.ToLocalTime()
}
default {
$DateTime
}
}
}
function ConvertTo-HumanDuration {
[CmdletBinding(DefaultParameterSetName = 'Timespan')]
param(
# Pass a [TimeSpan] directly
[Parameter(Mandatory, ParameterSetName = 'Timespan', ValueFromPipeline)]
[TimeSpan]$TimeSpan,
# Or pass two dates (or a date and "now") and it will compute the difference
[Parameter(Mandatory, ParameterSetName = 'Dates')]
[datetime]$From,
[Parameter(ParameterSetName = 'Dates')]
[datetime]$To = (Get-Date),
# How many units to include in the output (e.g., "1 hour, 30 mins" => 2)
[ValidateRange(1, 5)]
[int]$MaxParts = 2,
# Rounding behavior for the last part in the output
[ValidateSet('Floor', 'Round', 'Ceiling')]
[string]$Rounding = 'Floor',
# When the duration is 0, return this string
[string]$ZeroText = '0 seconds',
# If using Dates: show "in ..." for future and "... ago" for past
[switch]$IncludeDirection
)
begin {
function Get-UnitLabel([string]$Unit, [long]$Value) {
if ($Value -eq 1) { return $Unit }
switch ($Unit) {
'day' { 'days' }
'hour' { 'hours' }
'minute' { 'mins' }
'second' { 'secs' }
default { "${Unit}s" }
}
}
function Apply-Rounding([double]$Value, [string]$Mode) {
switch ($Mode) {
'Floor' { [math]::Floor($Value) }
'Ceiling' { [math]::Ceiling($Value) }
'Round' { [math]::Round($Value) }
}
}
}
process {
$directionPrefix = $null
$directionSuffix = $null
if ($PSCmdlet.ParameterSetName -eq 'Dates') {
$delta = $To - $From
if ($IncludeDirection) {
if ($delta.Ticks -lt 0) {
$delta = $delta.Negate()
$directionPrefix = 'in '
}
else {
$directionSuffix = ' ago'
}
}
else {
if ($delta.Ticks -lt 0) { $delta = $delta.Negate() }
}
$TimeSpan = $delta
}
if ($TimeSpan.Ticks -lt 0) { $TimeSpan = $TimeSpan.Negate() }
if ($TimeSpan.Ticks -eq 0) {
return ($directionPrefix + $ZeroText + $directionSuffix)
}
# Work in whole seconds to keep formatting stable
$totalSeconds = [math]::Floor($TimeSpan.TotalSeconds)
$units = @(
@{ Name = 'day'; Seconds = 86400L }
@{ Name = 'hour'; Seconds = 3600L }
@{ Name = 'minute'; Seconds = 60L }
@{ Name = 'second'; Seconds = 1L }
)
$parts = New-Object System.Collections.Generic.List[string]
$remaining = [double]$totalSeconds
# Build all but the last part using floor
for ($i = 0; $i -lt $units.Count -and $parts.Count -lt ($MaxParts - 1); $i++) {
$u = $units[$i]
$count = [long]([math]::Floor($remaining / $u.Seconds))
if ($count -gt 0) {
$parts.Add(("{0} {1}" -f $count, (Get-UnitLabel $u.Name $count)))
$remaining -= ($count * $u.Seconds)
}
}
# Last part: choose the best unit for what's left (or next smaller unit) and apply rounding mode
if ($parts.Count -lt $MaxParts) {
# Pick the largest unit that fits, otherwise seconds
$lastUnit = $units | Where-Object { $remaining -ge $_.Seconds } | Select-Object -First 1
if (-not $lastUnit) { $lastUnit = $units[-1] }
$raw = $remaining / $lastUnit.Seconds
$lastCount = [long](Apply-Rounding $raw $Rounding)
# If rounding produced 0, drop to the next smaller unit if possible
if ($lastCount -le 0 -and $lastUnit.Seconds -gt 1) {
$idx = [array]::IndexOf($units, $lastUnit)
$lastUnit = $units[[math]::Min($idx + 1, $units.Count - 1)]
$raw = $remaining / $lastUnit.Seconds
$lastCount = [long](Apply-Rounding $raw $Rounding)
}
if ($lastCount -gt 0) {
$parts.Add(("{0} {1}" -f $lastCount, (Get-UnitLabel $lastUnit.Name $lastCount)))
}
}
if ($parts.Count -eq 0) {
$out = $ZeroText
}
else {
$out = ($parts -join ', ')
}
return ($directionPrefix + $out + $directionSuffix)
}
}
#endregion
#region External Services
function Get-SahUserNinjaDevice {
param(
[Parameter(ValueFromPipelineByPropertyName)]
[Alias("OnPremisesSamAccountName")]
$UserId
)
Connect-SahNinja
$results = (Find-NinjaOneDevice -searchQuery $UserId).devices | where { $_.score -ge 95 -and $_.nodeClass -eq "WINDOWS_WORKSTATION" }
foreach ($result in $results) {
$result | Add-member -MemberType NoteProperty -Name URL -Value ("https://app.ninjarmm.com/#/deviceDashboard/$($result.Id)/overview")
}
$results
}
function Get-SahUserJiraIncidents {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipelineByPropertyName)]
[Alias("DisplayName")]
[string]$ReporterDisplayName, # e.g. 'Lynn Bastian (PMP)'
[string[]]$Fields = @('summary', 'status', 'priority', 'created', 'updated', 'assignee', 'reporter'),
[int]$SearchPageSize = 1000
)
$jql = 'status NOT IN ("Closed","Resolved") AND reporter = "{0}" ORDER BY created DESC' -f $ReporterDisplayName
# 1) Pull IDs (paginate via nextPageToken if present)
$allIds = New-Object System.Collections.Generic.List[string]
$nextPageToken = $null
do {
$body = @{
jql = $jql
maxResults = $SearchPageSize
}
if ($nextPageToken) { $body.nextPageToken = $nextPageToken }
$resp = Invoke-JiraMethod -Method POST `
-Uri 'https://spectrumag.atlassian.net/rest/api/3/search/jql' `
-Body ($body | ConvertTo-Json -Depth 10)
foreach ($i in $resp.issues) { $allIds.Add([string]$i.id) }
# depending on rollout you may see nextPageToken or isLast
$nextPageToken = $resp.nextPageToken
$isLast = $resp.isLast
} while ($nextPageToken -and -not $isLast)
if ($allIds.Count -eq 0) { return @() }
# 2) Hydrate (BulkFetch) in chunks of 100
$results = New-Object System.Collections.Generic.List[object]
$chunks = [System.Linq.Enumerable]::Range(0, [Math]::Ceiling($allIds.Count / 100)) | ForEach-Object {
$start = $_ * 100
$count = [Math]::Min(100, $allIds.Count - $start)
$allIds.GetRange($start, $count)
}
foreach ($chunk in $chunks) {
$bulkBody = @{
issueIdsOrKeys = @($chunk)
fields = @($Fields)
}
$bulk = Invoke-JiraMethod -Method POST `
-Uri 'https://spectrumag.atlassian.net/rest/api/3/issue/bulkfetch' `
-Body ($bulkBody | ConvertTo-Json -Depth 10)
# bulk.issues is the hydrated issue list; bulk.errors contains any misses
$bulk.issues | ForEach-Object { $results.Add($_) }
}
return $results | Select Key, @{n = 'Summary'; e = { $_.fields.summary } }, @{n = "Assignee"; e = { $_.fields.assignee.displayName } }, @{n = "Uri"; e = { "https://spectrumag.atlassian.net/browse/$($_.Key)" } }
}
function Get-SahMyJiraSprint {
[CmdletBinding()]
param(
[string[]]$Fields = @('summary', 'status', 'priority', 'created', 'updated', 'assignee', 'reporter'),
[int]$SearchPageSize = 1000
)
$jql = 'sprint in openSprints() AND assignee = currentUser()' -f $ReporterDisplayName
# 1) Pull IDs (paginate via nextPageToken if present)
$allIds = New-Object System.Collections.Generic.List[string]
$nextPageToken = $null
do {
$body = @{
jql = $jql
maxResults = $SearchPageSize
}
if ($nextPageToken) { $body.nextPageToken = $nextPageToken }
$resp = Invoke-JiraMethod -Method POST `
-Uri 'https://spectrumag.atlassian.net/rest/api/3/search/jql' `
-Body ($body | ConvertTo-Json -Depth 10)
foreach ($i in $resp.issues) { $allIds.Add([string]$i.id) }
# depending on rollout you may see nextPageToken or isLast
$nextPageToken = $resp.nextPageToken
$isLast = $resp.isLast
} while ($nextPageToken -and -not $isLast)
if ($allIds.Count -eq 0) { return @() }
# 2) Hydrate (BulkFetch) in chunks of 100
$results = New-Object System.Collections.Generic.List[object]
$chunks = [System.Linq.Enumerable]::Range(0, [Math]::Ceiling($allIds.Count / 100)) | ForEach-Object {
$start = $_ * 100
$count = [Math]::Min(100, $allIds.Count - $start)
$allIds.GetRange($start, $count)
}
foreach ($chunk in $chunks) {
$bulkBody = @{
issueIdsOrKeys = @($chunk)
fields = @($Fields)
}
$bulk = Invoke-JiraMethod -Method POST `
-Uri 'https://spectrumag.atlassian.net/rest/api/3/issue/bulkfetch' `
-Body ($bulkBody | ConvertTo-Json -Depth 10)
# bulk.issues is the hydrated issue list; bulk.errors contains any misses
$bulk.issues | ForEach-Object { $results.Add($_) }
}
return $results | Select Key, @{n = 'Summary'; e = { $_.fields.summary } }, @{n = "Assignee"; e = { $_.fields.assignee.displayName } }, @{n = "Uri"; e = { "https://spectrumag.atlassian.net/browse/$($_.Key)" } }
}
function Invoke-ManageJiraAPIKeys {
Start-Process 'https://id.atlassian.com/manage-profile/security/api-tokens'
}
function Get-SahCriticalAlerts {
param(
[ValidateSet("P1", "P2", "P3")]
$Priority = "P1"
)
$Results = (Invoke-JiraMethod -Method Get -URI 'https://api.atlassian.com/jsm/ops/api/c61f119b-c7a8-4052-a1f6-01d0358f0676/v1/alerts').Values |
where { $_.priority -eq $Priority -and $_.status -ne "closed" } |
select alias, createdAt, message, acknowledged, status, Seen
if (!$Results) {
"Whew! nothing bad is happening. 🙂"
}
$results
}
#endregion
#region Credentials
function Set-SahJiraCredentials {
param(
$Username,
$ApiKey
)
Import-SahModuleConfig
if (!$Global:SahModuleConfig) { Initialize-SahModuleConfig }
$Global:SahModuleConfig.jira.username = $Username
$Global:SahModuleConfig.jira.apiKey = ($ApiKey | ConvertTo-SecureString -AsPlainText -Force)
Export-SahModuleConfig
Import-SahModuleConfig
}
function Set-SahNinjaCredentials {
param(
$ClientId,
$ClientSecret
)
Import-SahModuleConfig
if (!$Global:SahModuleConfig) { Initialize-SahModuleConfig }
$Global:SahModuleConfig.ninja.clientId = $ClientId
$Global:SahModuleConfig.ninja.clientSecret = ($ClientSecret | ConvertTo-SecureString -AsPlainText -Force)
Export-SahModuleConfig
Import-SahModuleConfig
}
function Initialize-SahModuleConfig {
$config = @{
jira = @{
username = ""
apiKey = ""
}
ninja = @{
clientId = ""
clientSecret = ""
}
}
$Path = Join-Path $env:LOCALAPPDATA "SahModuleConfig.xml"
if (!(Test-Path $Path)) {
$config | Export-Clixml $Path
$Path
}
Import-SahModuleConfig
}
function Import-SahModuleConfig {
$Path = Join-Path $env:LOCALAPPDATA "SahModuleConfig.xml"
$Global:SahModuleConfig = Import-Clixml $Path
}
function Export-SahModuleConfig {
$Path = Join-Path $env:LOCALAPPDATA "SahModuleConfig.xml"
$Global:SahModuleConfig | Export-Clixml $Path
Import-SahModuleConfig
}
function Add-SahConfigData {
param(
$Name,
$Object
)
$Global:SahModuleConfig[$Name] = $Object
Export-SahModuleConfig
}
function Get-SahConfigValue {
param(
[Parameter()]
[ArgumentCompleter({
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
$Global:SahModuleConfig.Keys
})]
$Name
)
if (!$Name) {
$Global:SahModuleConfig
}
else {
$Global:SahModuleConfig.$Name
}
}
# -------------------------
# Helpers
# -------------------------
function New-SahAesKeyFile {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path,
[ValidateSet(16, 24, 32)]
[int]$Bytes = 32
)
$key = New-Object byte[] $Bytes
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($key)
$dir = Split-Path -Parent $Path
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
# Store as Base64 for easy transport
[IO.File]::WriteAllText($Path, [Convert]::ToBase64String($key), [Text.Encoding]::ASCII)
$Path
}
function Get-SahAesKeyFromFile {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)
$b64 = (Get-Content -LiteralPath $Path -Raw).Trim()
[Convert]::FromBase64String($b64)
}
function ConvertFrom-SecureStringPlain {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[SecureString]$SecureString
)
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
try {
[Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
}
# Deep-walk any object/hashtable/pscustomobject and transform SecureString leaf nodes
function Invoke-SahTransformObject {
param(
[Parameter(Mandatory)] $InputObject,
[Parameter(Mandatory)] [scriptblock] $OnSecureString
)
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [SecureString]) {
return & $OnSecureString $InputObject
}
# Strings (and other primitive-ish leaf values) should not be expanded into their properties
# (e.g. [string] => Length). Treat them as terminal nodes.
if ($InputObject -is [string]) {
return $InputObject
}
if ($InputObject -is [byte[]]) {
return $InputObject
}
if ($InputObject -is [System.Collections.IDictionary]) {
$out = @{}
foreach ($k in $InputObject.Keys) {
$out[$k] = Invoke-SahTransformObject -InputObject $InputObject[$k] -OnSecureString $OnSecureString
}
return $out
}
# Handle arrays/lists
if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
$items = New-Object System.Collections.Generic.List[object]
foreach ($item in $InputObject) {
$items.Add((Invoke-SahTransformObject -InputObject $item -OnSecureString $OnSecureString))
}
return @($items)
}
# Handle PSCustomObject / other objects with properties
if ($InputObject -is [pscustomobject] -or ($InputObject.PSObject?.Properties?.Count -gt 0)) {
$out = [ordered]@{}
foreach ($p in $InputObject.PSObject.Properties) {
$out[$p.Name] = Invoke-SahTransformObject -InputObject $p.Value -OnSecureString $OnSecureString
}
return [pscustomobject]$out
}
return $InputObject
}
# -------------------------
# Export: DPAPI SecureStrings -> AES-encrypted strings
# -------------------------
function Export-SahModuleConfigPortable {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$OutPath,
# Provide either a key byte[] OR a KeyFile
[byte[]]$Key,
[string]$KeyFile
)
if (-not $Key) {
if (-not $KeyFile) { throw "Provide -Key or -KeyFile." }
$Key = Get-SahAesKeyFromFile -Path $KeyFile
}
Import-SahModuleConfig
if (-not $Global:SahModuleConfig) { throw "Global:SahModuleConfig is empty. Initialize/Import first." }
$portable = Invoke-SahTransformObject -InputObject $Global:SahModuleConfig -OnSecureString {
param([SecureString]$ss)
# Convert to portable encrypted string using AES key
ConvertFrom-SecureString -SecureString $ss -Key $Key
}
$dir = Split-Path -Parent $OutPath
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$portable | Export-Clixml -LiteralPath $OutPath
$OutPath
}
# -------------------------
# Import: AES-encrypted strings -> DPAPI SecureStrings (re-key for this machine/user)
# -------------------------
function Import-SahModuleConfigPortable {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$InPath,
# Provide either a key byte[] OR a KeyFile
[byte[]]$Key,
[string]$KeyFile,
# Where to write the "normal" DPAPI-backed config (defaults to your current path)
[string]$ConfigPath = (Join-Path $env:LOCALAPPDATA "SahModuleConfig.xml")
)
if (-not $Key) {
if (-not $KeyFile) { throw "Provide -Key or -KeyFile." }
$Key = Get-SahAesKeyFromFile -Path $KeyFile
}
$portable = Import-Clixml -LiteralPath $InPath
# Walk all leaf strings and attempt to treat them as AES-encrypted SecureString payloads.
# If it fails, keep original value (covers non-secure plain strings like username, clientId).
function TryDecrypt($val) {
if ($val -isnot [string] -or [string]::IsNullOrWhiteSpace($val)) { return $val }
try {
$ss = ConvertTo-SecureString -String $val -Key $Key -ErrorAction Stop
# Re-key to DPAPI for this machine/user
return ($ss | ConvertFrom-SecureStringPlain | ConvertTo-SecureString -AsPlainText -Force)
}
catch {
return $val
}
}
$rehydrated = Invoke-SahTransformObject -InputObject $portable -OnSecureString {
# Portable export should not contain SecureString types; but if it does, re-key anyway
param([SecureString]$ss)
($ss | ConvertFrom-SecureStringPlain | ConvertTo-SecureString -AsPlainText -Force)
}
# Now also scan string leaves (portable encrypted strings are strings)
function WalkAndDecryptStrings($obj) {
if ($null -eq $obj) { return $null }
# Leaf values
if ($obj -is [SecureString]) {
return $obj
}
if ($obj -is [string]) {
return (TryDecrypt $obj)
}
if ($obj -is [System.Collections.IDictionary]) {
$out = @{}
foreach ($k in $obj.Keys) { $out[$k] = WalkAndDecryptStrings $obj[$k] }
return $out
}
# Handle arrays/lists
if ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) {
$items = New-Object System.Collections.Generic.List[object]
foreach ($item in $obj) { $items.Add((WalkAndDecryptStrings $item)) }
return @($items)
}
if ($obj -is [pscustomobject] -or $obj.PSObject?.Properties?.Count -gt 0) {
$out = [ordered]@{}
foreach ($p in $obj.PSObject.Properties) {
$out[$p.Name] = WalkAndDecryptStrings $p.Value
}
return [pscustomobject]$out
}
return $obj
}
$rehydrated = WalkAndDecryptStrings $rehydrated
$rehydrated | Export-Clixml -LiteralPath $ConfigPath
Import-SahModuleConfig
$ConfigPath
}
# -------------------------
# Convenience wrappers
# -------------------------
function Export-SahConfigBundle {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$BundleFolder
)
$keyFile = Join-Path $BundleFolder "SahConfig.aeskey"
$dataFile = Join-Path $BundleFolder "SahModuleConfig.portable.xml"
if (-not (Test-Path $BundleFolder)) { New-Item -ItemType Directory -Path $BundleFolder -Force | Out-Null }
New-SahAesKeyFile -Path $keyFile | Out-Null
Export-SahModuleConfigPortable -OutPath $dataFile -KeyFile $keyFile | Out-Null
[pscustomobject]@{
KeyFile = $keyFile
DataFile = $dataFile
}
}
function Import-SahConfigBundle {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$KeyFile,
[Parameter(Mandatory)]
[string]$DataFile
)
Import-SahModuleConfigPortable -InPath $DataFile -KeyFile $KeyFile
}
#endregion
#Export-ModuleMember -Function *
Initialize-SahModuleConfig
<#
Get-GraphToken -integratedWindowsAuth -tenantId (Get-TenantIDfromUserID (whoami /upn)) -scopes "openid offline_access"
function Get-SahUser {
[CmdletBinding()]
param(
[string]$UserId,
[Alias('Properties', 'AdditionalProperties')]
[string[]]$Property,
[string]$Filter,
[switch]$SkipADLookup
)
$baseProps = @(
"DisplayName",
"GivenName",
"SurName",
"UserPrincipalName",
"Mail",
"JobTitle",
"Department",
"CompanyName",
"OnPremisesSyncEnabled",
"OnPremisesSamAccountName",
"OnPremisesDomainName",
"OnPremisesDistinguishedName",
"AccountEnabled"
)
# Accept both array input and comma-separated input
$extraProps = @(
$Property |
ForEach-Object { $_ -split '\s*,\s*' } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
$props = @($baseProps + $extraProps | Select-Object -Unique)
# Escape single quotes for OData filter safety
$escapedUserId = $UserId.Replace("'", "''")
if (!$Filter -and $UserId) {
$filter = "onPremisesSamAccountName eq '$escapedUserId' or userPrincipalName eq '$escapedUserId' or startsWith(displayName, '$escapedUserId') or startsWith(userprincipalname, '$UserId@') or proxyAddresses/any(x:x eq 'SMTP:$Userid') or proxyAddresses/any(x:x eq 'smtp:$Userid')"
}
$result = Get-MgUser -Filter $filter -Property $props -ConsistencyLevel eventual -CountVariable count
if (-not $result) { return }
# Promote values from AdditionalProperties so Select-Object can show them
foreach ($u in $result) {
foreach ($p in $props) {
if (-not $u.PSObject.Properties[$p] -and $u.AdditionalProperties.ContainsKey($p)) {
$u | Add-Member -NotePropertyName $p -NotePropertyValue $u.AdditionalProperties[$p] -Force
}
}
}
if (!$result.OnPremisesSyncEnabled -and !$SkipADLookup) {
$ADAccount = Resolve-ADUser -SearchString ("{0} {1}" -f $result.givenname, $result.surname)
if ($ADAccount -is [array]) {
$ADAccount = ($ADAccount | Out-GridView -PassThru -Title "Multiple results")
}
if ($ADAccount) {
$result.OnPremisesSamAccountName = $ADAccount.samaccountname
$result.OnPremisesDistinguishedName = $ADAccount.DistinguishedName
$result.OnPremisesDomainName = (Get-DomainFromDN -DistinguishedName $ADAccount.DistinguishedName)
}
}
$result | Select-Object -Property $props
}
function Get-SahUser-old {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$UserId,
[Alias('Properties', 'AdditionalProperties')]
[string[]]$Property,
[switch]$IncludeADObject,
[switch]$ForceADLookup
)
$baseProps = @(
"DisplayName",
"GivenName",
"SurName",
"UserPrincipalName",
"Mail",
"JobTitle",
"Department",
"CompanyName",
"OnPremisesSyncEnabled",
"OnPremisesSamAccountName",
"OnPremisesDomainName",
"OnPremisesDistinguishedName",
"AccountEnabled"
)
# Accept both array input and comma-separated input
$extraProps = @(
$Property |
ForEach-Object { $_ -split '\s*,\s*' } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
$props = @($baseProps + $extraProps | Select-Object -Unique)
# Escape single quotes for OData filter safety
$escapedUserId = $UserId.Replace("'", "''")
$filter = "onPremisesSamAccountName eq '$escapedUserId' or userPrincipalName eq '$escapedUserId' or startsWith(displayName, '$escapedUserId') or startsWith(userprincipalname, '$UserId@') or proxyAddresses/any(x:x eq 'SMTP:$Userid') or proxyAddresses/any(x:x eq 'smtp:$Userid')"
$result = Get-MgUser -Filter $filter -Property $props -ConsistencyLevel eventual -CountVariable count
if (-not $result) { return }
# Promote values from AdditionalProperties so Select-Object can show them
foreach ($u in $result) {
foreach ($p in $props) {
if (-not $u.PSObject.Properties[$p] -and $u.AdditionalProperties.ContainsKey($p)) {
$u | Add-Member -NotePropertyName $p -NotePropertyValue $u.AdditionalProperties[$p] -Force
}
}
}
if ($ForceADLookup) {
$ADAccount = Resolve-ADIdentity -Identity ("{0} {1}" -f $result.givenname, $result.surname)
if ($ADAccount -is [array]) {
$ADAccount = ($ADAccount | Out-GridView -PassThru -Title "Multiple results")
}
$result.OnPremisesSamAccountName = $ADAccount.samaccountname
$result.OnPremisesDistinguishedName = $ADAccount.DistinguishedName
$result.OnPremisesDomainName = (Get-DomainFromDN -DistinguishedName $ADAccount.DistinguishedName)
}
$result | Select-Object -Property $props
}
#>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment