Providers/Entra/Private/Get-EntraTeardownCapability.ps1
|
function Get-EntraTeardownCapability { <# .SYNOPSIS Works out, before anything is prompted for, which teardown layers this identity can remove .DESCRIPTION A teardown that asks the person to confirm each deletion and then fails each one with a 403 has told them nothing they could act on and cost them a keypress per object. The permission question can be answered up front, from two sources: - the token's own claims: 'roles' on an app-only token, 'scp' on a delegated one, and 'wids' where the token carries directory roles - the identity's directory roles read from Graph, because a service principal that is a Global Administrator can delete everything while its token's roles claim says it can only read. That is documented in the README as the reason a claims-only check is a mistake, and it is why the roles are looked up rather than trusted absent. Each layer of the teardown is allowed if the identity holds any of the permissions that can delete that type, or any of the directory roles that can. Where neither source can be read - a token that is not a JWT, a Graph lookup that fails - the answer is unknown and the layer is allowed, because refusing to try on no evidence is worse than a failed delete that reports itself. Applications are the one layer with an in-between answer. Application.ReadWrite.OwnedBy permits deleting only applications the identity owns, so a caller holding that grant and nothing broader is told to filter by ownership rather than to skip the layer. .PARAMETER Connection The connection to judge. Defaults to the active one. .OUTPUTS EntraTeardownCapability with Known, IdentityKind, IdentityObjectId, Permissions, DirectoryRoles, Layers and ApplicationsOwnedOnly. .EXAMPLE PS> $capability = Get-EntraTeardownCapability DESCRIPTION: Judges the active connection OUTPUT: One Allowed flag and Reason per teardown layer USE CASE: The start of Remove-EntraEnvironment .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding()] [OutputType('EntraTeardownCapability')] param( [Parameter()] [hashtable]$Connection ) if (-not $Connection) { $Connection = Get-EntraConnection } # --- The token's claims ------------------------------------------------------------------ $claims = $null $token = [string]$Connection.AccessToken $segments = $token.Split('.') if ($segments.Count -ge 2) { try { $claims = [System.Text.Encoding]::UTF8.GetString((ConvertFrom-TestBase64Url -Text $segments[1])) | ConvertFrom-Json } catch { Write-Verbose "Could not decode the token claims: $($_.Exception.Message)" } } $permissions = @() $tokenRoles = @() $identityKind = 'Unknown' if ($claims) { if ($claims.PSObject.Properties['roles'] -and $claims.roles) { $permissions = @($claims.roles); $identityKind = 'Application' } if ($claims.PSObject.Properties['scp'] -and $claims.scp) { $permissions = @([string]$claims.scp -split '\s+' | Where-Object { $_ }); $identityKind = 'User' } if ($claims.PSObject.Properties['wids'] -and $claims.wids) { $tokenRoles = @($claims.wids) } } if ($identityKind -eq 'Unknown' -and $Connection.AuthMode -eq 'DeviceCode') { $identityKind = 'User' } if ($identityKind -eq 'Unknown' -and $Connection.AuthMode -eq 'Certificate') { $identityKind = 'Application' } # --- The identity, and its directory roles from Graph ----------------------------------- # Looked up rather than read from the token, because an app-only token does not carry the # roles its service principal holds, and those roles are what let a read-only-looking app # delete everything. $identityObjectId = $null $directoryRoles = @() $rolesKnown = $false try { if ($identityKind -eq 'User') { $me = Invoke-EntraRequest -Method GET -Path '/me' -Query @{ '$select' = 'id' } -Connection $Connection $identityObjectId = [string]$me.id } elseif ($Connection.ClientId) { $principal = @(Invoke-EntraRequest -Method GET -Path '/servicePrincipals' -Connection $Connection -Paginate -ConsistencyLevel ` -Query @{ '$filter' = "appId eq '$($Connection.ClientId)'"; '$select' = 'id' }) | Select-Object -First 1 if ($principal) { $identityObjectId = [string]$principal.id } } if ($identityObjectId) { $container = if ($identityKind -eq 'User') { '/users' } else { '/servicePrincipals' } $memberships = @(Invoke-EntraRequest -Method GET -Connection $Connection -Paginate ` -Path "$container/$identityObjectId/transitiveMemberOf/microsoft.graph.directoryRole" ` -Query @{ '$select' = 'roleTemplateId,displayName' }) $directoryRoles = @($memberships | ForEach-Object { [string]$_.roleTemplateId } | Where-Object { $_ }) $rolesKnown = $true } } catch { Write-Verbose "Could not read the identity's directory roles, so they are treated as unknown: $($_.Exception.Message)" } $directoryRoles = @($directoryRoles + $tokenRoles | Sort-Object -Unique) if ($tokenRoles.Count -gt 0) { $rolesKnown = $true } $known = ($permissions.Count -gt 0) -or $rolesKnown # --- What each layer needs ---------------------------------------------------------------- # Any one of the permissions, or any one of the directory roles, is enough. Template ids # are the well-known ones Entra uses for every tenant. $globalAdministrator = '62e90394-69f5-4237-9190-012177145e10' $privilegedRoleAdministrator = 'e8611ab8-c189-46e8-94e1-60213ab1f814' $conditionalAccessAdministrator = 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9' $securityAdministrator = '194ae4cb-b126-40b2-bd5b-6091b380977d' $authenticationPolicyAdministrator = '0526716b-113d-4c15-b2c8-68e3c22b9f80' $applicationAdministrator = '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3' $cloudApplicationAdministrator = '158c047a-c907-4556-b7ef-446551a6b5f7' $userAdministrator = 'fe930be7-5e62-47db-91af-98c3a49a38b1' $groupsAdministrator = 'fdd7a751-b60b-444a-984c-02652fe8fa1c' $licenseAdministrator = '4d6ac14f-3453-41d0-bef9-a3e0c569773a' $cloudDeviceAdministrator = '7698a772-787b-4ac8-901f-60d6b08affd2' $intuneAdministrator = '3a2c62db-5318-420d-8d74-23affee5d9d5' $requirements = [ordered]@{ ConditionalAccessPolicies = @{ Permissions = @('Policy.ReadWrite.ConditionalAccess') Roles = @($globalAdministrator, $securityAdministrator, $conditionalAccessAdministrator) } AuthenticationStrengths = @{ Permissions = @('Policy.ReadWrite.ConditionalAccess', 'Policy.ReadWrite.AuthenticationMethod') Roles = @($globalAdministrator, $securityAdministrator, $conditionalAccessAdministrator, $authenticationPolicyAdministrator) } RoleEligibilities = @{ Permissions = @('RoleManagement.ReadWrite.Directory', 'RoleEligibilitySchedule.ReadWrite.Directory') Roles = @($globalAdministrator, $privilegedRoleAdministrator) } DirectoryRoles = @{ Permissions = @('RoleManagement.ReadWrite.Directory') Roles = @($globalAdministrator, $privilegedRoleAdministrator) } NamedLocations = @{ Permissions = @('Policy.ReadWrite.ConditionalAccess') Roles = @($globalAdministrator, $securityAdministrator, $conditionalAccessAdministrator) } Licenses = @{ Permissions = @('Group.ReadWrite.All', 'Directory.ReadWrite.All') Roles = @($globalAdministrator, $groupsAdministrator, $userAdministrator, $licenseAdministrator) } Applications = @{ Permissions = @('Application.ReadWrite.All', 'Directory.ReadWrite.All') Roles = @($globalAdministrator, $applicationAdministrator, $cloudApplicationAdministrator) } Devices = @{ Permissions = @('Device.ReadWrite.All', 'Directory.ReadWrite.All', 'Directory.AccessAsUser.All') Roles = @($globalAdministrator, $cloudDeviceAdministrator, $intuneAdministrator) } Groups = @{ Permissions = @('Group.ReadWrite.All', 'Directory.ReadWrite.All') Roles = @($globalAdministrator, $groupsAdministrator, $userAdministrator) } Users = @{ Permissions = @('User.ReadWrite.All', 'User.DeleteRestore.All', 'Directory.ReadWrite.All') Roles = @($globalAdministrator, $userAdministrator) } AdministrativeUnits = @{ Permissions = @('AdministrativeUnit.ReadWrite.All', 'Directory.ReadWrite.All') Roles = @($globalAdministrator, $privilegedRoleAdministrator) } } $layers = [ordered]@{} foreach ($name in $requirements.Keys) { $need = $requirements[$name] $byPermission = @($need.Permissions | Where-Object { $permissions -contains $_ }) $byRole = @($need.Roles | Where-Object { $directoryRoles -contains $_ }) $allowed = $true $reason = 'unknown identity; attempting' if ($byPermission.Count -gt 0) { $reason = "permission $($byPermission[0])" } elseif ($byRole.Count -gt 0) { $reason = 'a directory role' } elseif ($known) { $allowed = $false $reason = "the token carries none of $($need.Permissions -join ', ') and the identity holds no directory role that permits it" } $layers[$name] = [PSCustomObject]@{ Allowed = $allowed; Reason = $reason } } # OwnedBy is a narrower yes for applications: not a reason to skip the layer, a reason to # filter it by ownership so nothing is prompted for that the delete would refuse anyway. $ownedOnly = $false if (-not $layers['Applications'].Allowed -and ($permissions -contains 'Application.ReadWrite.OwnedBy')) { $layers['Applications'] = [PSCustomObject]@{ Allowed = $true; Reason = 'permission Application.ReadWrite.OwnedBy, owned objects only' } $ownedOnly = $true } return [PSCustomObject]@{ PSTypeName = 'EntraTeardownCapability' Known = $known IdentityKind = $identityKind IdentityObjectId = $identityObjectId Permissions = @($permissions | Sort-Object) DirectoryRoles = $directoryRoles Layers = $layers ApplicationsOwnedOnly = $ownedOnly } } |