Public/Get-TntPrivilegedRoleReport.ps1
|
function Get-TntPrivilegedRoleReport { <# .SYNOPSIS Generates a report of permanent privileged role assignments and emergency access accounts. .DESCRIPTION This function analyzes permanent privileged role assignments in Azure AD, identifies emergency access accounts, and retrieves role activation audit logs. This function does NOT require an Azure AD Premium P2 license, as it focuses on permanent role assignments rather than PIM. For PIM-specific analysis (eligible and active assignments), use Get-TntPIMReport. .PARAMETER TenantId The Azure AD Tenant ID (GUID) to connect to. .PARAMETER ClientId The Application (Client) ID of the app registration created for security reporting. .PARAMETER ClientSecret The client secret for the app registration. Use this for automated scenarios. .PARAMETER CertificateThumbprint The thumbprint of the certificate to use for authentication instead of client secret. .PARAMETER LookbackDays Number of days to look back for activation pattern analysis. Defaults to 30 days. .PARAMETER EmergencyAccountPattern Regex pattern to identify emergency access accounts. Defaults to common patterns. .EXAMPLE Get-TntPrivilegedRoleReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret Generates a privileged role report for permanent assignments and emergency accounts. .EXAMPLE Get-TntPrivilegedRoleReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret -LookbackDays 90 Generates a report analyzing the last 90 days of role activation logs. .INPUTS None. This function does not accept pipeline input. .OUTPUTS System.Management.Automation.PSCustomObject Returns a privileged role report object with: - Summary: Statistics on assignments and activations - PermanentAssignments: Detailed list of permanent role assignments - RoleActivations: Audit logs of role activations - EmergencyAccessAccounts: Identified break-glass accounts - AssignmentsByRole: Grouped count of assignments per role .NOTES Author: Tom de Leeuw Website: https://systom.dev Module: TenantReports Required Permissions: - RoleManagement.Read.Directory (Application) - Directory.Read.All (Application) - AuditLog.Read.All (Application) Prerequisites: - No Azure AD Premium P2 license required (unlike PIM-based analysis) - Security Reader, Global Reader, or equivalent role to query privileged assignments. .LINK https://systom.dev #> [CmdletBinding(DefaultParameterSetName = 'ClientSecret')] [OutputType([System.Management.Automation.PSCustomObject])] param( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')] [Parameter(ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [Alias('Tenant')] [string]$TenantId, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')] [Parameter(ParameterSetName = 'Interactive')] [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')] [Alias('ApplicationId')] [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('Secret', 'ApplicationSecret')] [SecureString]$ClientSecret, [Parameter(Mandatory = $true, ParameterSetName = 'Certificate', ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('Thumbprint')] [string]$CertificateThumbprint, # Use interactive authentication (no app registration required). [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')] [switch]$Interactive, [Parameter()] [ValidateRange(1, 90)] [int]$LookbackDays = 30, [Parameter()] [string]$EmergencyAccountPattern = '(emergency|break-?glass|admin-?break|bg-|ea-)' ) begin { # Calculate date range for analysis $StartDate = (Get-Date).AddDays(-$LookbackDays) $StartDateString = $StartDate.ToString('yyyy-MM-ddTHH:mm:ssZ') Write-Information 'Starting privileged role report generation...' -InformationAction Continue } process { try { # Establish connection $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters $ConnectionInfo = Connect-TntGraphSession @ConnectionParams # Initialize collections $PermanentAssignments = [System.Collections.Generic.List[PSObject]]::new() $RoleActivations = [System.Collections.Generic.List[PSObject]]::new() $EmergencyAccounts = [System.Collections.Generic.List[PSObject]]::new() # Get all role definitions to identify privileged roles Write-Verbose 'Retrieving role definitions...' $RoleDefinitions = Get-MgRoleManagementDirectoryRoleDefinition -All -ErrorAction Stop # Client-side filtering required: Graph API does not support filtering by DisplayName array or IsBuiltIn property $PrivilegedRoles = $RoleDefinitions | Where-Object { $_.DisplayName -in $script:PrivilegedRoleNames -or $_.IsBuiltIn -eq $false } Write-Verbose "Identified $($PrivilegedRoles.Count) privileged roles" # Get permanent role assignments Write-Verbose 'Retrieving permanent role assignments...' $AllPermanentAssignments = Get-MgRoleManagementDirectoryRoleAssignment -All -ExpandProperty Principal -ErrorAction Stop foreach ($Assignment in $AllPermanentAssignments) { $Role = $PrivilegedRoles | Where-Object { $_.Id -eq $Assignment.RoleDefinitionId } if ($Role) { $PrincipalType = if ($Assignment.Principal.AdditionalProperties.'@odata.type') { $Assignment.Principal.AdditionalProperties.'@odata.type' -replace '#microsoft.graph.', '' } else { 'Unknown' } # Get PrincipalName - check direct property first, then AdditionalProperties $PrincipalName = $Assignment.Principal.DisplayName if (-not $PrincipalName) { $PrincipalName = $Assignment.Principal.AdditionalProperties.displayName } # If still empty, retrieve from Graph API based on principal type if (-not $PrincipalName -and $Assignment.PrincipalId) { try { switch ($PrincipalType) { 'group' { $Group = Get-MgGroup -GroupId $Assignment.PrincipalId -Property DisplayName -ErrorAction SilentlyContinue $PrincipalName = $Group.DisplayName } 'servicePrincipal' { $ServicePrincipal = Get-MgServicePrincipal -ServicePrincipalId $Assignment.PrincipalId -Property DisplayName -ErrorAction SilentlyContinue $PrincipalName = $ServicePrincipal.DisplayName } } } catch { Write-Verbose "Unable to retrieve name for $PrincipalType $($Assignment.PrincipalId): $($_.Exception.Message)" } } $PrincipalUPN = $Assignment.Principal.AdditionalProperties.userPrincipalName $PermanentAssignments.Add([PSCustomObject]@{ AssignmentId = $Assignment.Id RoleId = $Role.Id RoleName = $Role.DisplayName RoleType = if ($Role.IsBuiltIn) { 'Built-in' } else { 'Custom' } PrincipalId = $Assignment.PrincipalId PrincipalName = $PrincipalName PrincipalUPN = $PrincipalUPN PrincipalType = $PrincipalType AssignmentType = 'Permanent' CreatedDateTime = $Assignment.CreatedDateTime ExpirationDateTime = $null IsEmergencyAccount = if ($PrincipalUPN) { $PrincipalUPN -match $EmergencyAccountPattern -or $PrincipalName -match $EmergencyAccountPattern } else { $false } }) } } # Get role activations from audit logs Write-Verbose 'Retrieving role activation audit logs...' try { $AuditFilter = "activityDateTime ge $($StartDateString) and category eq 'RoleManagement'" $AuditLogs = Get-MgAuditLogDirectoryAudit -Filter $AuditFilter -All -ErrorAction Stop $RoleActivationLogs = $AuditLogs | Where-Object { $_.ActivityDisplayName -match 'Add member to role|Role activation|Activate role' } foreach ($Log in $RoleActivationLogs) { $RoleActivations.Add([PSCustomObject]@{ Id = $Log.Id ActivityDateTime = $Log.ActivityDateTime ActivityDisplayName = $Log.ActivityDisplayName InitiatedBy = $Log.InitiatedBy.User.UserPrincipalName TargetRole = ($Log.TargetResources | Where-Object { $_.Type -eq 'Role' }).DisplayName TargetUserPrincipalName = ($Log.TargetResources | Where-Object { $_.Type -eq 'User' }).UserPrincipalName Result = $Log.Result ResultReason = $Log.ResultReason }) } } catch { Write-Warning "Unable to retrieve audit logs: $($_.Exception.Message)" } # Identify emergency access accounts Write-Verbose 'Analyzing emergency access accounts...' $PotentialEmergencyAccounts = $PermanentAssignments | Where-Object { $_.IsEmergencyAccount -or ($_.RoleName -eq 'Global Administrator' -and $_.AssignmentType -eq 'Permanent') } | Select-Object PrincipalId, PrincipalName, PrincipalUPN -Unique # Pre-fetch emergency account users (incremental mode - only specific users needed) $EmergencyPrincipalIds = @($PotentialEmergencyAccounts | Select-Object -ExpandProperty PrincipalId -Unique) $UserCache = $null if ($EmergencyPrincipalIds.Count -gt 0) { Write-Verbose "Pre-fetching $($EmergencyPrincipalIds.Count) emergency account users (incremental mode)..." $CacheParams = @{ TenantId = $TenantId ClientId = $ClientId UserIds = $EmergencyPrincipalIds RequiredProperties = @('CreatedDateTime', 'SignInActivity') ForceBetaAPI = $true } $UserCache = Get-CachedUsers @CacheParams Write-Verbose "User cache ready: $($UserCache.UserCount) users (CacheHit: $($UserCache.CacheHit))" } foreach ($Account in $PotentialEmergencyAccounts) { try { # O(1) lookup from pre-fetched cache $User = $null if ($UserCache) { $User = $UserCache.LookupById[$Account.PrincipalId] } # Fallback to individual API call if not in cache if (-not $User) { $User = Get-MgUser -UserId $Account.PrincipalId -Property Id, DisplayName, UserPrincipalName, AccountEnabled, CreatedDateTime, SignInActivity -ErrorAction SilentlyContinue } if ($User) { $AccountRoles = $PermanentAssignments | Where-Object { $_.PrincipalId -eq $Account.PrincipalId } $LastSignIn = $User.SignInActivity.LastSignInDateTime # Validate LastSignIn - handle "-" or empty string values if ($LastSignIn -is [string] -and ($LastSignIn -eq '-' -or [string]::IsNullOrWhiteSpace($LastSignIn))) { $LastSignIn = $null } $EmergencyAccounts.Add([PSCustomObject]@{ UserId = $User.Id DisplayName = $User.DisplayName UserPrincipalName = $User.UserPrincipalName AccountEnabled = $User.AccountEnabled CreatedDateTime = $User.CreatedDateTime LastSignInDateTime = $LastSignIn AssignedRoles = ($AccountRoles.RoleName | Sort-Object -Unique) -join '; ' PermanentRoles = ($AccountRoles.RoleName) -join '; ' HasPermanentGlobalAdmin = ($AccountRoles | Where-Object { $_.RoleName -eq 'Global Administrator' }).Count -gt 0 MatchesNamingPattern = $User.UserPrincipalName -match $EmergencyAccountPattern -or $User.DisplayName -match $EmergencyAccountPattern DaysSinceLastSignIn = if ($LastSignIn -and $LastSignIn -is [datetime]) { [math]::Round(((Get-Date) - $LastSignIn).TotalDays, 0) } else { $null } }) } } catch { Write-Verbose "Unable to retrieve details for user $($Account.PrincipalId): $($_.Exception.Message)" } } # Calculate summary statistics $UniquePrivilegedUsers = ($PermanentAssignments | Where-Object { $_.PrincipalType -eq 'user' } | Select-Object PrincipalId -Unique).Count # Generate summary $Summary = [PSCustomObject]@{ TenantId = $TenantId ReportGeneratedDate = Get-Date AnalysisPeriodDays = $LookbackDays TotalPermanentAssignments = $PermanentAssignments.Count UniquePrivilegedUsers = $UniquePrivilegedUsers EmergencyAccessAccounts = $EmergencyAccounts.Count RoleActivationsInPeriod = $RoleActivations.Count GlobalAdministrators = ($PermanentAssignments | Where-Object { $_.RoleName -eq 'Global Administrator' }).Count CustomRoles = ($PermanentAssignments | Where-Object { $_.RoleType -eq 'Custom' } | Select-Object RoleId -Unique).Count } # Build comprehensive report Write-Information "Privileged role report completed - $($PermanentAssignments.Count) permanent assignments found" -InformationAction Continue [PSCustomObject]@{ Summary = $Summary PermanentAssignments = $PermanentAssignments | Sort-Object RoleName, PrincipalName RoleActivations = $RoleActivations | Sort-Object ActivityDateTime -Descending EmergencyAccessAccounts = $EmergencyAccounts | Sort-Object DisplayName AssignmentsByRole = $PermanentAssignments | Group-Object RoleName | ForEach-Object { [PSCustomObject]@{ RoleName = $_.Name TotalAssignments = $_.Count } } | Sort-Object TotalAssignments -Descending } } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-TntPrivilegedRoleReport failed: $($_.Exception.Message)", $_.Exception), 'GetTntPrivilegedRoleReportError', [System.Management.Automation.ErrorCategory]::OperationStopped, $TenantId ) $PSCmdlet.ThrowTerminatingError($errorRecord) } finally { if ($ConnectionInfo.ShouldDisconnect) { Disconnect-TntGraphSession -ConnectionState $ConnectionInfo } } } } |