modules/Azure/Discovery/Tests/Unit/CIEMAzureIdentityHierarchy.Tests.ps1
|
BeforeAll { Remove-Module Devolutions.CIEM -Force -ErrorAction SilentlyContinue Import-Module (Join-Path $PSScriptRoot '..' '..' '..' '..' '..' 'Devolutions.CIEM.psd1') Mock -ModuleName Devolutions.CIEM Write-CIEMLog {} New-CIEMDatabase -Path "$TestDrive/ciem.db" InModuleScope Devolutions.CIEM { $script:DatabasePath = "$TestDrive/ciem.db" } foreach ($schemaPath in @( (Join-Path $PSScriptRoot '..' '..' '..' 'Infrastructure' 'Data' 'azure_schema.sql'), (Join-Path $PSScriptRoot '..' '..' 'Data' 'discovery_schema.sql') )) { foreach ($statement in ((Get-Content $schemaPath -Raw) -split ';\s*\n' | Where-Object { $_.Trim() })) { $trimmed = $statement.Trim() try { Invoke-CIEMQuery -Query $trimmed -AsNonQuery | Out-Null } catch { if ($trimmed -match 'ALTER\s+TABLE' -and $_.Exception.Message -match 'duplicate column') { continue } throw } } } } Describe 'ResolveCIEMScopeLabel' { It 'Is available as a private function inside the module' { InModuleScope Devolutions.CIEM { Get-Command ResolveCIEMScopeLabel -ErrorAction Stop | Should -Not -BeNullOrEmpty } } Context 'Scope string parsing' { It 'Resolves subscription scope to friendly name when lookup contains it' { InModuleScope Devolutions.CIEM { $lookup = @{ 'sub-abc' = 'Visual Studio Enterprise' } $result = ResolveCIEMScopeLabel -Scope '/subscriptions/sub-abc' -SubscriptionNameLookup $lookup $result | Should -Be 'Visual Studio Enterprise (subscription)' } } It 'Falls back to subscription ID when name not in lookup' { InModuleScope Devolutions.CIEM { $result = ResolveCIEMScopeLabel -Scope '/subscriptions/sub-xyz' -SubscriptionNameLookup @{} $result | Should -Be 'sub-xyz (subscription)' } } It 'Resolves resource group scope' { InModuleScope Devolutions.CIEM { $result = ResolveCIEMScopeLabel -Scope '/subscriptions/sub-abc/resourceGroups/rg-web' -SubscriptionNameLookup @{} $result | Should -Be 'rg-web (resource group)' } } It 'Resolves resource scope to resource name' { InModuleScope Devolutions.CIEM { $result = ResolveCIEMScopeLabel -Scope '/subscriptions/sub-abc/resourceGroups/rg-web/providers/Microsoft.Compute/virtualMachines/vm-prod' -SubscriptionNameLookup @{} $result | Should -Be 'vm-prod' } } It 'Resolves root scope' { InModuleScope Devolutions.CIEM { $result = ResolveCIEMScopeLabel -Scope '/' -SubscriptionNameLookup @{} $result | Should -Be 'Root Scope' } } It 'Returns last path segment for unknown scope format' { InModuleScope Devolutions.CIEM { $result = ResolveCIEMScopeLabel -Scope '/providers/Microsoft.Management/managementGroups/mg-corp' -SubscriptionNameLookup @{} $result | Should -Be 'mg-corp' } } } } Describe 'Get-CIEMAzureIdentityHierarchy' { Context 'Command structure' { It 'Is available as a public command' { Get-Command -Module Devolutions.CIEM -Name Get-CIEMAzureIdentityHierarchy -ErrorAction Stop | Should -Not -BeNullOrEmpty } It 'Has -Mode parameter with ValidateSet Effective,Direct' { $cmd = Get-Command -Module Devolutions.CIEM -Name Get-CIEMAzureIdentityHierarchy $modeParam = $cmd.Parameters['Mode'] $modeParam | Should -Not -BeNullOrEmpty $validateSet = $modeParam.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } $validateSet.ValidValues | Should -Contain 'Effective' $validateSet.ValidValues | Should -Contain 'Direct' } It 'Has -SubscriptionId parameter' { $cmd = Get-Command -Module Devolutions.CIEM -Name Get-CIEMAzureIdentityHierarchy $cmd.Parameters['SubscriptionId'] | Should -Not -BeNullOrEmpty } } Context 'No data' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments" Invoke-CIEMQuery -Query "DELETE FROM azure_arm_resources" } It 'Throws when no effective role assignments exist (Effective mode)' { { Get-CIEMAzureIdentityHierarchy -Mode Effective } | Should -Throw } It 'Throws when no role assignment ARM resources exist (Direct mode)' { { Get-CIEMAzureIdentityHierarchy -Mode Direct } | Should -Throw } } Context 'Effective mode — 2 users, 1 group, 3 role assignments' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments" Invoke-CIEMQuery -Query "DELETE FROM azure_arm_resources" Invoke-CIEMQuery -Query "DELETE FROM azure_entra_resources" # Seed subscription name lookup Save-CIEMAzureArmResource -Id '/subscriptions/sub1' ` -Type 'microsoft.resources/subscriptions' -Name 'Visual Studio Enterprise' ` -SubscriptionId 'sub1' -TenantId 'tenant1' # Seed effective role assignments # User1: direct Contributor on a resource Save-CIEMAzureEffectiveRoleAssignment ` -PrincipalId 'user1-id' -PrincipalType 'User' ` -PrincipalDisplayName 'John Smith' ` -OriginalPrincipalId 'user1-id' -OriginalPrincipalType 'User' ` -RoleDefinitionId 'roledef-contributor' -RoleName 'Contributor' ` -Scope '/subscriptions/sub1/resourceGroups/rg-web/providers/Microsoft.Compute/virtualMachines/vm-prod' ` -ComputedAt '2026-03-16T00:00:00Z' # User1: inherited Owner via group Save-CIEMAzureEffectiveRoleAssignment ` -PrincipalId 'user1-id' -PrincipalType 'User' ` -PrincipalDisplayName 'John Smith' ` -OriginalPrincipalId 'group1-id' -OriginalPrincipalType 'Group' ` -RoleDefinitionId 'roledef-owner' -RoleName 'Owner' ` -Scope '/subscriptions/sub1' ` -ComputedAt '2026-03-16T00:00:00Z' # User2: direct Reader on subscription Save-CIEMAzureEffectiveRoleAssignment ` -PrincipalId 'user2-id' -PrincipalType 'User' ` -PrincipalDisplayName 'Jane Doe' ` -OriginalPrincipalId 'user2-id' -OriginalPrincipalType 'User' ` -RoleDefinitionId 'roledef-reader' -RoleName 'Reader' ` -Scope '/subscriptions/sub1' ` -ComputedAt '2026-03-16T00:00:00Z' # Group1: direct Owner on subscription (the group itself) Save-CIEMAzureEffectiveRoleAssignment ` -PrincipalId 'group1-id' -PrincipalType 'Group' ` -PrincipalDisplayName 'Cloud Admins' ` -OriginalPrincipalId 'group1-id' -OriginalPrincipalType 'Group' ` -RoleDefinitionId 'roledef-owner' -RoleName 'Owner' ` -Scope '/subscriptions/sub1' ` -ComputedAt '2026-03-16T00:00:00Z' # Seed Entra resource for group name lookup (used by annotation) Save-CIEMAzureEntraResource -Id 'group1-id' -Type 'group' ` -DisplayName 'Cloud Admins' $script:effectiveResult = @(Get-CIEMAzureIdentityHierarchy -Mode Effective) } It 'Returns PSCustomObject array' { $script:effectiveResult | Should -Not -BeNullOrEmpty $script:effectiveResult[0] | Should -BeOfType [PSCustomObject] } It 'Contains exactly one Tenant node at Depth 0' { $tenants = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Tenant' }) $tenants | Should -HaveCount 1 $tenants[0].Depth | Should -Be 0 } It 'Contains IdentityType nodes for User and Group at Depth 1' { $types = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'IdentityType' }) $types | Should -Not -BeNullOrEmpty $labels = $types | Select-Object -ExpandProperty Label # User and Group should both appear (labels contain counts) ($labels -join ',') | Should -Match 'User' ($labels -join ',') | Should -Match 'Group' $types | ForEach-Object { $_.Depth | Should -Be 1 } } It 'Contains Identity nodes at Depth 2' { $identities = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Identity' }) $identities | Should -Not -BeNullOrEmpty $identities | ForEach-Object { $_.Depth | Should -Be 2 } } It 'Contains 3 distinct identities (John Smith, Jane Doe, Cloud Admins)' { $identities = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Identity' }) $identities | Should -HaveCount 3 $labels = $identities | Select-Object -ExpandProperty Label $labels | Should -Contain 'John Smith' $labels | Should -Contain 'Jane Doe' $labels | Should -Contain 'Cloud Admins' } It 'Contains Role nodes at Depth 3' { $roles = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Role' }) $roles | Should -Not -BeNullOrEmpty $roles | ForEach-Object { $_.Depth | Should -Be 3 } } It 'Contains Scope nodes at Depth 4' { $scopes = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Scope' }) $scopes | Should -Not -BeNullOrEmpty $scopes | ForEach-Object { $_.Depth | Should -Be 4 } } It 'Annotates inherited assignments with group name' { # User1 has Owner via group1-id (Cloud Admins) — scope label should contain "via" $user1Identity = $script:effectiveResult | Where-Object { $_.NodeType -eq 'Identity' -and $_.Label -eq 'John Smith' } $user1NodeId = $user1Identity.NodeId $user1Roles = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Role' -and $_.ParentNodeId -eq $user1NodeId }) $ownerRole = $user1Roles | Where-Object { $_.Label -match 'Owner' } $ownerRole | Should -Not -BeNullOrEmpty $ownerScopes = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Scope' -and $_.ParentNodeId -eq $ownerRole.NodeId }) ($ownerScopes.Label -join ',') | Should -Match 'via Cloud Admins' } It 'Does not annotate direct assignments' { # User1 has direct Contributor — no "via" in scope label $user1Identity = $script:effectiveResult | Where-Object { $_.NodeType -eq 'Identity' -and $_.Label -eq 'John Smith' } $user1NodeId = $user1Identity.NodeId $user1Roles = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Role' -and $_.ParentNodeId -eq $user1NodeId }) $contributorRole = $user1Roles | Where-Object { $_.Label -match 'Contributor' } $contributorScopes = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Scope' -and $_.ParentNodeId -eq $contributorRole.NodeId }) ($contributorScopes.Label -join ',') | Should -Not -Match 'via' } It 'Resolves subscription scope to friendly name' { $scopes = @($script:effectiveResult | Where-Object { $_.NodeType -eq 'Scope' }) $subScopes = @($scopes | Where-Object { $_.Label -match 'subscription' }) $subScopes | Should -Not -BeNullOrEmpty ($subScopes.Label -join ',') | Should -Match 'Visual Studio Enterprise' } It 'Has no duplicate NodeIds' { $ids = $script:effectiveResult | Select-Object -ExpandProperty NodeId ($ids | Select-Object -Unique).Count | Should -Be $ids.Count } It 'All non-root nodes have non-empty ParentNodeId' { $nonRoot = @($script:effectiveResult | Where-Object { $_.NodeType -ne 'Tenant' }) foreach ($node in $nonRoot) { $node.ParentNodeId | Should -Not -BeNullOrEmpty } } It 'All nodes have non-empty Label' { foreach ($node in $script:effectiveResult) { $node.Label | Should -Not -BeNullOrEmpty } } It 'All non-root nodes have Relationship = HAS_ACCESS' { $nonRoot = @($script:effectiveResult | Where-Object { $_.NodeType -ne 'Tenant' }) foreach ($node in $nonRoot) { $node.Relationship | Should -Be 'HAS_ACCESS' } } } Context 'Direct mode — raw role assignments from ARM resources' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments" Invoke-CIEMQuery -Query "DELETE FROM azure_arm_resources" Invoke-CIEMQuery -Query "DELETE FROM azure_entra_resources" # Seed subscription for name resolution Save-CIEMAzureArmResource -Id '/subscriptions/sub1' ` -Type 'microsoft.resources/subscriptions' -Name 'My Sub' ` -SubscriptionId 'sub1' -TenantId 'tenant1' # Seed role definition for role name resolution $roleDefProps = @{ roleName = 'Contributor'; permissions = @(@{ actions = @('*') }) } | ConvertTo-Json -Compress Save-CIEMAzureArmResource -Id '/providers/Microsoft.Authorization/roleDefinitions/roledef-contrib' ` -Type 'microsoft.authorization/roledefinitions' -Name 'Contributor' ` -TenantId 'tenant1' -Properties $roleDefProps # Seed Entra resource for principal display name Save-CIEMAzureEntraResource -Id 'sp1-id' -Type 'servicePrincipal' ` -DisplayName 'GitHub Actions SP' # Seed raw role assignment ARM resource $raProps = @{ principalId = 'sp1-id' principalType = 'ServicePrincipal' roleDefinitionId = '/providers/Microsoft.Authorization/roleDefinitions/roledef-contrib' scope = '/subscriptions/sub1/resourceGroups/rg-deploy' } | ConvertTo-Json -Compress Save-CIEMAzureArmResource -Id '/subscriptions/sub1/providers/Microsoft.Authorization/roleAssignments/ra-1' ` -Type 'microsoft.authorization/roleassignments' -Name 'ra-1' ` -SubscriptionId 'sub1' -TenantId 'tenant1' -Properties $raProps $script:directResult = @(Get-CIEMAzureIdentityHierarchy -Mode Direct) } It 'Returns PSCustomObject array' { $script:directResult | Should -Not -BeNullOrEmpty } It 'Contains a ServicePrincipal identity type node' { $types = @($script:directResult | Where-Object { $_.NodeType -eq 'IdentityType' }) ($types.Label -join ',') | Should -Match 'ServicePrincipal' } It 'Contains the GitHub Actions SP identity node' { $identities = @($script:directResult | Where-Object { $_.NodeType -eq 'Identity' }) $identities.Label | Should -Contain 'GitHub Actions SP' } It 'Contains a Contributor role node' { $roles = @($script:directResult | Where-Object { $_.NodeType -eq 'Role' }) ($roles.Label -join ',') | Should -Match 'Contributor' } It 'Contains a scope node for rg-deploy' { $scopes = @($script:directResult | Where-Object { $_.NodeType -eq 'Scope' }) ($scopes.Label -join ',') | Should -Match 'rg-deploy' } It 'Has correct 5-level depth structure' { $maxDepth = ($script:directResult | Measure-Object -Property Depth -Maximum).Maximum $maxDepth | Should -Be 4 } } Context '-SubscriptionId filter' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM azure_effective_role_assignments" Invoke-CIEMQuery -Query "DELETE FROM azure_arm_resources" # Seed two subscriptions Save-CIEMAzureArmResource -Id '/subscriptions/subA' ` -Type 'microsoft.resources/subscriptions' -Name 'Sub A' ` -SubscriptionId 'subA' -TenantId 'tenant1' Save-CIEMAzureArmResource -Id '/subscriptions/subB' ` -Type 'microsoft.resources/subscriptions' -Name 'Sub B' ` -SubscriptionId 'subB' -TenantId 'tenant1' # Assignment in subA Save-CIEMAzureEffectiveRoleAssignment ` -PrincipalId 'user-a' -PrincipalType 'User' ` -PrincipalDisplayName 'User A' ` -OriginalPrincipalId 'user-a' -OriginalPrincipalType 'User' ` -RoleDefinitionId 'roledef-1' -RoleName 'Reader' ` -Scope '/subscriptions/subA/resourceGroups/rg1' ` -ComputedAt '2026-03-16T00:00:00Z' # Assignment in subB Save-CIEMAzureEffectiveRoleAssignment ` -PrincipalId 'user-b' -PrincipalType 'User' ` -PrincipalDisplayName 'User B' ` -OriginalPrincipalId 'user-b' -OriginalPrincipalType 'User' ` -RoleDefinitionId 'roledef-2' -RoleName 'Owner' ` -Scope '/subscriptions/subB' ` -ComputedAt '2026-03-16T00:00:00Z' } It 'Returns only identities with scopes matching the specified subscription' { $result = @(Get-CIEMAzureIdentityHierarchy -Mode Effective -SubscriptionId 'subA') $identities = @($result | Where-Object { $_.NodeType -eq 'Identity' }) $identities | Should -HaveCount 1 $identities[0].Label | Should -Be 'User A' } It 'Excludes identities from other subscriptions' { $result = @(Get-CIEMAzureIdentityHierarchy -Mode Effective -SubscriptionId 'subA') $scopes = @($result | Where-Object { $_.NodeType -eq 'Scope' }) ($scopes.Label -join ',') | Should -Not -Match 'Sub B' } } } |