Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/powershell/tests/Test-Assessment.25384.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
An Application Administrator role scoped at the tenant can manage every app registration and enterprise app. If a threat actor compromises an App Admin with tenant-wide scope, they can add credentials to any service principal (Persistence), consent malicious APIs (Defence Evasion), modify or create applications that proxy data exfiltration (Exfiltration), and disable or tamper with Private Access (PA) apps (Impact). Scoping the role to only required Private Access enterprise apps enforces least privilege and limits blast radius.

Additionally, assigning Application Administrator to groups, service principals, or guest users increases risk. Groups make it difficult to track who has access, service principals can be compromised through stolen credentials, and guest users may have different security controls. Assignments should be made directly to member users only.

**Remediation action**

To constrain Application Administrator rights to specific Private Access apps:

1. **Scope App Admin to specific apps**: Remove tenant-wide assignments and reassign with `directoryScopeId` pointing to the required Private Access or Quick Access apps.
- [Assign roles with app registration scope](https://learn.microsoft.com/entra/identity/role-based-access-control/assign-roles-different-scopes?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci#app-registration-scope)

2. **Remove problematic assignments**: Remove assignments to groups, service principals, or guest users. Assign directly to member user accounts instead.
- [Manage directory role assignments](https://learn.microsoft.com/graph/api/rbacapplication-list-roleassignments?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci)

3. **Use PIM for JIT elevation**: Implement Privileged Identity Management to provide just-in-time access for Application Administrator role.
- [Privileged Identity Management](https://learn.microsoft.com/entra/id-governance/privileged-identity-management/pim-configure?wt.mc_id=zerotrustrecommendations_automation_content_cnl_csasci)

<!--- Results --->
%TestResult%
350 changes: 350 additions & 0 deletions src/powershell/tests/Test-Assessment.25384.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
<#
.SYNOPSIS
Checks if Application Administrator rights are constrained to specific Private Access apps.

.DESCRIPTION
This test validates that Application Administrator role assignments are scoped to specific
applications rather than tenant-wide, and that assignments follow least privilege principles.

.NOTES
Test ID: 25384
Category: Access control
Required API: roleManagement/directory (beta)
#>

function Test-Assessment-25384 {
[ZtTest(
Category = 'Access control',
ImplementationCost = 'Low',
MinimumLicense = ('P1'),
Pillar = 'Network',
RiskLevel = 'High',
SfiPillar = 'Protect identities and secrets',
TenantType = ('Workforce'),
TestId = 25384,
Title = 'Application admin rights are constrained to specific Private Access apps, not tenant-wide',
UserImpact = 'Low'
)]
[CmdletBinding()]
param()

#region Data Collection
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

$activity = 'Checking Application Administrator role assignments'
Write-ZtProgress -Activity $activity -Status 'Getting role definition'

# Query 1: Get Application Administrator role definition
$appAdminRoleId = Get-ZtRoleInfo -RoleName 'ApplicationAdministrator'

Write-ZtProgress -Activity $activity -Status 'Getting role assignments with principal details'
Comment thread
Manoj-Kesana marked this conversation as resolved.

# Query 2: Get Application Administrator role assignments with expanded principal (no nested $select)
$assignments = Invoke-ZtGraphRequest -RelativeUri "roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$appAdminRoleId'&`$expand=principal" -ApiVersion beta

# Default to empty array if no assignments found
$assignments = $assignments ?? @()

# Collect scoped IDs from assignments for Q3 resolution
$spIds = @()
$appIds = @()
foreach ($assignment in $assignments) {
if ($assignment.directoryScopeId -ne '/') {
$scopeId = $assignment.directoryScopeId -replace '^/', ''
if ($scopeId -match '^servicePrincipals/(.+)') {
$spIds += $Matches[1]
} elseif ($scopeId -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
$appIds += $scopeId
}
}
}

Write-ZtProgress -Activity $activity -Status 'Resolving scoped service principals and applications'

# Query 3: Resolve scoped service principals and applications
$spLookup = @{}
$appLookup = @{}

# Fetch service principals referenced in scoped assignments
$uniqueSpIds = $spIds | Select-Object -Unique

if ($uniqueSpIds) {
$sps = Invoke-ZtGraphBatchRequest -Path "servicePrincipals/{0}?`$select=id,displayName,appId,appOwnerOrganizationId" -ArgumentList $uniqueSpIds -ApiVersion beta
foreach ($sp in $sps) { $spLookup[$sp.id] = $sp }
}

# Fetch applications directly referenced in scoped assignments (app registrations)
$uniqueAppIds = $appIds | Select-Object -Unique

if ($uniqueAppIds) {
$apps = Invoke-ZtGraphBatchRequest -Path "applications/{0}?`$select=id,displayName,appId,tags,appOwnerOrganizationId" -ArgumentList $uniqueAppIds -ApiVersion beta
foreach ($app in $apps) {
$appLookup[$app.id] = $app
if ($app.appId) { $appLookup[$app.appId] = $app }
}
}

Write-ZtProgress -Activity $activity -Status 'Detecting Private Access and Quick Access apps'

# Query 4: Detect Private Access / Quick Access apps via tags (bulk fetch)
$paQaAppLookup = @{}
try {
$paQuickAccessApps = Invoke-ZtGraphRequest -RelativeUri "applications" -Filter "(tags/any(t: t eq 'PrivateAccessNonWebApplication') or tags/any(t: t eq 'NetworkAccessQuickAccessApplication'))" -Select 'id,displayName,appId,tags' -ApiVersion beta
foreach ($app in $paQuickAccessApps) {
if ($app.appId) { $paQaAppLookup[$app.appId] = $app }
if ($app.id) { $paQaAppLookup[$app.id] = $app }
}
} catch {
Write-PSFMessage "Unable to query Private Access/Quick Access apps by tags" -Level Verbose
}

# Fetch application details for service principals from Q3 (if not already in PA/QA lookup)
$spAppIds = @($spLookup.Values | Where-Object { $_.appId } | ForEach-Object { $_.appId }) | Select-Object -Unique
$appIdsToFetch = $spAppIds | Where-Object { -not $paQaAppLookup.ContainsKey($_) -and -not $appLookup.ContainsKey($_) }

if ($appIdsToFetch) {
$apps = Invoke-ZtGraphBatchRequest -Path "applications?`$filter=appId eq '{0}'&`$select=id,displayName,appId,tags,appOwnerOrganizationId" -ArgumentList $appIdsToFetch -ApiVersion beta
foreach ($app in $apps) {
if ($app) {
$appLookup[$app.id] = $app
$appLookup[$app.appId] = $app
}
}
}

#endregion Data Collection

#region Assessment Logic
$testResultMarkdown = ''
$passed = $true
$tenantWideAssignments = @()
$scopedAssignments = @()
$problematicAssignments = @()
$warnings = @()

foreach ($assignment in $assignments) {
$principalType = if ($assignment.principal.'@odata.type') {
$assignment.principal.'@odata.type' -replace '#microsoft.graph.', ''
} else { 'unknown' }

$assignmentInfo = [PSCustomObject]@{
DirectoryScopeId = $assignment.directoryScopeId
PrincipalId = $assignment.principalId
PrincipalDisplayName = $assignment.principal.displayName
PrincipalUPN = $assignment.principal.userPrincipalName
PrincipalType = $principalType
UserType = $assignment.principal.userType
AccountEnabled = $assignment.principal.accountEnabled
AppDisplayName = ''
AppId = ''
IsPAApp = $false
}

if ($assignment.directoryScopeId -eq '/') {
$tenantWideAssignments += $assignmentInfo
$passed = $false
} else {
$scopeId = $assignment.directoryScopeId -replace '^/', ''
if ($scopeId -match '^servicePrincipals/(.+)') {
$spId = $Matches[1]
if ($spLookup.ContainsKey($spId)) {
$sp = $spLookup[$spId]
$assignmentInfo.AppDisplayName = $sp.displayName
$assignmentInfo.AppId = $sp.appId
$app = $paQaAppLookup[$sp.appId] ?? $appLookup[$sp.appId]
if ($app) {
$assignmentInfo.IsPAApp = ($app.tags -contains 'PrivateAccessNonWebApplication') -or ($app.tags -contains 'NetworkAccessQuickAccessApplication')
}
}
} else {
$app = $paQaAppLookup[$scopeId] ?? $appLookup[$scopeId]
if ($app) {
$assignmentInfo.AppDisplayName = $app.displayName
$assignmentInfo.AppId = $app.appId
$assignmentInfo.IsPAApp = ($app.tags -contains 'PrivateAccessNonWebApplication') -or ($app.tags -contains 'NetworkAccessQuickAccessApplication')
}
}
$scopedAssignments += $assignmentInfo
}

if ($principalType -in @('group', 'servicePrincipal') -or $assignment.principal.userType -eq 'Guest') {
$problematicAssignments += $assignmentInfo
$passed = $false
}
}

if ($assignments.Count -gt 5) {
$warnings += "Assignment count ($($assignments.Count)) exceeds recommended threshold of 5"
}

$scopedNonPACount = ($scopedAssignments | Where-Object { -not $_.IsPAApp -and $_.AppDisplayName }).Count
if ($scopedNonPACount -gt 0) {
$warnings += "$scopedNonPACount scoped assignment(s) to apps that are not confirmed as Private Access or Quick Access apps"
}
#endregion Assessment Logic

#region Report Generation
Comment thread
Manoj-Kesana marked this conversation as resolved.
$mdInfo = ''

# Summary Section
$mdInfo += "`n## Summary`n`n"
$mdInfo += "| Metric | Count |`n"
$mdInfo += "| :--- | ---: |`n"
$mdInfo += "| Total Assignments | $($assignments.Count) |`n"
$mdInfo += "| Tenant-Wide Assignments | $($tenantWideAssignments.Count) |`n"
$mdInfo += "| Scoped Assignments | $($scopedAssignments.Count) |`n"
$mdInfo += "| Problematic Assignments | $($problematicAssignments.Count) |`n`n"

# Application Administrator Assignments
$mdInfo += "`n## Application Administrator Assignments:`n`n"
$mdInfo += "- Count: $($assignments.Count)`n`n"

if ($assignments.Count -gt 0) {
$mdInfo += "| DirectoryScopeId | Principal DisplayName | UPN | AccountEnabled | Type | User Type |`n"
$mdInfo += "| :--- | :--- | :--- | :---: | :--- | :--- |`n"
foreach ($rawA in $assignments) {
$scope = $rawA.directoryScopeId
$displayName = $rawA.principal.displayName
$upn = $rawA.principal.userPrincipalName
$acctEnabled = if ($null -ne $rawA.principal.accountEnabled) { $rawA.principal.accountEnabled } else { '' }
$pType = if ($rawA.principal.'@odata.type') { $rawA.principal.'@odata.type' -replace '#microsoft.graph.', '' } else { 'unknown' }
$uType = $rawA.principal.userType
$mdInfo += "| $(Get-SafeMarkdown -Text $scope) | $(Get-SafeMarkdown -Text $displayName) | $upn | $acctEnabled | $pType | $uType |`n"
}
$mdInfo += "`n"
}

# Build map of all discovered apps for display
$scopedAppsMap = @{ }
foreach ($app in $paQaAppLookup.Values) {
if ($app.appId) {
$scopedAppsMap[$app.appId] = $app
} elseif ($app.id) {
$scopedAppsMap[$app.id] = $app
}
}
foreach ($app in $appLookup.Values) {
if ($app.appId) {
$scopedAppsMap[$app.appId] = $app
} elseif ($app.id) {
$scopedAppsMap[$app.id] = $app
}
}
foreach ($sp in $spLookup.Values) {
if ($sp.appId) {
if (-not $scopedAppsMap.ContainsKey($sp.appId)) {
if ($appLookup.ContainsKey($sp.appId)) {
$scopedAppsMap[$sp.appId] = $appLookup[$sp.appId]
} else {
$scopedAppsMap[$sp.appId] = [PSCustomObject]@{
displayName = $sp.displayName
appId = $sp.appId
id = $null
tags = @()
}
}
}
}
}

# Scoped Apps section
if ($scopedAppsMap.Count -gt 0) {
$mdInfo += "`n## Scoped Apps:`n`n"
$mdInfo += "| App DisplayName | appId / servicePrincipalId | Tags (includes PA/QA?) |`n"
$mdInfo += "| :--- | :--- | :--- |`n"
foreach ($app in $scopedAppsMap.Values) {
$display = if ($app.displayName) {
$(Get-SafeMarkdown -Text $app.displayName)
} else {
'Unknown'
}
$id = if ($app.appId) {
$app.appId
} elseif ($app.id) {
$app.id
} else {
''
}
$tags = if ($app.tags) {
($app.tags -join ', ')
} else {
''
}
$paqa = if ($app.tags -and (($app.tags -contains 'PrivateAccessNonWebApplication') -or ($app.tags -contains 'NetworkAccessQuickAccessApplication'))) {
'✅'
} else {
'❌'
}
$mdInfo += "| $display | $id | $tags $paqa |`n"
}
$mdInfo += "`n"
}

# Tenant-Wide Assignments
if ($tenantWideAssignments.Count -gt 0) {
$mdInfo += "`n## ❌ Tenant-Wide Assignments`n`n"
$mdInfo += "The following Application Administrator assignments have tenant-wide scope and should be constrained:`n`n"
$mdInfo += "| Principal | Type | User Type | Scope |`n"
$mdInfo += "| :--- | :--- | :--- | :--- |`n"
foreach ($a in $tenantWideAssignments) {
$principalName = if ($a.PrincipalUPN) { $a.PrincipalUPN } else { $a.PrincipalDisplayName }
$mdInfo += "| $(Get-SafeMarkdown -Text $principalName) | $($a.PrincipalType) | $($a.UserType) | Tenant-wide (/) |`n"
}
$mdInfo += "`n"
}

# Problematic Assignments
if ($problematicAssignments.Count -gt 0) {
$mdInfo += "`n## ❌ Problematic Principal Assignments`n`n"
$mdInfo += "The following assignments use groups, service principals, or guest users:`n`n"
$mdInfo += "| Principal | Type | User Type | Scope |`n"
$mdInfo += "| :--- | :--- | :--- | :--- |`n"
foreach ($a in $problematicAssignments) {
$principalName = if ($a.PrincipalUPN) { $a.PrincipalUPN } else { $a.PrincipalDisplayName }
$scope = if ($a.DirectoryScopeId -eq '/') { 'Tenant-wide (/)' } else { 'Scoped' }
$mdInfo += "| $(Get-SafeMarkdown -Text $principalName) | $($a.PrincipalType) | $($a.UserType) | $scope |`n"
}
$mdInfo += "`n"
}

# Scoped Assignments
if ($scopedAssignments.Count -gt 0) {
$mdInfo += "`n## ✅ Scoped Assignments`n`n"
$mdInfo += "The following Application Administrator assignments are scoped to specific applications:`n`n"
$mdInfo += "| Principal | Type | Application | PA/QA App |`n"
$mdInfo += "| :--- | :--- | :--- | :---: |`n"
foreach ($a in $scopedAssignments | Sort-Object PrincipalDisplayName) {
$principalName = if ($a.PrincipalUPN) { $a.PrincipalUPN } else { $a.PrincipalDisplayName }
$appName = if ($a.AppDisplayName) { $a.AppDisplayName } else { 'Unknown app' }
$paIcon = if ($a.IsPAApp) { '✅' } else { '❌' }
$mdInfo += "| $(Get-SafeMarkdown -Text $principalName) | $($a.PrincipalType) | $(Get-SafeMarkdown -Text $appName) | $paIcon |`n"
}
$mdInfo += "`n"
}

# Warnings
if ($warnings.Count -gt 0) {
$mdInfo += "`n## ⚠️ Warnings`n`n"
foreach ($warning in $warnings) {
$mdInfo += "- $warning`n"
}
$mdInfo += "`n"
}

# Portal Link
$portalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/AllRolesBlade"
$portalLinkText = Get-SafeMarkdown -Text "View in Entra Portal: Roles and administrators"
$mdInfo += "`n[$portalLinkText]($portalLink)"

$testResultMarkdown = $mdInfo
#endregion Report Generation

$params = @{
TestId = '25384'
Title = 'Application admin rights are constrained to specific Private Access apps, not tenant-wide'
Status = $passed
Result = $testResultMarkdown
}

Add-ZtTestResultDetail @params
}