Skip to content

Instantly share code, notes, and snippets.

@maskati
Last active January 13, 2025 10:03
Show Gist options
  • Save maskati/464fd389c72dc706e9ab1181a5fbf1d7 to your computer and use it in GitHub Desktop.
Save maskati/464fd389c72dc706e9ab1181a5fbf1d7 to your computer and use it in GitHub Desktop.
Generate a report of Entra ID principals and permissions

Generate a report of Entra ID principals and permissions

I wanted a way to report on current Entra ID principals in my tenant as well as the permissions granted to such principals across apps, the tenant directory and Azure subscriptions. Microsoft provides the separately licensed Entra Permissions Management which provides comprehensive principal and permissions discovery and reporting. I wanted something simpler.

The PowerShell script entra-id-principals-permissions-report.ps1 enumerates all principals and various permissions available in your current Azure CLI login context.

Warning

This script might export PII as well as potentially security sensitive information. Review the script and understand what it is doing before running it.

principals.json

Export of all user, group and service principals. Each item includes the principal type, id, createdDateTime, and displayName. In addition a composite name is added to uniquely identify the principal across tenants in a user readable way, and includes information about the owner tenant, publisher or Microsoft first-party application status.

Enumerating tenant users and groups is fairly simple. Service principals are slightly more complex, since I wanted to also know with some confidence the owning organization (home tenant) and publisher of service principals for multi-tenant applications.

  • Service principals include appOwnerOrganizationId, but that is a GUID which doesn't really tell us much. Microsoft has some documentation on verifying Microsoft first-party applications, but that does not help with other tenants. Fortunately we can findTenantInformationByTenantId to resolve the default domain and display name of the associated tenant.
  • However not all apps include this owner information. We can also check the verified publisher to gain insights into the owning organization.
  • Not all apps include either of these, and most seem to be Microsoft first-party applications. How can we determine if an application is such a first-party application? Microsoft Graph does not give us this information, but the Azure AD legacy API includes the microsoftFirstParty property, which is also used by the Entra ID portal to indicate if an application is a Microsoft first-party application.

Example

[
  {
    "type": "User",
    "id": "00000000-0000-0000-0000-000000000000",
    "createdDateTime": "2025-01-02T03:04:05Z",
    "displayName": "Test User",
    "name": "Tenant([email protected])/User:[email protected]"
  },
  {
    "type": "Group",
    "id": "00000000-0000-0000-0000-000000000000",
    "createdDateTime": "2025-01-02T03:04:05Z",
    "displayName": "Test Group",
    "name": "Tenant([email protected])/Group:Test Group"
  },
  {
    "type": "ServicePrincipal",
    "id": "00000000-0000-0000-0000-000000000000",
    "createdDateTime": "2025-01-02T03:04:05Z",
    "displayName": "Microsoft Cloud App Security",
    "name": "Tenant(Microsoft [email protected])/Application:Microsoft Cloud App Security"
  },
  {
    "type": "ServicePrincipal",
    "id": "00000000-0000-0000-0000-000000000000",
    "createdDateTime": "2025-01-02T03:04:05Z",
    "displayName": "PowerApps Service",
    "name": "MicrosoftFirstParty(Microsoft Corporation)/Application:PowerApps Service"
  },
  {
    "type": "ServicePrincipal",
    "id": "00000000-0000-0000-0000-000000000000",
    "createdDateTime": "2025-01-02T03:04:05Z",
    "displayName": "CloudPosture/securityOperators/DefenderCSPMSecurityOperator",
    "name": "Tenant([email protected])/ManagedIdentity/SystemAssigned:/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Security/pricings/CloudPosture/securityOperators/DefenderCSPMSecurityOperator"
  }
]

permissions.json

Export of app, directory and Azure permissions granted to principals. Each item includes the principal unique name, the permission description (app role assignment role name, OAuth2 scope and client name, or directory role definition name), target resource type and unique name. The following permissions are exported:

Example

[
  {
    "principalName": "Tenant([email protected])/User:[email protected]",
    "permission": "User.Read,User.ReadBasic.All@Tenant([email protected])/Application:Azure VPN",
    "resourceType": "Principal:ServicePrincipal",
    "resourceName": "Principal:Tenant(Microsoft [email protected])/Application:Microsoft Graph"
  },
  {
    "principalName": "Tenant([email protected])/User:[email protected]",
    "permission": "Application Administrator",
    "resourceType": "Directory",
    "resourceName": "Directory(contoso.onmicrosoft.com):/"
  },
  {
    "principalName": "<Deleted>",
    "permission": "AcrPush",
    "resourceType": "Azure",
    "resourceName": "Azure:/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mycontainerregistry/providers/Microsoft.ContainerRegistry/registries/mycontainerregistry"
  },
  {
    "principalName": "Tenant([email protected])/ManagedIdentity/UserAssigned:/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/rg-myidentity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myidentity",
    "permission": "Reader",
    "resourceType": "Azure",
    "resourceName": "Azure:/subscriptions/00000000-0000-0000-0000-000000000000"
  },
  {
    "principalName": "Tenant([email protected])/ManagedIdentity/SystemAssigned:/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Security/dataScanners/storageDataScanner",
    "permission": "Storage Blob Data Reader",
    "resourceType": "Azure",
    "resourceName": "Azure:/subscriptions/00000000-0000-0000-0000-000000000000"
  }
]

permissions.mermaid and permissions.svg

A Mermaid flowchart of the previous permissions, as well as an SVG rendering of the flowchart.

Example

flowchart LR
 subgraph Azure["Azure"]
 a1["Azure:/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/acr"]
 a2["Azure:/subscriptions/00000000-0000-0000-0000-000000000000"]
 end
 subgraph Directory["Directory"]
 d1["Directory(contoso.onmicrosoft.com):/"]
 end
 subgraph ServicePrincipal["Principal:ServicePrincipal"]
 sp1["Principal:Tenant(Microsoft&commat;microsoft.onmicrosoft.com)/Application:Azure VPN"]
 sp2["Principal:Tenant(Microsoft Services&commat;sharepoint.com)/Application:Microsoft Graph"]
 end
 p1["Tenant(contoso&commat;contoso.onmicrosoft.com)/User:test.user&commat;contoso.onmicrosoft.com"] -- "Application Administrator" --> d1["Directory(contoso.onmicrosoft.com):/"]
 p1["Tenant(contoso&commat;contoso.onmicrosoft.com)/User:test.user&commat;contoso.onmicrosoft.com"] -- "AssignmentWithoutRole" --> sp1["Principal:Tenant(Microsoft&commat;microsoft.onmicrosoft.com)/Application:Azure VPN"]
 p1["Tenant(contoso&commat;contoso.onmicrosoft.com)/User:test.user&commat;contoso.onmicrosoft.com"] -- "Key Vault Administrator" --> a2["Azure:/subscriptions/00000000-0000-0000-0000-000000000000"]
 p1["Tenant(contoso&commat;contoso.onmicrosoft.com)/User:test.user&commat;contoso.onmicrosoft.com"] -- "User.Read,User.ReadBasic.All&commat;Tenant(Microsoft&commat;microsoft.onmicrosoft.com)/Application:Azure VPN" --> sp2["Principal:Tenant(Microsoft Services&commat;sharepoint.com)/Application:Microsoft Graph"]
 p2["Tenant(contoso&commat;contoso.onmicrosoft.com)/ManagedIdentity/SystemAssigned:/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Security/dataScanners/storageDataScanner"] -- "Storage Blob Data Reader" --> a2["Azure:/subscriptions/00000000-0000-0000-0000-000000000000"]
 p3["MicrosoftFirstParty(Microsoft Corporation)/Application:Microsoft.Azure.SyncFabric"] -- "Directory Readers" --> d1["Directory(contoso.onmicrosoft.com):/"]
 p4["&lt;Deleted&gt;"] -- "AcrPull" --> a1["Azure:/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.ContainerRegistry/registries/acr"]
Loading
$tokencache=[runtime.caching.memorycache]::new('tokencache')
function Get-Token($resource){
$token=$tokencache.get($resource)
if($null -eq $token){
write-host -f yellow "Acquiring token for $resource"
$tokenjson=az account get-access-token --resource $resource|convertfrom-json
$token=$tokenjson.accesstoken|convertto-securestring -a -f
$expireson=[datetimeoffset]::fromunixtimeseconds($tokenjson.expires_on).addminutes(-5)
rv tokenjson
$tokencache.add($resource, $token, $expireson)|out-null
}
return $token
}
function Get-MsGraph($uri){
$token=get-token 'https://graph.microsoft.com/'
write-host -f yellow "Getting $uri"
$response=irm -au be -to $token -ur $uri
return $response
}
function Get-MsGraphCollectionAll($uri){
$values=@()
do{
write-host -f yellow "Getting $uri"
$response=get-msgraph $uri
$values+=$response.value
$uri=$response.'@odata.nextLink'
}while($uri)
return $values
}
# use legacy api to get microsoftFirstParty which is not returned by msgraph
function Get-EnterpriseApplications(){
$token=get-token '74658136-14ec-4630-ad9b-26e160ff0fc6' # ADIbizaUX
$uri='https://main.iam.ad.ext.azure.com/api/ManagedApplications/List'
$body=@{
appListQuery=2 # 0=Enterprise, 1=Microsoft, 2=All
top=100
nextLink=$null
}
$values=@()
do{
write-host -f yellow "Getting $uri with $($body.nextLink)"
$response=irm -me post -au be -to $token -he @{'x-ms-client-request-id'=[guid]::newguid().tostring()} -ur $uri -cont 'application/json' -b ($body|convertto-json -c)
$values+=$response.appList
$body.nextLink=$response.nextLink
}while($response.nextLink)
return $values
}
# get current azure subscription and tenant
$azaccount=az account show|convertfrom-json
# collection for all principals
$principals=@()
# user principals
$users=get-msgraphcollectionall 'https://graph.microsoft.com/v1.0/users?$select=id,createdDateTime,displayName,userPrincipalName&$expand=appRoleAssignments'
$users|%{
$principals+=[pscustomobject]@{
type='User'
id=$_.id
createdDateTime=$_.createdDateTime
displayName=$_.displayName
name="Tenant($($azaccount.tenantDisplayName)@$($azaccount.tenantDefaultDomain))/User:$($_.userPrincipalName)"
appRoleAssignments=$_.appRoleAssignments
}
}
# group principals
$groups=get-msgraphcollectionall 'https://graph.microsoft.com/v1.0/groups?$select=id,createdDateTime,displayName,mailNickname,uniqueName&$expand=appRoleAssignments'
$groups|%{
$principals+=[pscustomobject]@{
type='Group'
id=$_.id
createdDateTime=$_.createdDateTime
displayName=$_.displayName
name="Tenant($($azaccount.tenantDisplayName)@$($azaccount.tenantDefaultDomain))/Group:$($_.uniqueName ?? $_.displayName)"
appRoleAssignments=$_.appRoleAssignments
}
}
# service principals
$services=get-msgraphcollectionall 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=id,createdDateTime,appId,displayName,signInAudience,servicePrincipalType,appOwnerOrganizationId,verifiedPublisher,alternativeNames,appRoles&$expand=appRoleAssignments'
$services|?{$_.appOwnerOrganizationId}|group appOwnerOrganizationId|%{
$tenant=get-msgraph "https://graph.microsoft.com/v1.0/tenantRelationships/findTenantInformationByTenantId(tenantId='$($_.name)')"
$_.group|%{
$_|add-member -notepropertyname 'tenant' -notepropertyvalue "Tenant($($tenant.displayName)@$($tenant.defaultDomainName))"
}
}
get-enterpriseapplications|%{$enterpriseapps_ht=@{}}{$enterpriseapps_ht.add($_.objectId, $_)}
$services|?{-not $_.tenant -and $_.servicePrincipalType -eq 'Application'}|%{
if($enterpriseapps_ht.containskey($_.id)){
$enterpriseapp=$enterpriseapps_ht[$_.id]
if($enterpriseapp.microsoftFirstParty){
$_|add-member -notepropertyname 'tenant' -notepropertyvalue "MicrosoftFirstParty($($enterpriseapp.publisherName ?? $enterpriseapp.publisherDisplayName ?? 'Microsoft'))"
}
}
}
$services|%{
$name=if($_.servicePrincipalType -eq 'Application'){
if($_.signInAudience -eq 'AzureADMyOrg'){
"Tenant($($azaccount.tenantDisplayName)@$($azaccount.tenantDefaultDomain))/$($_.servicePrincipalType):$($_.displayName)"
}elseif($_.tenant){
"$($_.tenant)/$($_.servicePrincipalType):$($_.displayname)"
}elseif($_.verifiedpublisher.displayname){
"VerifiedPublisher($($_.verifiedpublisher.displayname))/$($_.servicePrincipalType):$($_.displayname)"
}else{
"Unknown/$($_.servicePrincipalType):$($_.displayname)"
}
}elseif($_.servicePrincipalType -eq 'ManagedIdentity'){
$mitype=$_.alternativenames -contains 'isExplicit=False' ? 'SystemAssigned' : 'UserAssigned'
$mipath=$_.alternativenames|%{if($_ -like '*/providers/*'){$_}}
"Tenant($($azaccount.tenantDisplayName)@$($azaccount.tenantDefaultDomain))/$($_.servicePrincipalType)/${mitype}:$mipath"
}elseif($_.servicePrincipalType -eq 'SocialIdp'){
"MicrosoftFirstParty(Microsoft)/$($_.servicePrincipalType):$($_.displayname)"
}else{
"Unknown/$($_.servicePrincipalType):$($_.displayname)"
}
$principals+=[pscustomobject]@{
type='ServicePrincipal'
id=$_.id
createdDateTime=$_.createdDateTime
displayName=$_.displayName
name=$name
appRoleAssignments=$_.appRoleAssignments
}
}
# create hashtable from principals for easier lookup
$principals|%{$principals_ht=@{}}{$principals_ht.add($_.id, $_)}
# collect app role assignments (principal permissions to apps)
$approleassignments=$principals.approleassignments
$approleassignments|%{
$ra=$_
$ar=$principals_ht[$_.resourceId].approles|?{$_.id -eq $ra.approleid}
$_|add-member -notepropertyname 'principalName' -notepropertyvalue ($principals_ht[$_.principalId].name ?? '<Deleted>')
$_|add-member -notepropertyname 'resourceType' -notepropertyvalue "Principal:$($principals_ht[$_.resourceid].type ?? '<Deleted>')"
$_|add-member -notepropertyname 'resourceName' -notepropertyvalue "Principal:$($principals_ht[$_.resourceId].name ?? '<Deleted>')"
$_|add-member -notepropertyname 'appRoleName' -notepropertyvalue ($_.approleid -eq [guid]::empty?"AssignmentWithoutRole":($ar ? "$($ar.displayName)$($ar.value?"($($ar.value))":'')":"Unknown"))
}
# collect oauth2 permission grants (principal delegated authorizations)
$oauth2permissiongrants=get-msgraphcollectionall 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants'
$oauth2permissiongrants|%{
$_|add-member -notepropertyname 'clientName' -notepropertyvalue ($principals_ht[$_.clientid].name ?? '<Deleted>')
$_|add-member -notepropertyname 'clientDisplayName' -notepropertyvalue ($principals_ht[$_.clientid].displayname ?? '<Deleted>')
# Index operation failed; the array index evaluated to null.
$_|add-member -notepropertyname 'principalName' -notepropertyvalue ($principals_ht[$_.principalid].name ?? '<Deleted>')
$_|add-member -notepropertyname 'principalDisplayName' -notepropertyvalue ($principals_ht[$_.principalid].displayname ?? '<Deleted>')
$_|add-member -notepropertyname 'resourceType' -notepropertyvalue "Principal:$($principals_ht[$_.resourceid].type ?? '<Deleted>')"
$_|add-member -notepropertyname 'resourceName' -notepropertyvalue "Principal:$($principals_ht[$_.resourceid].name ?? '<Deleted>')"
$_|add-member -notepropertyname 'resourceDisplayName' -notepropertyvalue ($principals_ht[$_.resourceid].displayname ?? '<Deleted>')
}
# collect entra directory role assignments
get-msgraphcollectionall 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?$select=id,displayName'|%{$tenantroledefinitions_ht=@{}}{$tenantroledefinitions_ht.add($_.id, $_)}
$tenantroleassignments=get-msgraphcollectionall 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments'
$tenantroleassignments|%{
$_|add-member -notepropertyname 'principalName' -notepropertyvalue ($principals_ht[$_.principalid].name ?? '<Deleted>')
$_|add-member -notepropertyname 'principalDisplayName' -notepropertyvalue ($principals_ht[$_.principalid].displayname ?? '<Deleted>')
$_|add-member -notepropertyname 'roleDefinitionName' -notepropertyvalue ($tenantroledefinitions_ht[$_.roledefinitionid].displayname ?? "Unknown ($($_.roledefinitionid))")
$_|add-member -notepropertyname 'resourceType' -notepropertyvalue "Directory"
$_|add-member -notepropertyname 'resourceName' -notepropertyvalue "Directory($($azaccount.tenantDefaultDomain)):$($_.directoryScopeId)"
}
# get azure role assignments for subscriptions in the current tenant
$az_roleassignments=az account list|convertfrom-json|?{$_.tenantid -eq $azaccount.tenantid}|%{
az role assignment list --subscription $_.id --all --include-inherited|convertfrom-json
}
$az_roleassignments|%{
$_|add-member -notepropertyname 'resourceType' -notepropertyvalue "Azure"
$_|add-member -notepropertyname 'resourceName' -notepropertyvalue "Azure:$($_.scope)"
}
# collect permissions
$permissions=@()
$permissions+=$approleassignments|select principalName, @{n='permission';e={$_.appRoleName}}, resourceType, resourceName
$permissions+=$oauth2permissiongrants|select principalName, @{n='permission';e={"$($_.scope -replace ' ',',')@$($_.clientName)"}}, resourceType, resourceName
$permissions+=$tenantroleassignments|select principalName, @{n='permission';e={$_.roleDefinitionName}}, resourceType, resourceName
$permissions+=$az_roleassignments|select @{n='principalName';e={$principals_ht[$_.principalId].name ?? '<Deleted>'}}, @{n='permission';e={$_.roleDefinitionName}}, resourceType, resourceName
# generate mermaid flowchart
$permissions_mermaid=($permissions|group resourceType|%{
'flowchart LR'
}{
$principalTypeHash=[convert]::tobase64string([security.cryptography.sha256]::hashdata([text.encoding]::utf8.getbytes($_.name))) -replace '='
" subgraph $principalTypeHash[`"$($_.name)`"]"
$_.group|%{
$resourceHash=[convert]::tobase64string([security.cryptography.sha256]::hashdata([text.encoding]::utf8.getbytes($_.resourceName))) -replace '='
$resourceNameEscaped=[web.httputility]::htmlencode($_.resourceName) -replace '@','&commat;'
" $resourceHash[`"$resourceNameEscaped`"]"
}
" end"
})+($permissions|%{
$principalHash=[convert]::tobase64string([security.cryptography.sha256]::hashdata([text.encoding]::utf8.getbytes($_.principalName))) -replace '='
$resourceHash=[convert]::tobase64string([security.cryptography.sha256]::hashdata([text.encoding]::utf8.getbytes($_.resourceName))) -replace '='
$principalNameEscaped=[web.httputility]::htmlencode($_.principalName) -replace '@','&commat;'
$permissionEscaped=[web.httputility]::htmlencode($_.permission) -replace '@','&commat;'
$resourceNameEscaped=[web.httputility]::htmlencode($_.resourceName) -replace '@','&commat;'
" $principalHash[`"$principalNameEscaped`"] -- `"$permissionEscaped`" --> $resourceHash[`"$resourceNameEscaped`"]"
})
# clean up token cache
$tokencache.dispose()
# export all principals to json
$principals|select type,id,createdDateTime,displayName,name|convertto-json -d 3 > principals.json
# export all permissions to json
$permissions|convertto-json -d 3 > permissions.json
# export all permissions to mermaid syntax
$permissions_mermaid|out-file permissions.mermaid
# render the mermaid chart as an image using mermaid cli
# use configfile to support large diagrams
@{maxTextSize=99999999;maxEdges=99999999}|convertto-json|out-file mermaid.json
$permissions_mermaid|npx -p @mermaid-js/mermaid-cli mmdc -i - -o permissions.svg --width 2048 --configFile=mermaid.json -b transparent
ri mermaid.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment