modules/Devolutions.CIEM.EffectivePermissions/Tests/Unit/CIEMEffectivePermission.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" $azureSchema = Join-Path $PSScriptRoot '..' '..' '..' 'Azure' 'Infrastructure' 'Data' 'azure_schema.sql' Invoke-CIEMQuery -Query (Get-Content $azureSchema -Raw) $discoverySchema = Join-Path $PSScriptRoot '..' '..' '..' 'Azure' 'Discovery' 'Data' 'discovery_schema.sql' Invoke-CIEMQuery -Query (Get-Content $discoverySchema -Raw) $graphSchema = Join-Path $PSScriptRoot '..' '..' '..' 'Devolutions.CIEM.Graph' 'Data' 'graph_schema.sql' Invoke-CIEMQuery -Query (Get-Content $graphSchema -Raw) InModuleScope Devolutions.CIEM { $script:DatabasePath = "$TestDrive/ciem.db" } $script:NewTestCIEMAzurePermissionJson = { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Actions, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$DataActions ) @( @{ actions = @($Actions) notActions = @() dataActions = @($DataActions) notDataActions = @() } ) | ConvertTo-Json -Depth 5 -Compress } } Describe 'Get-CIEMEffectivePermission' { Context 'when validating the public command contract' { It 'Is available as a public command' { Get-Command -Module Devolutions.CIEM -Name Get-CIEMEffectivePermission -ErrorAction Stop | Should -Not -BeNullOrEmpty } It 'Reports unavailable provider data instead of synthesizing AWS results' { { Get-CIEMEffectivePermission -Provider AWS } | Should -Throw '*AWS effective permission data is not available*' } It 'Does not call provider APIs from the effective-permissions projection layer' { $moduleRoot = Join-Path $PSScriptRoot '..' '..' $source = @( Get-ChildItem (Join-Path $moduleRoot 'Public') -File -Filter '*.ps1' Get-ChildItem (Join-Path $moduleRoot 'Private') -File -Filter '*.ps1' Get-ChildItem (Join-Path $moduleRoot 'Classes') -File -Filter '*.ps1' ) | ForEach-Object { Get-Content $_.FullName -Raw } $source | Should -Not -Match 'Invoke-AzureApi' $source | Should -Not -Match 'InvokeCIEMResourceGraphQuery' $source | Should -Not -Match 'Invoke-CIEMResourceGraphQuery' $source | Should -Not -Match 'Invoke-RestMethod' $source | Should -Not -Match 'Invoke-WebRequest' $source | Should -Not -Match 'Connect-CIEM' } } Context 'when direct and inherited Azure RBAC graph edges exist' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM graph_edges" Invoke-CIEMQuery -Query "DELETE FROM graph_nodes" Save-CIEMGraphNode -Id 'user-direct' -Kind 'EntraUser' -DisplayName 'Direct User' -Provider 'azure' Save-CIEMGraphNode -Id 'user-inherited' -Kind 'EntraUser' -DisplayName 'Inherited User' -Provider 'azure' Save-CIEMGraphNode -Id 'group-admins' -Kind 'EntraGroup' -DisplayName 'Cloud Admins' -Provider 'azure' Save-CIEMGraphNode -Id '/subscriptions/sub-1' -Kind 'AzureSubscription' -DisplayName 'Sub 1' -Provider 'azure' -SubscriptionId 'sub-1' $readerPermissions = & $script:NewTestCIEMAzurePermissionJson ` -Actions @('Microsoft.Resources/subscriptions/resourceGroups/read') ` -DataActions @() $ownerPermissions = & $script:NewTestCIEMAzurePermissionJson ` -Actions @('*') ` -DataActions @() Save-CIEMGraphEdge -SourceId 'user-direct' -TargetId '/subscriptions/sub-1' -Kind 'HasRole' -Computed 1 ` -Properties (@{ role_name = 'Reader' role_definition_id = 'role-reader' permissions_json = $readerPermissions privileged = $false principal_type = 'User' } | ConvertTo-Json -Compress) Save-CIEMGraphEdge -SourceId 'user-inherited' -TargetId '/subscriptions/sub-1' -Kind 'InheritedRole' -Computed 1 ` -Properties (@{ role_name = 'Owner' role_definition_id = 'role-owner' permissions_json = $ownerPermissions privileged = $true principal_type = 'User' inherited_from = 'group-admins' inherited_from_name = 'Cloud Admins' } | ConvertTo-Json -Compress) $script:results = @(Get-CIEMEffectivePermission -Provider Azure) } It 'Returns typed effective permission objects' { $script:results[0].GetType().Name | Should -Be 'CIEMEffectivePermission' } It 'Returns typed nested principal objects' { $script:results[0].Principal.GetType().Name | Should -Be 'CIEMEffectivePrincipal' } It 'Projects direct role assignment as a direct path' { $direct = $script:results | Where-Object { $_.Principal.Id -eq 'user-direct' } [string]$direct.Path[0].Type | Should -Be 'Direct' } It 'Projects inherited role assignment with inherited group evidence' { $inherited = $script:results | Where-Object { $_.Principal.Id -eq 'user-inherited' } [string]$inherited.Path[0].Type | Should -Be 'GroupInherited' $inherited.Path[0].SourceName | Should -Be 'Cloud Admins' } It 'Classifies wildcard role actions as Manage access' { $inherited = $script:results | Where-Object { $_.Principal.Id -eq 'user-inherited' } [string]$inherited.Actions[0].AccessLevel | Should -Be 'Manage' } } Context 'when Azure RBAC actions need friendly descriptions' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM graph_edges" Invoke-CIEMQuery -Query "DELETE FROM graph_nodes" Save-CIEMGraphNode -Id 'user-friendly' -Kind 'EntraUser' -DisplayName 'Friendly User' -Provider 'azure' Save-CIEMGraphNode -Id 'tenant-friendly' -Kind 'AzureTenant' -DisplayName 'Tenant' -Provider 'azure' Save-CIEMGraphNode -Id '/subscriptions/sub-friendly/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/vault-friendly' ` -Kind 'AzureKeyVault' ` -DisplayName 'Friendly Vault' ` -Provider 'azure' ` -Properties '{"arm_type":"microsoft.keyvault/vaults"}' $tenantReaderPermissions = & $script:NewTestCIEMAzurePermissionJson ` -Actions @('*/read') ` -DataActions @() $keyVaultSecretPermissions = & $script:NewTestCIEMAzurePermissionJson ` -Actions @() ` -DataActions @( 'Microsoft.KeyVault/vaults/secrets/getSecret/action', 'Microsoft.KeyVault/vaults/secrets/readMetadata/action' ) Save-CIEMGraphEdge -SourceId 'user-friendly' -TargetId 'tenant-friendly' -Kind 'HasRole' -Computed 1 ` -Properties (@{ role_name = 'Tenant Reader' role_definition_id = 'role-tenant-reader' permissions_json = $tenantReaderPermissions privileged = $false principal_type = 'User' } | ConvertTo-Json -Compress) Save-CIEMGraphEdge -SourceId 'user-friendly' -TargetId '/subscriptions/sub-friendly/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/vault-friendly' -Kind 'HasRole' -Computed 1 ` -Properties (@{ role_name = 'Key Vault Secrets User' role_definition_id = 'role-keyvault-secrets' permissions_json = $keyVaultSecretPermissions privileged = $true principal_type = 'User' } | ConvertTo-Json -Compress) $script:friendlyResults = @(Get-CIEMEffectivePermission -Provider Azure) } It 'Describes wildcard tenant read access in plain language' { $tenantPermission = $script:friendlyResults | Where-Object { $_.Target.Type -eq 'AzureTenant' } $tenantPermission.Actions[0].Description | Should -Be 'Can read all resources in the Azure tenant' } It 'Describes Key Vault secret data actions in plain language' { $keyVaultPermission = $script:friendlyResults | Where-Object { $_.Target.Type -eq 'AzureKeyVault' } $keyVaultPermission.Actions.Description | Should -Contain 'Can read secret values in this Azure Key Vault' $keyVaultPermission.Actions.Description | Should -Contain 'Can read secret metadata in this Azure Key Vault' } It 'Describes wildcard Azure authorization actions in plain language' { InModuleScope Devolutions.CIEM { ResolveCIEMEffectivePermissionActionDescription ` -Provider Azure ` -NativeAction 'Microsoft.Authorization/classicAdministrators/roleAssignments/write' ` -TargetType 'AzureSubscription' | Should -Be 'Can modify Azure role assignments in this Azure subscription' } } } Context 'when directory role and app consent graph edges exist' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM graph_edges" Invoke-CIEMQuery -Query "DELETE FROM graph_nodes" Save-CIEMGraphNode -Id 'sp-client' -Kind 'EntraServicePrincipal' -DisplayName 'Client App' -Provider 'azure' Save-CIEMGraphNode -Id 'sp-resource' -Kind 'EntraServicePrincipal' -DisplayName 'Resource API' -Provider 'azure' Save-CIEMGraphNode -Id 'user-admin' -Kind 'EntraUser' -DisplayName 'Directory Admin' -Provider 'azure' Save-CIEMGraphNode -Id 'dir-role-1' -Kind 'EntraDirectoryRole' -DisplayName 'Global Administrator' -Provider 'azure' Save-CIEMGraphEdge -SourceId 'user-admin' -TargetId 'dir-role-1' -Kind 'HasRoleMember' -Computed 0 Save-CIEMGraphEdge -SourceId 'sp-client' -TargetId 'sp-resource' -Kind 'HasAppRoleAssignment' -Computed 1 ` -Properties (@{ assignment_id = 'assignment-1' app_role_id = 'app-role-1' principal_type = 'ServicePrincipal' } | ConvertTo-Json -Compress) Save-CIEMGraphEdge -SourceId 'sp-client' -TargetId 'sp-resource' -Kind 'HasOAuthGrant' -Computed 1 ` -Properties (@{ grant_id = 'grant-1' scope = 'User.Read Directory.Read.All Activity.Read.All User.ReadBasic.All Sites.FullControl.All Directory.AccessAsUser.All TeamsAppInstallation.ReadWriteForTeam openid' consent_type = 'AllPrincipals' } | ConvertTo-Json -Compress) $script:results = @(Get-CIEMEffectivePermission -Provider Azure) } It 'Projects directory role membership as a directory role entitlement' { $directoryRole = $script:results | Where-Object { [string]$_.Entitlement.Type -eq 'DirectoryRole' } $directoryRole.Principal.DisplayName | Should -Be 'Directory Admin' } It 'Describes directory roles as actions the principal can perform' { $directoryRole = $script:results | Where-Object { [string]$_.Entitlement.Type -eq 'DirectoryRole' } $directoryRole.Actions[0].Description | Should -Be 'Can administer all Microsoft Entra resources' } It 'Projects OAuth grants as typed OAuth grant entitlements' { $oauthGrant = $script:results | Where-Object { [string]$_.Entitlement.Type -eq 'OAuthGrant' } $oauthGrant.Actions[0].NativeAction | Should -Be 'User.Read' } It 'Describes OAuth grants as Microsoft Graph actions' { $oauthGrant = $script:results | Where-Object { [string]$_.Entitlement.Type -eq 'OAuthGrant' } $oauthGrant.Actions.Description | Should -Contain 'Can read Microsoft Graph user data' $oauthGrant.Actions.Description | Should -Contain 'Can read Microsoft Graph directory data' $oauthGrant.Actions.Description | Should -Contain 'Can read Microsoft Graph activity data' $oauthGrant.Actions.Description | Should -Contain 'Can read basic Microsoft Graph user data' $oauthGrant.Actions.Description | Should -Contain 'Can fully control Microsoft Graph site data' $oauthGrant.Actions.Description | Should -Contain 'Can access Microsoft Graph directory data as the signed-in user' $oauthGrant.Actions.Description | Should -Contain 'Can read and modify Microsoft Teams app installations for teams' $oauthGrant.Actions.Description | Should -Contain 'Can sign the user in with OpenID Connect' } It 'Describes app role assignments as application access actions' { $appRoleAssignment = $script:results | Where-Object { [string]$_.Entitlement.Type -eq 'AppRoleAssignment' } $appRoleAssignment.Actions[0].Description | Should -Be 'Can access this enterprise application' } It 'Does not describe effective actions as held roles or entitlements' { $actionDescriptions = @($script:results | ForEach-Object { $_.Actions.Description }) ($actionDescriptions -join "`n") | Should -Not -Match '\b(hold|entitlement|directory role)\b' } } Context 'when description catalog mappings are missing' { It 'Throws for <Name>' -TestCases @( @{ Name = 'unmapped Microsoft Graph permission subjects' NativeAction = 'UnknownSubject.Read.All' TargetType = 'EntraServicePrincipal' ExpectedMessage = "*Microsoft Graph permission subject 'UnknownSubject'*" }, @{ Name = 'unmapped Microsoft Graph permission verbs' NativeAction = 'User.UnknownVerb.All' TargetType = 'EntraServicePrincipal' ExpectedMessage = "*Microsoft Graph permission verb 'UnknownVerb'*" }, @{ Name = 'unmapped Microsoft Entra directory roles' NativeAction = 'Unknown Directory Role' TargetType = 'EntraDirectoryRole' ExpectedMessage = "*Microsoft Entra directory role 'Unknown Directory Role'*" }, @{ Name = 'unmapped effective permission target scopes' NativeAction = '*/read' TargetType = 'AzureUnknownResource' ExpectedMessage = "*target scope 'AzureUnknownResource'*" }, @{ Name = 'unmapped Azure action resource paths' NativeAction = 'Microsoft.Unknown/widgets/read' TargetType = 'AzureSubscription' ExpectedMessage = "*Azure resource path 'Microsoft.Unknown/widgets'*" } ) { param( [string]$Name, [string]$NativeAction, [string]$TargetType, [string]$ExpectedMessage ) InModuleScope Devolutions.CIEM -Parameters @{ NativeAction = $NativeAction TargetType = $TargetType ExpectedMessage = $ExpectedMessage } { { ResolveCIEMEffectivePermissionActionDescription -Provider Azure -NativeAction $NativeAction -TargetType $TargetType } | Should -Throw $ExpectedMessage } } } Context 'when filtering effective permissions' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM graph_edges" Invoke-CIEMQuery -Query "DELETE FROM graph_nodes" Save-CIEMGraphNode -Id 'user-reader' -Kind 'EntraUser' -DisplayName 'Reader User' -Provider 'azure' Save-CIEMGraphNode -Id 'user-owner' -Kind 'EntraUser' -DisplayName 'Owner User' -Provider 'azure' Save-CIEMGraphNode -Id '/subscriptions/sub-filter' -Kind 'AzureSubscription' -DisplayName 'Sub Filter' -Provider 'azure' -SubscriptionId 'sub-filter' Save-CIEMGraphEdge -SourceId 'user-reader' -TargetId '/subscriptions/sub-filter' -Kind 'HasRole' -Computed 1 ` -Properties (@{ role_name = 'Reader'; role_definition_id = 'reader'; permissions_json = $null; privileged = $false; principal_type = 'User' } | ConvertTo-Json -Compress) Save-CIEMGraphEdge -SourceId 'user-owner' -TargetId '/subscriptions/sub-filter' -Kind 'HasRole' -Computed 1 ` -Properties (@{ role_name = 'Owner'; role_definition_id = 'owner'; permissions_json = $null; privileged = $true; principal_type = 'User' } | ConvertTo-Json -Compress) $script:privilegedResults = @(Get-CIEMEffectivePermission -Provider Azure -PrivilegedOnly) $script:principalResults = @(Get-CIEMEffectivePermission -Provider Azure -PrincipalId 'user-reader') } It 'Returns only privileged rows when PrivilegedOnly is specified' { $script:privilegedResults | Should -HaveCount 1 $script:privilegedResults[0].Principal.Id | Should -Be 'user-owner' } It 'Returns only selected principal rows when PrincipalId is specified' { $script:principalResults | Should -HaveCount 1 $script:principalResults[0].Principal.Id | Should -Be 'user-reader' } It 'Describes Azure role names as actions when permission JSON is absent' { $script:principalResults[0].Actions[0].Description | Should -Be 'Can read Azure resources in this Azure subscription' } } Context 'when app role and OAuth grants are already discovered' { BeforeAll { Invoke-CIEMQuery -Query "DELETE FROM graph_edges" Invoke-CIEMQuery -Query "DELETE FROM graph_nodes" Save-CIEMGraphNode -Id 'sp-client' -Kind 'EntraServicePrincipal' -DisplayName 'Client App' -Provider 'azure' Save-CIEMGraphNode -Id 'sp-resource' -Kind 'EntraServicePrincipal' -DisplayName 'Resource API' -Provider 'azure' $script:entraResources = @( [pscustomobject]@{ Id = 'sp-client_app-role-assignment-1' Type = 'appRoleAssignment' ParentId = 'sp-client' Properties = (@{ id = 'app-role-assignment-1' appRoleId = 'app-role-1' principalId = 'sp-client' principalType = 'ServicePrincipal' resourceId = 'sp-resource' resourceDisplayName = 'Resource API' } | ConvertTo-Json -Compress) }, [pscustomobject]@{ Id = 'oauth-grant-1' Type = 'oauth2PermissionGrant' ParentId = 'sp-client' Properties = (@{ id = 'oauth-grant-1' clientId = 'sp-client' consentType = 'AllPrincipals' resourceId = 'sp-resource' scope = 'User.Read Directory.Read.All' } | ConvertTo-Json -Compress) } ) InModuleScope Devolutions.CIEM -Parameters @{ entraResources = $script:entraResources } { InvokeCIEMGraphComputedEdgeBuild -ArmResources @() -EntraResources $entraResources -Relationships @() -Connection $null -CollectedAt '2026-01-01T00:00:00Z' } $script:appRoleEdges = @(Get-CIEMGraphEdge -SourceId 'sp-client' -TargetId 'sp-resource' -Kind 'HasAppRoleAssignment') $script:oauthEdges = @(Get-CIEMGraphEdge -SourceId 'sp-client' -TargetId 'sp-resource' -Kind 'HasOAuthGrant') } It 'Projects discovered app role assignments into graph edges' { $script:appRoleEdges | Should -HaveCount 1 } It 'Projects discovered OAuth grants into graph edges' { $script:oauthEdges | Should -HaveCount 1 } } } |