Skip to content

Commit fc89b22

Browse files
authored
Merge pull request #1164 from microsoft/feature/266-assessment-fix
Bugfix/issue-266: Exclude emergency access accounts
2 parents 4851cdb + d1fc504 commit fc89b22

3 files changed

Lines changed: 235 additions & 5 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<#
2+
.SYNOPSIS
3+
Gets emergency access accounts from the configuration.
4+
5+
.DESCRIPTION
6+
Returns emergency access accounts defined in the ZeroTrustAssessment configuration file.
7+
Uses Maester-style configuration format where customers explicitly define their
8+
emergency/breakglass accounts.
9+
10+
Configuration format (in zt-config.json) - follows Maester format:
11+
{
12+
"GlobalSettings": {
13+
"EmergencyAccessAccounts": [
14+
{ "Type": "User", "UserPrincipalName": "breakglass1@contoso.com" },
15+
{ "Type": "User", "Id": "00000000-0000-0000-0000-000000000001" },
16+
{ "Type": "Group", "Id": "00000000-0000-0000-0000-000000000002" }
17+
]
18+
}
19+
}
20+
21+
Note: Group-based emergency accounts are resolved at runtime via Microsoft Graph API.
22+
23+
.PARAMETER Database
24+
The DuckDB database connection used to resolve user information.
25+
26+
.OUTPUTS
27+
Array of PSCustomObject with properties:
28+
- Id: User's object ID
29+
- UserPrincipalName: User's UPN
30+
- DisplayName: User's display name
31+
- Type: 'User' or 'GroupMember' (indicates user resolved from a configured group)
32+
33+
.EXAMPLE
34+
$emergencyAccounts = Get-ZtEmergencyAccessAccounts -Database $Database
35+
36+
.NOTES
37+
Created to fix Issue #266 - Test 21815 incorrectly flags emergency access accounts
38+
as failures for having permanent privileged role assignments.
39+
40+
Updated to use config-based approach per PM feedback (FIDO2 requirement too strict).
41+
#>
42+
43+
function Get-ZtEmergencyAccessAccounts {
44+
[CmdletBinding()]
45+
param(
46+
[Parameter(Mandatory = $true)]
47+
$Database
48+
)
49+
50+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
51+
Write-PSFMessage 'Getting emergency access accounts from configuration' -Level Verbose
52+
53+
# Get emergency accounts from PSFConfig (set by Invoke-ZtAssessment)
54+
$configuredAccounts = Get-PSFConfigValue -FullName 'ZeroTrustAssessment.EmergencyAccessAccounts'
55+
56+
if (-not $configuredAccounts -or $configuredAccounts.Count -eq 0) {
57+
Write-PSFMessage 'No emergency access accounts configured' -Level Verbose
58+
return @()
59+
}
60+
61+
Write-PSFMessage "Found $($configuredAccounts.Count) configured emergency access accounts" -Level Verbose
62+
63+
$emergencyAccessAccounts = @()
64+
65+
foreach ($account in $configuredAccounts) {
66+
$type = $account.Type
67+
$id = $account.Id
68+
$upn = $account.UserPrincipalName
69+
70+
if ($type -eq 'User') {
71+
# Resolve user by UPN or ID
72+
if ($upn) {
73+
# Lower-case both sides for case-insensitive UPN match (portable; avoids DB-specific COLLATE syntax)
74+
$escapedUpn = ($upn.ToLowerInvariant()) -replace "'", "''"
75+
$sql = "SELECT id, userPrincipalName, displayName FROM User WHERE LOWER(userPrincipalName) = '$escapedUpn'"
76+
}
77+
elseif ($id) {
78+
$guidRef = [System.Guid]::Empty
79+
if (-not [System.Guid]::TryParse($id, [ref]$guidRef)) {
80+
Write-PSFMessage "Skipping invalid user entry: Id '$id' is not a valid GUID" -Level Warning
81+
continue
82+
}
83+
$escapedId = $guidRef.ToString()
84+
$sql = "SELECT id, userPrincipalName, displayName FROM User WHERE id = '$escapedId'"
85+
}
86+
else {
87+
Write-PSFMessage "Skipping invalid user entry: no Id or UserPrincipalName provided" -Level Warning
88+
continue
89+
}
90+
91+
$user = Invoke-DatabaseQuery -Database $Database -Sql $sql | Select-Object -First 1
92+
93+
if ($user) {
94+
$emergencyAccessAccounts += [PSCustomObject]@{
95+
Id = $user.id
96+
UserPrincipalName = $user.userPrincipalName
97+
DisplayName = $user.displayName
98+
Type = 'User'
99+
}
100+
Write-PSFMessage "Emergency access user found: $($user.userPrincipalName)" -Level Verbose
101+
}
102+
else {
103+
Write-PSFMessage "Emergency access user not found in tenant: UPN=$upn, Id=$id" -Level Warning
104+
}
105+
}
106+
elseif ($type -eq 'Group') {
107+
if (-not $id) {
108+
Write-PSFMessage "Skipping invalid group entry: no Id provided" -Level Warning
109+
continue
110+
}
111+
112+
$guidRef = [System.Guid]::Empty
113+
if (-not [System.Guid]::TryParse($id, [ref]$guidRef)) {
114+
Write-PSFMessage "Skipping invalid group entry: Id '$id' is not a valid GUID" -Level Warning
115+
continue
116+
}
117+
118+
# Resolve group members via Microsoft Graph API (GroupMember table not available in DB)
119+
try {
120+
Write-PSFMessage "Resolving emergency access group members via Graph API: Id=$id" -Level Verbose
121+
$membersResponse = Get-ZtGroupMember -GroupId $id -Recurse -ErrorAction Stop
122+
$members = @($membersResponse | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.user' })
123+
124+
if ($members.Count -gt 0) {
125+
# Batch all member IDs into a single SQL lookup to avoid N+1 queries;
126+
# member IDs come from Graph API responses which are always valid GUIDs.
127+
$escapedIds = $members | ForEach-Object {
128+
$memberGuid = [System.Guid]::Empty
129+
if ([System.Guid]::TryParse($_.id, [ref]$memberGuid)) {
130+
"'" + $memberGuid.ToString() + "'"
131+
}
132+
} | Where-Object { $_ }
133+
134+
if (-not $escapedIds) {
135+
Write-PSFMessage "Emergency access group members had no valid GUIDs: Id=$id" -Level Warning
136+
}
137+
else {
138+
$idList = $escapedIds -join ','
139+
$memberSql = "SELECT id, userPrincipalName, displayName FROM User WHERE id IN ($idList)"
140+
$userDetailsList = @(Invoke-DatabaseQuery -Database $Database -Sql $memberSql)
141+
142+
foreach ($userDetails in $userDetailsList) {
143+
$emergencyAccessAccounts += [PSCustomObject]@{
144+
Id = $userDetails.id
145+
UserPrincipalName = $userDetails.userPrincipalName
146+
DisplayName = $userDetails.displayName
147+
Type = 'GroupMember'
148+
}
149+
Write-PSFMessage "Emergency access group member found: $($userDetails.userPrincipalName)" -Level Verbose
150+
}
151+
}
152+
}
153+
else {
154+
Write-PSFMessage "Emergency access group has no user members: Id=$id" -Level Warning
155+
}
156+
}
157+
catch {
158+
Write-PSFMessage "Failed to resolve emergency access group members: Id=$id. Error: $($_.Exception.Message)" -Level Warning
159+
}
160+
}
161+
else {
162+
Write-PSFMessage "Skipping unknown account type: $type" -Level Warning
163+
}
164+
}
165+
166+
Write-PSFMessage "Total emergency access accounts resolved: $($emergencyAccessAccounts.Count)" -Level Verbose
167+
168+
return $emergencyAccessAccounts
169+
}

src/powershell/public/Invoke-ZtAssessment.ps1

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ $titleLine
249249
#region Preparation
250250
Show-ZtiBanner
251251

252+
# Always reset emergency access accounts at the start of each run to prevent stale
253+
# config from a previous Invoke-ZtAssessment call carrying over (Issue #266 follow-up).
254+
Set-PSFConfig -FullName 'ZeroTrustAssessment.EmergencyAccessAccounts' -Value $null
255+
252256
$effectiveIgnore = $IgnoreLanguageMode -or $script:IgnoreLanguageMode
253257
if (-not (Test-ZtLanguageMode -IgnoreLanguageMode:$effectiveIgnore)) {
254258
Stop-PSFFunction -Message "PowerShell is running in Constrained Language Mode, which is not supported." -EnableException $true -Cmdlet $PSCmdlet
@@ -301,6 +305,21 @@ $titleLine
301305
}
302306
}
303307

308+
# Parse EmergencyAccessAccounts if present (Maester-style config with GlobalSettings)
309+
$emergencyAccounts = $null
310+
if ($configContent.PSObject.Properties.Name -contains 'GlobalSettings' -and
311+
$configContent.GlobalSettings.PSObject.Properties.Name -contains 'EmergencyAccessAccounts' -and
312+
$configContent.GlobalSettings.EmergencyAccessAccounts -and
313+
$configContent.GlobalSettings.EmergencyAccessAccounts.Count -gt 0) {
314+
$emergencyAccounts = $configContent.GlobalSettings.EmergencyAccessAccounts
315+
}
316+
if ($emergencyAccounts -and $emergencyAccounts.Count -gt 0) {
317+
Set-PSFConfig -FullName 'ZeroTrustAssessment.EmergencyAccessAccounts' -Value $emergencyAccounts
318+
Write-Host "🔐 " -NoNewline -ForegroundColor Cyan
319+
Write-Host "Loaded $($emergencyAccounts.Count) emergency access account(s) from configuration." -ForegroundColor White
320+
}
321+
# Note: stale-clear is now performed unconditionally at the start of Invoke-ZtAssessment.
322+
304323
Write-Host "" -NoNewline -ForegroundColor Green
305324
Write-Host "Configuration loaded successfully. Command line parameters will override configuration file values." -ForegroundColor White
306325
Write-Host

src/powershell/tests/Test-Assessment.21815.ps1

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,37 @@ function Test-Assessment-21815 {
3131
Write-ZtProgress -Activity $activity -Status "Getting privileged role assignments"
3232

3333
$sql = @"
34-
select distinct principalDisplayName, userPrincipalName, roleDisplayName, privilegeType, isPrivileged
34+
select distinct principalId, principalDisplayName, userPrincipalName, roleDisplayName, privilegeType, isPrivileged
3535
from vwRole
3636
"@
3737
$roleAssignments = Invoke-DatabaseQuery -Database $Database -Sql $sql
3838

3939
# Check if any privileged role assignment in the results has privilegeType set to Permanent
40-
$results = $roleAssignments | Where-Object { $_.isPrivileged -eq $true -and $_.privilegeType -eq 'Permanent' }
40+
$permanentPrivileged = $roleAssignments | Where-Object { $_.isPrivileged -eq $true -and $_.privilegeType -eq 'Permanent' }
41+
42+
# Issue #266: Exclude emergency access accounts from failures
43+
# Emergency accounts are expected to have permanent privileged role assignments per Microsoft best practices
44+
Write-ZtProgress -Activity $activity -Status "Identifying emergency access accounts"
45+
$emergencyAccounts = Get-ZtEmergencyAccessAccounts -Database $Database
46+
$emergencyAccountIds = @($emergencyAccounts | Select-Object -ExpandProperty Id)
47+
48+
# Filter out emergency access accounts from the results
49+
$results = @($permanentPrivileged | Where-Object { $_.principalId -notin $emergencyAccountIds })
50+
$excludedEmergencyAccounts = @($permanentPrivileged | Where-Object { $_.principalId -in $emergencyAccountIds })
51+
52+
# Count of *distinct* excluded emergency accounts (one user can have multiple permanent role assignments)
53+
$excludedAccountCount = @($excludedEmergencyAccounts | Select-Object -ExpandProperty principalId -Unique).Count
4154

4255
$testResultMarkdown = ""
4356

4457
if ($results.Count -eq 0) {
4558
$passed = $true
46-
$testResultMarkdown += "No privileged users have permanent role assignments."
59+
if ($excludedAccountCount -gt 0) {
60+
$testResultMarkdown += "No privileged users have permanent role assignments (excluding $excludedAccountCount emergency access account(s) which are expected to have permanent assignments)."
61+
}
62+
else {
63+
$testResultMarkdown += "No privileged users have permanent role assignments."
64+
}
4765
}
4866
else {
4967
$passed = $false
@@ -80,8 +98,32 @@ from vwRole
8098
$mdInfo = $formatTemplate -f $reportTitle, $tableRows
8199
}
82100

83-
# Replace the placeholder with the detailed information
84-
$testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $mdInfo
101+
# Build section for excluded emergency access accounts (Issue #266)
102+
$emergencySection = ''
103+
if ($excludedEmergencyAccounts.Count -gt 0) {
104+
$emergencySection = @'
105+
106+
## Excluded emergency access accounts
107+
108+
The following emergency access accounts were excluded from this check as they are expected to have permanent privileged role assignments per [Microsoft best practices](https://learn.microsoft.com/entra/identity/role-based-access-control/security-emergency-access).
109+
110+
| User | UPN | Role Name |
111+
| :--- | :-- | :-------- |
112+
'@
113+
foreach ($emergency in $excludedEmergencyAccounts) {
114+
$portalLink = 'https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/AdministrativeRole/userId/{0}/hidePreviewBanner~/true' -f $emergency.principalId
115+
$emergencySection += "| [$(Get-SafeMarkdown($emergency.principalDisplayName))]($portalLink) | $($emergency.userPrincipalName) | $($emergency.roleDisplayName) |`n"
116+
}
117+
}
118+
119+
if ($passed) {
120+
# Pass message has no %TestResult% placeholder; append excluded section if any
121+
$testResultMarkdown += $emergencySection
122+
}
123+
else {
124+
# Replace the placeholder with the detailed failure table plus excluded section
125+
$testResultMarkdown = $testResultMarkdown -replace "%TestResult%", ($mdInfo + $emergencySection)
126+
}
85127

86128
$params = @{
87129
TestId = '21815'

0 commit comments

Comments
 (0)