Public/Entra/Object/Get-MgExtensionAttributeInfo.ps1
|
<#
.SYNOPSIS Inventories the custom attributes usable in Entra ID dynamic group rules: the 15 built-in extension attributes and the directory extensions. .DESCRIPTION Returns one row per custom attribute available in the tenant, whether it carries a value or not, so the inventory is exhaustive. Two mechanisms are covered: - the 15 built-in extension attributes exposed through onPremisesExtensionAttributes (extensionAttribute1..15); - the directory extensions declared by an application, named extension_<AppId without dashes>_<Name>. Custom security attributes are deliberately out of scope: they cannot be used in dynamic membership rules. Use Get-MgCustomSecurityAttributeInfo for those. For every attribute the function reports whether it is populated, how many objects carry a value, and which dynamic groups reference it. It also flags the two situations that are hard to see from the portal: - Orphaned: the application that declared the directory extension no longer exists. The values remain in the directory and keep driving dynamic group membership, but the attribute can no longer be listed or corrected, and recreating the application does not help since the new AppId differs. - Referenced but empty: a dynamic group rule points at an attribute no object carries, so the group stays empty. .PARAMETER TargetObject Object types to count values on. Valid values: User, Group, Device. Default is User only. Adding Device also counts the built-in extension attributes on devices, which carry them under a different property name and can be addressed by a device membership rule. .PARAMETER ExcludeBuiltIn Leaves the 15 built-in extension attributes out of the inventory and reports directory extensions only. .PARAMETER SkipUsageCount Skips counting the objects carrying a value. The count is one Graph query per attribute and per object type, which is the slow part of the scan on a large tenant. .PARAMETER SkipDynamicGroups Skips reading the dynamic group membership rules, so the DynamicGroups columns are left empty. .PARAMETER ForceNewToken Forces a new token to be requested from Microsoft Graph. .PARAMETER ExportToExcel Exports the result to an Excel file in the user's profile directory instead of returning it. .PARAMETER ExportPath Optional output directory for the Excel export. Defaults to the user profile. .EXAMPLE Get-MgExtensionAttributeInfo Returns every built-in extension attribute and every directory extension, with their usage and their status. .EXAMPLE Get-MgExtensionAttributeInfo -TargetObject User, Group, Device Counts the objects carrying a value on users, groups and devices instead of users only. .EXAMPLE Get-MgExtensionAttributeInfo -SkipUsageCount Lists the attributes without counting values. Fast inventory on a large tenant. .EXAMPLE Get-MgExtensionAttributeInfo -ExportToExcel Exports the inventory to an Excel file in the user's profile directory. .NOTES Required Microsoft Graph permissions: - Application.Read.All - Directory.Read.All - Group.Read.All - User.Read.All The value counts use advanced queries ($count with ConsistencyLevel eventual). When Graph refuses a filter on a given attribute, the count is left null and the reason is reported in the CountError column rather than failing the whole scan. A count that failed on one object type is never reported as a partial total: an attribute counted at zero on users and unreadable on devices is left Unknown rather than offered for cleanup. Definitions come from getAvailableExtensionProperties rather than from a walk of the applications collection. That is the only supported way to see an extension whose declaring application has been deleted, since Graph exposes no extensionProperties navigation on the recycle bin. Dynamic group rules are matched on both the user. and device. prefixes: extension attributes and directory extensions can be addressed either way depending on the type of group. .LINK https://ps365.clidsys.com/docs/commands/Get-MgExtensionAttributeInfo #> function Get-MgExtensionAttributeInfo { [CmdletBinding()] param ( [Parameter(Mandatory = $false, Position = 0)] [ValidateSet('User', 'Group', 'Device')] [string[]]$TargetObject = @('User'), [Parameter(Mandatory = $false)] [switch]$ExcludeBuiltIn, [Parameter(Mandatory = $false)] [switch]$SkipUsageCount, [Parameter(Mandatory = $false)] [switch]$SkipDynamicGroups, [Parameter(Mandatory = $false)] [switch]$ForceNewToken, [Parameter(Mandatory = $false)] [switch]$ExportToExcel, [Parameter(Mandatory = $false, HelpMessage = 'Optional output directory for the Excel export (defaults to the user profile).')] [string]$ExportPath ) $modules = @( 'Microsoft.Graph.Authentication' ) foreach ($module in $modules) { try { $null = Import-Module $module -ErrorAction Stop } catch { Write-Warning "Please install $module first" return } } $permissionsNeeded = @( 'Application.Read.All' 'Directory.Read.All' 'Group.Read.All' 'User.Read.All' ) $isConnected = $null -ne (Get-MgContext -ErrorAction SilentlyContinue) if ($ForceNewToken.IsPresent) { $null = Disconnect-MgGraph -ErrorAction SilentlyContinue $isConnected = $false } if (-not $isConnected) { Write-Host -ForegroundColor Cyan 'Connecting to Microsoft Graph' $null = Connect-MgGraph -Scopes $permissionsNeeded -NoWelcome } if (-not (Test-MgGraphPermission -RequiredScopes $permissionsNeeded -CallerName $MyInvocation.MyCommand.Name)) { return } $collectionByObject = @{ User = 'users' Group = 'groups' Device = 'devices' } function Get-GraphCollection { param( [Parameter(Mandatory = $true)] [string]$Uri ) $items = [System.Collections.Generic.List[PSCustomObject]]@() $next = $Uri do { # Through the retry wrapper: a throttled page would land in a catch block that carries # on with a partial list, and a directory extension missing from an inventory reads as # an extension that does not exist. $response = Invoke-MgGraphRequestWithRetry -Method GET -Uri $next -OutputType PSObject foreach ($item in $response.value) { $items.Add($item) } $next = $response.'@odata.nextLink' } while ($next) return $items } # Directory extension definitions are read with getAvailableExtensionProperties, which returns # every definition registered in the tenant, including those declared by an application that has # since been deleted. Walking the applications collection cannot reach those: the application is # gone from that collection, and Graph exposes no extensionProperties navigation on the recycle # bin. The declaring application is identified by the AppId embedded in the attribute name. Write-Host -ForegroundColor Cyan 'Retrieving directory extension definitions' $availableExtensions = @() try { $response = Invoke-MgGraphRequestWithRetry -Method POST -Uri 'https://graph.microsoft.com/v1.0/directoryObjects/getAvailableExtensionProperties' -Body '{}' -ContentType 'application/json' -OutputType PSObject $availableExtensions = @($response.value) } catch { Write-Warning "Unable to retrieve the directory extension definitions: $_" return } # Applications are read only to name the owner of each extension and to tell a live application # from a missing one. The AppId is normalized the way it appears in an attribute name: no dashes. $applicationsByAppId = @{} try { $applications = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName&$top=999' foreach ($application in $applications) { $applicationsByAppId[($application.appId -replace '-', '').ToLowerInvariant()] = $application } } catch { Write-Warning "Unable to retrieve applications, every extension will be reported as orphaned: $_" } $deletedApplicationsByAppId = @{} try { $deletedApplications = Get-GraphCollection -Uri 'https://graph.microsoft.com/v1.0/directory/deletedItems/microsoft.graph.application?$select=id,appId,displayName,deletedDateTime&$top=999' foreach ($application in $deletedApplications) { $deletedApplicationsByAppId[($application.appId -replace '-', '').ToLowerInvariant()] = $application } } catch { # Reading the recycle bin can be denied without breaking the rest: an extension whose # application is missing is still reported as orphaned, only the deletion date is lost. Write-Warning "Unable to read deleted applications, deletion dates will be missing: $_" } [System.Collections.Generic.List[PSCustomObject]]$extensionDefinitions = @() foreach ($property in $availableExtensions) { # extension_<AppId without dashes>_<Name>. An on-premises synced extension follows the same # shape; anything else is left with an unknown owner rather than guessed at. $ownerAppKey = if ($property.name -match '^extension_([0-9a-fA-F]{32})_') { $Matches[1].ToLowerInvariant() } else { $null } $ownerApplication = $null $isDeleted = $false if ($ownerAppKey) { if ($applicationsByAppId.ContainsKey($ownerAppKey)) { $ownerApplication = $applicationsByAppId[$ownerAppKey] } elseif ($deletedApplicationsByAppId.ContainsKey($ownerAppKey)) { $ownerApplication = $deletedApplicationsByAppId[$ownerAppKey] $isDeleted = $true } else { # Neither live nor in the recycle bin: the application is gone for good, which is the # worst case since the extension can no longer be maintained at all. $isDeleted = $true } } $extensionDefinitions.Add([PSCustomObject]@{ Name = $property.name DataType = $property.dataType IsMultiValued = $property.isMultiValued TargetObjects = ($property.targetObjects -join ', ') AppDisplayName = if ($ownerApplication) { $ownerApplication.displayName } else { $property.appDisplayName } AppId = if ($ownerApplication) { $ownerApplication.appId } else { $ownerAppKey } AppDeleted = $isDeleted AppDeletedDate = if ($ownerApplication) { $ownerApplication.deletedDateTime } else { $null } }) } Write-Host -ForegroundColor Cyan "Found $($extensionDefinitions.Count) directory extension(s)" # Membership rules are read once and searched per attribute afterwards: one pass over the # groups instead of one query per attribute. $dynamicGroups = @() if (-not $SkipDynamicGroups.IsPresent) { Write-Host -ForegroundColor Cyan 'Retrieving dynamic group membership rules' try { $dynamicGroups = Get-GraphCollection -Uri "https://graph.microsoft.com/v1.0/groups?`$filter=groupTypes/any(c:c eq 'DynamicMembership')&`$select=id,displayName,membershipRule&`$top=999" Write-Host -ForegroundColor Cyan "Found $($dynamicGroups.Count) dynamic group(s)" } catch { Write-Warning "Unable to retrieve dynamic groups, the DynamicGroups columns will be empty: $_" } } function Get-GroupsUsingAttribute { param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]]$Groups, [Parameter(Mandatory = $true)] [string]$AttributeName ) # A rule addresses the attribute as 'user.<name>' or, on a device group, as # 'device.<name>': both prefixes are supported for extension attributes and for # directory extensions. The trailing word boundary matters: a plain substring match # on 'user.extensionAttribute1' also hits a rule that only uses extensionAttribute15. $pattern = "(?:user|device)\.$([regex]::Escape($AttributeName))\b" return @($Groups | Where-Object { $_.membershipRule -and $_.membershipRule -match $pattern }) } function Get-ValueCount { param( [Parameter(Mandatory = $true)] [string]$Collection, [Parameter(Mandatory = $true)] [string]$Filter ) $uri = "https://graph.microsoft.com/v1.0/$Collection/`$count?`$filter=$([uri]::EscapeDataString($Filter))" # One count per attribute and per collection is where throttling actually bites on a large # tenant, so this is the call that most needs the backoff. # # Json, not Text: the SDK OutputType enum only accepts HashTable, PSObject, # HttpResponseMessage and Json, so Text fails parameter binding before the request is even # sent. A count endpoint answers with a bare number, which is valid Json, so the raw string # comes back untouched. $raw = Invoke-MgGraphRequestWithRetry -Method GET -Uri $uri -Headers @{ ConsistencyLevel = 'eventual' } -OutputType Json $parsed = 0 if (-not [int]::TryParse(([string]$raw -replace '[^\d]', ''), [ref]$parsed)) { throw "Graph returned a count that is not a number: $raw" } return $parsed } [System.Collections.Generic.List[PSCustomObject]]$resultsArray = @() # Built-in extension attributes: they always exist, so the useful information is which of # the fifteen actually carry a value. if (-not $ExcludeBuiltIn.IsPresent) { Write-Host -ForegroundColor Cyan 'Inspecting the 15 built-in extension attributes' foreach ($index in 1..15) { $attributeName = "extensionAttribute$index" $usedByGroups = Get-GroupsUsingAttribute -Groups $dynamicGroups -AttributeName $attributeName $count = $null $countError = $null if (-not $SkipUsageCount.IsPresent) { $countIsComplete = $true # Users and devices hold the fifteen attributes under different property names, and # a membership rule can address either. Devices are counted only when asked for. $countSources = [ordered]@{ users = "onPremisesExtensionAttributes/$attributeName ne null" } if ($TargetObject -contains 'Device') { $countSources['devices'] = "extensionAttributes/$attributeName ne null" } foreach ($collection in $countSources.Keys) { try { $count = [int]$count + (Get-ValueCount -Collection $collection -Filter $countSources[$collection]) } catch { $countError = $_.Exception.Message $countIsComplete = $false } } if (-not $countIsComplete) { $count = $null } } $resultsArray.Add([PSCustomObject][ordered]@{ Kind = 'BuiltIn' AttributeName = $attributeName FriendlyName = $attributeName TargetObjects = if ($TargetObject -contains 'Device') { 'User, Device' } else { 'User' } DataType = 'String' IsMultiValued = $false OwnerApp = $null OwnerAppId = $null OwnerAppDeleted = $null OwnerAppDeletedOn = $null ObjectsWithValue = $count CountError = $countError DynamicGroupCount = $usedByGroups.Count DynamicGroupNames = ($usedByGroups.displayName -join ' | ') Status = if ($usedByGroups.Count -gt 0 -and $count -eq 0) { 'ReferencedButEmpty' } elseif ($count -gt 0) { 'InUse' } elseif ($null -eq $count) { 'Unknown' } else { 'Empty' } }) } } # Directory extensions: here the attribute itself may be dead weight, or worse, orphaned. if ($extensionDefinitions.Count -gt 0) { Write-Host -ForegroundColor Cyan 'Inspecting directory extensions' } foreach ($definition in $extensionDefinitions) { $usedByGroups = Get-GroupsUsingAttribute -Groups $dynamicGroups -AttributeName $definition.Name $totalCount = $null $countError = $null if (-not $SkipUsageCount.IsPresent) { $countIsComplete = $true foreach ($object in $TargetObject) { # An extension declared for users only cannot be counted on devices. if ($definition.TargetObjects -and $definition.TargetObjects -notmatch $object) { continue } try { $count = Get-ValueCount -Collection $collectionByObject[$object] -Filter "$($definition.Name) ne null" $totalCount = [int]$totalCount + $count } catch { $countError = $_.Exception.Message $countIsComplete = $false } } # A partial total is worse than no total: an attribute counted at zero on users and # unreadable on devices would otherwise be reported as empty and offered for cleanup. if (-not $countIsComplete) { $totalCount = $null } } $resultsArray.Add([PSCustomObject][ordered]@{ Kind = 'DirectoryExtension' AttributeName = $definition.Name FriendlyName = ($definition.Name -split '_', 3)[-1] TargetObjects = $definition.TargetObjects DataType = $definition.DataType IsMultiValued = $definition.IsMultiValued OwnerApp = $definition.AppDisplayName OwnerAppId = $definition.AppId OwnerAppDeleted = $definition.AppDeleted OwnerAppDeletedOn = $definition.AppDeletedDate ObjectsWithValue = $totalCount CountError = $countError DynamicGroupCount = $usedByGroups.Count DynamicGroupNames = ($usedByGroups.displayName -join ' | ') Status = if ($definition.AppDeleted) { 'Orphaned' } elseif ($usedByGroups.Count -gt 0 -and $totalCount -eq 0) { 'ReferencedButEmpty' } elseif ($totalCount -gt 0) { 'InUse' } elseif ($null -eq $totalCount) { 'Unknown' } else { 'Empty' } }) } if ($resultsArray.Count -eq 0) { Write-Host -ForegroundColor Yellow 'No custom attribute found in this tenant.' return } $orphaned = @($resultsArray | Where-Object { $_.Status -eq 'Orphaned' }) if ($orphaned.Count -gt 0) { Write-Host -ForegroundColor Yellow "$($orphaned.Count) directory extension(s) belong to a deleted application. Their values still apply but the attributes can no longer be maintained." } $referencedButEmpty = @($resultsArray | Where-Object { $_.Status -eq 'ReferencedButEmpty' }) if ($referencedButEmpty.Count -gt 0) { Write-Host -ForegroundColor Yellow "$($referencedButEmpty.Count) attribute(s) are referenced by a dynamic group rule but carried by no object." } Write-Host -ForegroundColor Green "Found $($resultsArray.Count) custom attribute(s)." if ($ExportToExcel.IsPresent) { $now = Get-Date -Format 'yyyy-MM-dd_HHmmss' $excelFilePath = "$(if ($ExportPath) { $ExportPath } else { $env:userprofile })\$now-MgExtensionAttributeInfo.xlsx" Write-Host -ForegroundColor Cyan "Exporting extension attribute inventory to Excel file: $excelFilePath" $resultsArray | Export-Excel -Path $excelFilePath -AutoSize -AutoFilter -WorksheetName 'Entra-ExtensionAttributes' -TableStyle Light9 Write-Host -ForegroundColor Green 'Export completed successfully!' } else { return $resultsArray } } |