GroupExpirationAudit.ps1


<#PSScriptInfo
 
.VERSION 1.0
 
.GUID 79d1df22-ec96-4860-b2d4-40dafb649ae1
 
.AUTHOR timmcmic
 
.COMPANYNAME Micorosft CSS
 
.COPYRIGHT
 
.TAGS
 
.LICENSEURI
 
.PROJECTURI
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
 
.DESCRIPTION "This script allows administators to report on Group Expiration."
 
.PRIVATEDATA
 
#>


#Requires -Module @{ ModuleName = 'Microsoft.Graph.Authentication'; ModuleVersion = '2.29.1' }
#Requires -Module @{ ModuleName = 'Microsoft.Graph.Groups'; ModuleVersion = '2.29.1' }
#Requires -Module @{ ModuleName = 'PSWriteHTML'; ModuleVersion = '1.30.8' }
<#
 
.DESCRIPTION
 This script audits group expiration and provides output information
 
#>
 
Param(
    #Define Microsoft Graph Parameters
        [Parameter(Mandatory = $false)]
        [ValidateSet("China","Global","USGov","USGovDod")]
        [string]$msGraphEnvironmentName="Global",
        [Parameter(Mandatory=$true)]
        [string]$msGraphTenantID="",
        [Parameter(Mandatory=$false)]
        [string]$msGraphApplicationID="",
        [Parameter(Mandatory=$false)]
        [string]$msGraphCertificateThumbprint="",
        [Parameter(Mandatory=$false)]
        [string]$msGraphClientSecret="",
        #Define other mandatory parameters
        [Parameter(Mandatory = $true)]
        [string]$logFolderPath,
        [Parameter(Mandatory = $false)]
        [boolean]$includePolicyEvaluation=$TRUE
)

#*****************************************************
Function new-LogFile
{
    [cmdletbinding()]

    Param
    (
        [Parameter(Mandatory = $true)]
        [string]$logFileName,
        [Parameter(Mandatory = $true)]
        [string]$logFolderPath
    )

    [string]$logFileSuffix=".log"
    [string]$fileName=$logFileName+$logFileSuffix

    # Get our log file path

    $logFolderPath = $logFolderPath+"\"+$logFileName+"\"
    
    #Since $logFile is defined in the calling function - this sets the log file name for the entire script
    
    $global:LogFile = Join-path $logFolderPath $fileName

    #Test the path to see if this exists if not create.

    [boolean]$pathExists = Test-Path -Path $logFolderPath

    if ($pathExists -eq $false)
    {
        try 
        {
            #Path did not exist - Creating

            New-Item -Path $logFolderPath -Type Directory
        }
        catch 
        {
            throw $_
        } 
    }
}

#*****************************************************
Function Out-LogFile
{
    [cmdletbinding()]

    Param
    (
        [Parameter(Mandatory = $true)]
        $String,
        [Parameter(Mandatory = $false)]
        [boolean]$isError=$FALSE
    )

    # Get the current date

    [string]$date = Get-Date -Format G

    # Build output string
    #In this case since I abuse the function to write data to screen and record it in log file
    #If the input is not a string type do not time it just throw it to the log.

    if ($string.gettype().name -eq "String")
    {
        [string]$logstring = ( "[" + $date + "] - " + $string)
    }
    else 
    {
        $logString = $String
    }

    # Write everything to our log file and the screen

    $logstring | Out-File -FilePath $global:LogFile -Append

    #Write to the screen the information passed to the log.

    if ($string.gettype().name -eq "String")
    {
        Write-Host $logString
    }
    else 
    {
        write-host $logString | select-object -expandProperty *
    }

    #If the output to the log is terminating exception - throw the same string.

    if ($isError -eq $TRUE)
    {
        #Ok - so here's the deal.
        #By default error action is continue. IN all my function calls I use STOP for the most part.
        #In this case if we hit this error code - one of two things happen.
        #If the call is from another function that is not in a do while - the error is logged and we continue with exiting.
        #If the call is from a function in a do while - write-error rethrows the exception. The exception is caught by the caller where a retry occurs.
        #This is how we end up logging an error then looping back around.

        if ($global:GraphConnection -eq $TRUE)
        {
            Disconnect-MGGraph
        }

        write-error $logString

        exit
    }
}

#*****************************************************
Function Validate-GraphInfo
{
    [cmdletbinding()]

    Param
    (
        [Parameter(Mandatory=$true)]
        [AllowEmptyString()]
        [string]$msGraphApplicationID,
        [Parameter(Mandatory=$true)]
        [AllowEmptyString()]
        [string]$msGraphCertificateThumbprint,
        [Parameter(Mandatory=$true)]
        [AllowEmptyString()]
        [string]$msGraphClientSecret
    )

    $functionConnectionType = ""
    $functionConnectionTypeInteractive = "Interactive"
    $functionConnectionTypeCertificate = "CertAuth"
    $functionConnectionTypeSecret = "ClientSecret"

    out-logfile -string "Entering Validate-GraphInfo"

    out-logfile -string "Testing parameters to determine graph authentication type."

    if (($msGraphApplicationID -eq "") -and ($msGraphCertificateThumbprint -eq "") -and ($msGraphClientSecret -eq ""))
    {
        out-logfile -string "No appID, certThumbprint, or clientSecret provided - set type interactive auth."
        $functionConnectionType = $functionConnectionTypeInteractive
    }
    elseif (($msGraphCertificateThumbprint -ne "") -and ($msGraphClientSecret -ne ""))
    {
        out-logfile -string "Specifying a certificate thumbprint and client secret is not allowed - specify one authentication method." -isError:$true
    }
    elseif ((($msGraphCertificateThumbprint -ne "") -or ($msGraphClientSecret -ne "")) -and ($msGraphApplicationID -eq ""))
    {
        out-logfile -string "Specifying a client secret or certificate thumbprint without application ID is not allowed - specify an application ID." -isError:$true
    }
    elseif ($msGraphApplicationID -ne "")
    {
        out-logfile -string "Application ID specified - check for certificate or client secret authentication."

        if ($msGraphCertificateThumbprint -ne "")
        {
            out-logfile -string "Application ID specifeid - certificate thumbprint specified - set type certificate auth."

            $functionConnectionType = $functionConnectionTypeCertificate
        }
        elseif ($msGraphClientSecret -ne "")
        {
            out-logfile -string "Application ID specifeid - client secret specified - set type client secret auth."

            $functionConnectionType = $functionConnectionTypeSecret
        }
        else 
        {
            out-logfile -string "Specifying an application ID without certificate thumbprint or client secret is not allowed - specify a certificate thumbprint or client secret." -isError:$true
        }
    }

    out-logfile -string "Exit Validate-GraphInfo"

    return $functionConnectionType
}

#*****************************************************
Function Connect-MicrosoftGraph 
{
    [cmdletbinding()]

    Param
    (
        [string]$msGraphEnvironmentName,
        [Parameter(Mandatory=$true)]
        [string]$msGraphTenantID,
        [Parameter(Mandatory=$true)]
        [AllowEmptyString()]
        [string]$msGraphApplicationID,
        [Parameter(Mandatory=$true)]
        [AllowEmptyString()]
        [string]$msGraphCertificateThumbprint,
        [Parameter(Mandatory=$true)]
        [AllowEmptyString()]
        [string]$msGraphClientSecret,
        [Parameter(Mandatory=$true)]
        [string]$graphAuthenticationType
    )

    out-logfile -string "Entering Connect-MicrosoftGraph"

    $functionConnectionTypeInteractive = "Interactive"
    $functionConnectionTypeCertificate = "CertAuth"
    $functionConnectionTypeSecret = "ClientSecret"

    if ($graphAuthenticationType -eq $functionConnectionTypeInteractive)
    {
        out-logfile -string "Interactive Authentication"

        try {
            connect-mgGraph -TenantId $msGraphTenantID -environment $msGraphEnvironmentName -errorAction Stop

            out-logfile -string "Interactive authentication to Microosft Graph successful."
        }
        catch {
            out-logfile -string "Interactive authentication to Microsoft Graph FAILED."
            out-logfile -string $_ -isError:$true
        }
    }
    elseif ($graphAuthenticationType -eq $functionConnectionTypeCertificate)
    {
        out-logfile -string "Certificate Authentication"

        try {
            connect-mgGraph -TenantId $msGraphTenantID -Environment $msGraphEnvironmentName -ClientId $msGraphApplicationID -CertificateThumbprint $msGraphCertificateThumbprint -errorAction Stop

            out-logfile -string "Certificate authentication to Microsoft Graph successful."
        }
        catch {
            out-logfile -string "Certificate authentication to Microsoft Graph FAILED."
            out-logfile -string $_ -isError:$TRUE
        }
    }
    elseif ($graphAuthenticationType -eq $functionConnectionTypeSecret)
    {
        out-logfile -string "Client Secret Authentication"

        $securedPasswordPassword = convertTo-SecureString -string $msGraphClientSecret -AsPlainText -Force

        $clientSecretCredential = new-object -typeName System.Management.Automation.PSCredential -argumentList $msGraphApplicationID,$securedPasswordPassword

        try {
            Connect-MgGraph -tenantID $msGraphTenantID -environment $msGraphEnvironmentName -ClientSecretCredential $clientSecretCredential -errorAction Stop

            out-logfile -string "Client secret authentication to Microsoft Graph successful."
        }
        catch {
            out-logfile -string "Client secret authentication to Microsoft Graph FAILED."
            out-logfile -string $_ -isError:$TRUE
        }
    }
    else 
    {
        out-logfile -string "This is bad - you should not have been able to end up here." -isError:$TRUE
    }

    out-logfile -string "Exiting Connect-MicrosoftGraph"
}

#*****************************************************
Function Validate-GraphScopes 
{
    out-logfile -string "Entering Validate-GraphScopes"

    #Define local variables.

    $graphScopesRequired = @("GroupMember.Read.All","Group.ReadWrite.All","Directory.Read.All","Directory.ReadWrite.All","Group.Read.All")
    $graphContext = $NULL
    $validGraphScope = ""

    out-logfile -string "Exiting Validate-GraphScopes"

    out-logfile -string "Obtaining graph context."

    $graphContext = Get-MgContext

    out-logfile -string "The following scopes are assigned to the authentication context:"

    foreach ($scope in $graphContext.scopes)
    {
        out-logfile -string $scope
    }

    out-logfile -string "Searching scopes to ensure a minimum scope is present."

    for ($i = 0 ; $i -lt $graphContext.Scopes.Count ; $i++)
    {
        out-logfile -string $graphContext.scopes[$i]

        if ($graphScopesRequired.Contains($graphContext.scopes[$i]))
        {
            out-logfile -string "Minium graph scope found - proceed."
            $validGraphScope = $graphContext.scopes[$i]
            $i = $graphContext.Scopes.Count+1
        }
        else 
        {
            out-logfile -string "Not a minimum graph scope."
        }
    }

    if ($validGraphScope -eq "")
    {
        out-logfile -string "A valid graph scope for continuing was not found in the authentication context."
        out-logfile -string "The user or application must have one of the following scopes:"

        foreach ($scope in $graphScopesRequired)
        {
            out-logfile -string $scope
        }

        out-logfile -string "ERROR: Correct graph scopes!"
    }

    out-logfile -string 'Exiting Validate-GraphScopes'
}

#*****************************************************
Function Get-M365Groups 
{
    out-logfile -string "Entering Get-M365Groups"

    #Declare variables.

    $groupReturn = $null
    $groupType = "Unified"

    out-logfile -string "Obtaining all M365 / Unified Groups by Filter"

    try {
        $groupReturn = Get-MgGroup -Filter "groupTypes/any(c:c eq '$groupType')" -All -PageSize 500 -ConsistencyLevel Eventual -Property DisplayName, ID, CreatedDateTime, RenewedDateTime, ExpirationDateTime
    }
    catch {
        out-logfile $_ -isError:$true
    }

    out-logfile -string 'Exiting Get-M365Group'

    return $groupReturn
}

#*****************************************************
Function Calculate-GroupExpiration
{
    #Give credit where credit is due.
    #Code largely adapted from https://office365itpros.com/2022/02/09/microsoft-groups-expiration-policy/
    #Code modified to account for groups that may not have a renewal date or expiration date.

    Param
    (
        [Parameter(Mandatory=$true)]
        $groupsToEvaluate
    )

    out-logfile -string "Entering Calculate-GroupExpiration"

    #Declare variables.

    $functionGroups = [System.Collections.Generic.List[Object]]::new()
    $today = (Get-Date)

    foreach ($group in $groupsToEvaluate)
    {
        out-logfile -string ("Evaluting group: "+$group.DisplayName)
        out-logfile -string ("Evaluating group id: "+$group.id)

        $Days = (New-TimeSpan -Start $group.CreatedDateTime -End $Today).Days  # Age of group
        $createdOn = Get-Date($group.CreatedDateTime) -format 'dd-MMM-yyyy HH:mm'

        out-logfile -string ("Age of group in days: "+$Days)
        out-logfile -string ("Group created on: "+$createdOn)

        if ($group.ExpirationDateTime -ne $null)
        {
            out-logfile -string "Group has expiration date - evaluate."

            $DaysLeft = (New-TimeSpan -Start $Today -End $group.ExpirationDateTime).Days
            $nextRenewal = Get-Date($group.ExpirationDateTime) -format 'dd-MMM-yyyy'
        }
        else 
        {
            $DaysLeft = "N/A"
            $nextRenewal = "N/A"
        }

        out-logfile -string ("Days till group expiration: "+$DaysLeft)
        out-logfile -string ("Expiration Date: "+$nextRenewal)

        if ($group.RenewedDateTime -ne $null)
        {
            out-logfile -string "Group has last renewed date - evaluate."

            $lastRenewal = Get-Date($group.RenewedDateTime) -format 'dd-MMM-yyyy'
        }
        else 
        {
            $lastRenewal = "N/A"
        }

        out-logfile -string ("Last Renewed Date: "+$lastRenewal)

        $ReportLine = [PSCustomObject]@{
            Group                   = $group.DisplayName
            GroupID                 = $group.id
            Created                 = $createdOn
            "Age in days"            = $Days
            "Last Renewed"           = $lastRenewal
            "Next Renewal"           = $nextRenewal
            "Days Before Expiration" = $DaysLeft
            "Group Expiration Policy ID" = ""}

      $functionGroups.Add($ReportLine)
    }

    out-logfile -string 'Exiting Calculate-GroupExpiration'

    return $functionGroups
}

#*****************************************************
Function Calculate-ExpirationPolicy
{
    Param
    (
        [Parameter(Mandatory=$true)]
        $groupsToEvaluate,
        [Parameter(Mandatory=$true)]
        $groupsExpirationPolicy
    )

    $groupExpirationPolicySelected = "Selected"

    out-logfile -string "Entering Calculate-ExpirationPolicy"

    foreach ($group in $groupsToEvaluate)
    {
        out-logfile -string ("Evaluting group: "+$group.group)
        out-logfile -string ("Evaluating group id: "+$group.groupID)
        
        if ($groupsExpirationPolicy.ManagedGroupTypes -eq $groupExpirationPolicySelected)
        {
            out-logfile -string "Group expiration policy is scoped to selected groups - evaluate group."
            $id = $group.groupID
            $uri = "https://graph.microsoft.com/v1.0/groups/$id/groupLifecyclePolicies"

            out-logfile -string $uri

            try {
                $policy = Invoke-MgGraphRequest -Method "Get" -Uri $uri -ErrorAction Stop

                if ($policy.value.id -ne $NULL)
                {
                    out-logfile -string ("Group has expiration policy id: "+$policy.value.id)
                    $group.'Group Expiration Policy ID' = $policy.value.id
                }
                else 
                {
                    out-logfile -string ("Group does not have expiration policy id.")
                    $group.'Group Expiration Policy ID' = "None"
                }
            }
            catch {
                $group.'Group Expiration Policy ID' = "None"
            }
        }
        else 
        {
            out-logfile -string "Group expiration policy applies to all groups - update ID."
            $group.'Group Expiration Policy ID' = $groupExpirationPolicy.id
        }

        out-logfile -string $group
    }

    out-logfile -string 'Exiting Calculate-ExpirationPolicy'

    return $groupsToEvaluate
}

#*****************************************************
Function Get-GroupExpirationPolicy
{
    out-logfile -string "Entering Get-GroupExpirationPolicy"

    try {
        $functionPolicy = Get-MgGroupLifecyclePolicy -errorAction STOP

        out-logfile -string 'Successfully obtained lifecycle policy.'
        out-logfile -string $functionPolicy
    }
    catch {
        out-logfile -string 'Unable to obtain group lifecycle policy.'
        out-logfile -string $_ -isError:$true
    }

    out-logfile -string 'Exiting Get-GroupExpirationPolicy'

    return $functionPolicy
}

#*****************************************************
Function WriteXMLFile
{
    [cmdletbinding()]

    Param
    (
        [Parameter(Mandatory = $true)]
        $outputFile,
        [Parameter(Mandatory = $true)]
        $data
    )

    out-logfile -string "Entering WriteXMLFile"

    try
    {
        out-logfile -string "Writing outout to xml file."

        $data | export-cliXML -path $outputFile -errorAction STOP
    }
    catch
    {
        out-logfile -string $_
        out-logfile -string "Unable to write data to XML file." -isError:$TRUE
    }
    
        out-logfile -string "Exiting WriteXMLFile"
}

#*****************************************************
Function WriteCSVFile
{
    [cmdletbinding()]

    Param
    (
        [Parameter(Mandatory = $true)]
        $outputFile,
        [Parameter(Mandatory = $true)]
        $data
    )

    out-logfile -string "Entering WriteCSVFile"

    try
    {
        out-logfile -string "Writing outout to csv file."

        $data | Export-Csv -path $outputFile -errorAction STOP
    }
    catch
    {
        out-logfile -string $_
        out-logfile -string "Unable to write data to CSV file." -isError:$TRUE
    }

    out-logfile -string "Exiting WriteCSVFile"
}

#*****************************************************
Function Generate-HTMLFile
{
    [cmdletbinding()]

    Param
    (
        [Parameter(Mandatory = $true)]
        $groupsOutput,
        [Parameter(Mandatory = $true)]
        $expirationSettings
    )

    out-logfile -string "Entering Generate-HTMLData"

    $functionHTMLSuffix = "HTML"
    $functionLogSuffix = "log"
    $functionHTMLFile = $global:LogFile.replace("$functionLogSuffix","$functionHTMLSuffix")
    $headerString = "Group Expiration Audit"

    $groupPolicyCount = ($groupsOutput | where {($_.'Group Expiration Policy ID' -ne "") -and ($_.'Group Expiration Policy ID' -ne "None")}).count
    $noGroupPolicyCount = ($groupsOutput | where {($_.'Group Expiration Policy ID' -eq "None")}).count
    $notEvaluatedCount = ($groupsOutput | where {($_.'Group Expiration Policy ID' -eq "")}).count
    $totalGroupsEvaluated = $groupsOutput.count

    new-HTML -TitleText $headerString -FilePath $functionHTMLFile {
        New-HTMLHeader {
            New-HTMLText -Text $headerString -FontSize 24 -Color White -BackGroundColor Black -Alignment center
        }
        new-htmlMain{
            New-HTMLTableOption -DataStore JavaScript

            New-htmlSection -HeaderText ("Group Expiration Information"){
                new-htmlTable -DataTable ($groupsOutput | Select-Object Group,GroupID,Created,'Age In Days','Last Renewed','Next Renewal','Days Before Expiration','Group Expiration Policy ID') -Filtering {
                } -AutoSize
            } -HeaderTextAlignment "Left" -HeaderTextSize "16" -HeaderTextColor "White" -HeaderBackGroundColor "Black"  -CanCollapse -BorderRadius 10px -collapsed

            New-htmlSection -HeaderText ("Group Expiration Policy Information"){
                new-htmlTable -DataTable ($expirationSettings | select-object ID,GroupLifeTimeInDays,AlternateNotificationEmails,ManagedGroupTypes) -Filtering {
                } -AutoSize
            } -HeaderTextAlignment "Left" -HeaderTextSize "16" -HeaderTextColor "White" -HeaderBackGroundColor "Black"  -CanCollapse -BorderRadius 10px -collapsed
            New-HTMLSection -HeaderText "Group Evaluation Summary" {
                new-htmlList{
                    new-htmlListItem -text ("Groups with Policy ID: "+$groupPolicyCount) -FontSize 14
                    new-htmlListItem -text ("Groups without Policy ID: "+$noGroupPolicyCount) -FontSize 14
                    new-htmlListItem -text ("Groups not evaluated for PolicyID: "+$notEvaluatedCount) -FontSize 14
                    new-htmlListItem -text ("Total Groups Evaluated: "+$totalGroupsEvaluated) -FontSize 14
                }
            }-HeaderTextAlignment "Left" -HeaderTextSize "16" -HeaderTextColor "White" -HeaderBackGroundColor "Black"  -CanCollapse -BorderRadius 10px -collapsed
        }
    } -online -ShowHTML

    out-logfile -string "Exiting Generate-HTMLData"
}

#*****************************************************
#*****************************************************

#Start main function

#*****************************************************
#*****************************************************

#Declare variables

[string]$logFileName = "GroupExpirationAudit"
[string]$graphConnectionType = ""
[string]$backSlash = "\"
$groupsToEvaluate = $null

$groupsOutput=[System.Collections.Generic.List[Object]]::new()
$groupExpirationPolicy = $null
[string]$logFileNameFull = $logFileName +".log"
[string]$m365GroupsXML = "Groups.xml"
[string]$m365GroupsInfo = "GroupsExpirationReport.csv"
[string]$m365GroupsPolicy = "GroupsPolicyInfo.xml"

new-LogFile -logFileName $logFileName -logFolderPath $logFolderPath

$outputM365Groups = $global:LogFile.replace($logFileNameFull,$m365GroupsXML)
$outputM365GroupsInfo = $global:LogFile.replace($logFileNameFull,$m365GroupsInfo)
$outputM365GroupsPolicy = $global:LogFile.replace($logFileNameFull,$m365GroupsPolicy)

out-logfile -string "Starting GroupExpirationAudit"

out-logfile -string "Validating graph parameters provided"

$graphConnectionType = Validate-GraphInfo -msGraphApplicationID $msGraphApplicationID -msGraphCertificateThumbprint $msGraphCertificateThumbprint -msGraphClientSecret $msGraphClientSecret -errorAction STOP

out-logfile -string ("Graph authentication type: "+$graphConnectionType)

out-logfile -string "Initiating connection to Microsoft Graph."

Connect-MicrosoftGraph -msGraphEnvironmentName $msGraphEnvironmentName -msGraphTenantID $msGraphTenantID -msGraphApplicationID $msGraphApplicationID -msGraphCertificateThumbprint $msGraphCertificateThumbprint -msGraphClientSecret $msGraphClientSecret -graphAuthenticationType $graphConnectionType -errorAction STOP

out-logfile -string "Validating necessary graph scopes post connection."

Validate-GraphScopes

out-logfile -string "Obtain all M365 or Unified Group types for evaluation."

$groupsToEvaluate = Get-M365Groups

$groupExpirationPolicy = Get-GroupExpirationPolicy

WriteXMLFile -outputFile $outputM365GroupsPolicy -data $groupExpirationPolicy

if ($groupsToEvaluate.count -gt 0)
{
    out-logfile -string "M365 groups were located in Entra ID - proceed with evaluation."

    out-logfile -string "Calculate group expiration information and create objects."

    WriteXMLFile -outputFile $outputM365Groups -data $groupsToEvaluate

    $groupsOutput = Calculate-GroupExpiration -groupsToEvaluate $groupsToEvaluate

    if ($includePolicyEvaluation -eq $TRUE)
    {
        out-logfile -string "Policy evaluation is included."

        $groupsOutput = Calculate-ExpirationPolicy -groupsToEvaluate $groupsOutput -groupsExpirationPolicy $groupExpirationPolicy
    }
    else 
    {
        out-logfile -string "Policy evaluation was not included."
    }


    WriteCSVFile -outputFile $outputM365GroupsInfo -data $groupsOutput

    Generate-HTMLFile -groupsoutput $groupsOutput -expirationSettings $groupExpirationPolicy
}
else 
{
    out-logfile -string "M365 groups were not located in Entra ID - no further work to do."
}