tests/New-MsecApp.Tests.ps1
|
#Requires -Module Pester # # Tests for New-MsecApp. The behaviour that matters is idempotence: it is documented as safe # to re-run, which means a re-run against an app holding only SOME of the permissions has to # add the rest - to the app's requiredResourceAccess AND as appRoleAssignments, which are # what actually grant them. # # It also has to SAY so. This step used to report only through Write-Verbose, so a re-run # that added a dozen permissions printed one line about finding the app and nothing about # the grants - which is indistinguishable from having done nothing, and was reported as # exactly that. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop # Passed as TEXT and rebuilt inside InModuleScope - a scriptblock stays bound to the # session state it was written in and could not resolve Mock's private targets there. $script:MockText = @' Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = 'user-token' } } Mock Get-MsecEnvironment -MockWith { [pscustomobject]@{ EnvironmentName = 'AzureCloud' GraphResource = 'https://graph.microsoft.com' DefenderResource = 'https://api.securitycenter.microsoft.com' } } # The vault already exists by default, so most tests take the find path and never touch # creation. New-AzKeyVault and New-AzRoleAssignment are mocked all the same: an unmocked one # would create REAL Azure resources from a test run, which is a far worse failure than a red # test. Same reasoning as the certificate cmdlets below. Mock Get-AzKeyVault -MockWith { [pscustomobject]@{ VaultName = 'kv-test' ResourceGroupName = 'rg-test' ResourceId = '/subscriptions/s1/resourceGroups/rg-test/providers/Microsoft.KeyVault/vaults/kv-test' } } Mock New-AzKeyVault -MockWith { [pscustomobject]@{ VaultName = 'kv-test' ResourceGroupName = 'rg-test' ResourceId = '/subscriptions/s1/resourceGroups/rg-test/providers/Microsoft.KeyVault/vaults/kv-test' } } Mock Get-AzADUser -MockWith { [pscustomobject]@{ Id = 'signed-in-user' } } Mock New-AzRoleAssignment -MockWith { } # The certificate already exists and matches, so the run reaches the consent step. Every # Key Vault cmdlet the function can touch is mocked - Update-AzKeyVaultCertificate runs # unconditionally, and an unmocked one reaches real Azure from a test run. Mock Get-AzKeyVaultCertificate -MockWith { [pscustomobject]@{ Thumbprint = 'AABB'; Certificate = [pscustomobject]@{ RawData = [byte[]](1..10) } } } Mock Update-AzKeyVaultCertificate -MockWith { } Mock Add-AzKeyVaultCertificate -MockWith { } Mock New-AzKeyVaultCertificatePolicy -MockWith { [pscustomobject]@{} } Mock Get-AzKeyVaultCertificateOperation -MockWith { [pscustomobject]@{ Status = 'completed' } } $script:Calls = [System.Collections.Generic.List[object]]::new() Mock Invoke-RestMethod -MockWith { # The function's Graph helper serialises to JSON before calling Invoke-RestMethod, so # $Body here is a STRING. Parsed back so assertions can address its properties - # reading .appRoleId off the raw JSON silently yields $null and every assertion passes # vacuously. $parsed = if ($Body) { $Body | ConvertFrom-Json } else { $null } $script:Calls.Add([pscustomobject]@{ Method = [string]$Method; Uri = [string]$Uri; Body = $parsed }) $u = [string]$Uri if ($u -match "servicePrincipals\(appId='00000003-0000-0000-c000-") { # Graph exposes every application role msec asks for. return [pscustomobject]@{ id = 'sp-graph' appRoles = @($script:GraphRoleValues | ForEach-Object { [pscustomobject]@{ value = $_; id = "role-$_"; allowedMemberTypes = @('Application') } }) } } if ($u -match "servicePrincipals\(appId='fc780465-") { return [pscustomobject]@{ id = 'sp-mdatp'; appRoles = @( [pscustomobject]@{ value = 'Score.Read.All'; id = 'role-score'; allowedMemberTypes = @('Application') } [pscustomobject]@{ value = 'Machine.Read.All'; id = 'role-machine'; allowedMemberTypes = @('Application') } [pscustomobject]@{ value = 'Vulnerability.Read.All'; id = 'role-vuln'; allowedMemberTypes = @('Application') }) } } # Office 365 Exchange Online and SharePoint - only reached with -Workload. if ($u -match "servicePrincipals\(appId='00000002-0000-0ff1-ce00-") { if ($script:ExchangeSpMissing) { throw 'Request_ResourceNotFound' } return [pscustomobject]@{ id = 'sp-exo'; appRoles = @( [pscustomobject]@{ value = 'Exchange.ManageAsApp'; id = 'role-exo'; allowedMemberTypes = @('Application') }) } } if ($u -match "servicePrincipals\(appId='48ac35b8-9aa8-4d74-927d-1f4a14a0b239") { return [pscustomobject]@{ id = 'sp-teams'; appRoles = @( [pscustomobject]@{ value = 'application_access'; id = 'role-teams'; allowedMemberTypes = @('Application') }) } } if ($u -match "servicePrincipals\(appId='00000003-0000-0ff1-ce00-") { return [pscustomobject]@{ id = 'sp-spo'; appRoles = @( [pscustomobject]@{ value = 'Sites.Read.All'; id = 'role-spo-sites'; allowedMemberTypes = @('Application') }) } } if ($u -match '/roleManagement/directory/roleDefinitions') { if ($script:RoleDefinitionMissing) { return [pscustomobject]@{ value = @() } } return [pscustomobject]@{ value = @([pscustomobject]@{ id = 'roledef-globalreader'; displayName = 'Global Reader' }) } } if ($u -match '/roleManagement/directory/roleAssignments') { if ($Method -eq 'POST') { if ($script:RoleAssignDenied) { throw 'Authorization_RequestDenied' } return [pscustomobject]@{ id = 'assignment-1' } } return [pscustomobject]@{ value = @($script:ExistingRoleAssignments) } } if ($u -match '/applications\?\$filter=') { return [pscustomobject]@{ value = @([pscustomobject]@{ id = 'app-obj-1'; appId = 'client-1'; displayName = 'msec' requiredResourceAccess = $script:ExistingRRA keyCredentials = @() }) } } if ($u -match '/servicePrincipals\?\$filter=appId') { return [pscustomobject]@{ value = @([pscustomobject]@{ id = 'sp-app-1' }) } } if ($u -match '/appRoleAssignments' -and $Method -eq 'GET') { return [pscustomobject]@{ value = $script:ExistingGrants } } [pscustomobject]@{ id = 'generic' } } '@ } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'New-MsecApp' { Context 're-run against an app missing most permissions' { BeforeEach { InModuleScope Msec { $script:GraphRoleValues = @( 'SecurityEvents.Read.All', 'DeviceManagementConfiguration.Read.All', 'DeviceManagementManagedDevices.Read.All', 'DeviceManagementScripts.Read.All', 'ThreatHunting.Read.All', 'SecurityIncident.Read.All', 'Policy.Read.All', 'AuditLog.Read.All', 'Organization.Read.All', 'RoleManagement.Read.Directory', 'User.Read.All', 'Group.Read.All', 'Application.Read.All', 'PrivilegedEligibilitySchedule.Read.AzureADGroup' ) # The app requests, and has consent for, only two of them. $script:ExistingRRA = @( [pscustomobject]@{ resourceAppId = '00000003-0000-0000-c000-000000000000' resourceAccess = @( [pscustomobject]@{ id = 'role-SecurityEvents.Read.All'; type = 'Role' } [pscustomobject]@{ id = 'role-Policy.Read.All'; type = 'Role' } ) } ) $script:ExistingGrants = @( [pscustomobject]@{ resourceId = 'sp-graph'; appRoleId = 'role-SecurityEvents.Read.All' } [pscustomobject]@{ resourceId = 'sp-graph'; appRoleId = 'role-Policy.Read.All' } ) } } It 'grants every missing permission and leaves the present ones alone' { $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) $result = New-MsecApp -KeyVaultName 'kv-test' -InformationAction SilentlyContinue 6>$null [pscustomobject]@{ Result = $result; Calls = @($script:Calls) } } # 14 Graph roles + 3 Defender roles = 17 desired, 2 already consented. @($out.Result.GrantedNow).Count | Should -Be 15 @($out.Result.AlreadyGranted).Count | Should -Be 2 # One POST per newly granted pair, and none for the two that were already there. $posts = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' }) @($posts).Count | Should -Be 15 $posts.Body.appRoleId | Should -Not -Contain 'role-SecurityEvents.Read.All' $posts.Body.appRoleId | Should -Contain 'role-DeviceManagementManagedDevices.Read.All' $posts.Body.appRoleId | Should -Contain 'role-score' $posts.Body.appRoleId | Should -Contain 'role-machine' $posts.Body.appRoleId | Should -Contain 'role-vuln' # Every grant targets the app's own SP as principal, and the resource's SP as # resource - transposing those two silently grants nothing useful. ($posts.Body.principalId | Sort-Object -Unique) | Should -Be 'sp-app-1' } It 'merges requiredResourceAccess instead of clobbering it' { $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) New-MsecApp -KeyVaultName 'kv-test' 6>$null | Out-Null [pscustomobject]@{ Calls = @($script:Calls) } } $patch = @($out.Calls | Where-Object { $_.Method -eq 'PATCH' -and $_.Body.requiredResourceAccess }) | Select-Object -First 1 $patch | Should -Not -BeNullOrEmpty $graphEntry = $patch.Body.requiredResourceAccess | Where-Object { $_.resourceAppId -eq '00000003-0000-0000-c000-000000000000' } $ids = @($graphEntry.resourceAccess | ForEach-Object { $_.id }) @($ids).Count | Should -Be 14 # The two it already had survive... $ids | Should -Contain 'role-SecurityEvents.Read.All' $ids | Should -Contain 'role-Policy.Read.All' # ...and the missing ones are added. $ids | Should -Contain 'role-Group.Read.All' # No duplicates - re-running must not grow the collection every time. @($ids | Sort-Object -Unique).Count | Should -Be 14 } It 'reports the grants on stdout, not only through -Verbose' { # THE regression. Silence here was reported as "it does not add the grants". $text = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) New-MsecApp -KeyVaultName 'kv-test' 6>&1 | Out-String } $text | Should -Match '15 granted now' $text | Should -Match '2 already present' $text | Should -Match 'Group\.Read\.All' # And the instruction without which a caller re-runs this, retries, gets the same # 403, and concludes the grant failed. $text | Should -Match 'Disconnect-Msec' } } Context 'a fully consented app' { BeforeEach { InModuleScope Msec { $script:GraphRoleValues = @('SecurityEvents.Read.All') $script:ExistingRRA = @( [pscustomobject]@{ resourceAppId = '00000003-0000-0000-c000-000000000000' resourceAccess = @([pscustomobject]@{ id = 'role-SecurityEvents.Read.All'; type = 'Role' }) } [pscustomobject]@{ resourceAppId = 'fc780465-2017-40d4-a0c5-307022471b92' resourceAccess = @( [pscustomobject]@{ id = 'role-score'; type = 'Role' } [pscustomobject]@{ id = 'role-machine'; type = 'Role' } [pscustomobject]@{ id = 'role-vuln'; type = 'Role' } ) } ) $script:ExistingGrants = @( [pscustomobject]@{ resourceId = 'sp-graph'; appRoleId = 'role-SecurityEvents.Read.All' } [pscustomobject]@{ resourceId = 'sp-mdatp'; appRoleId = 'role-score' } [pscustomobject]@{ resourceId = 'sp-mdatp'; appRoleId = 'role-machine' } [pscustomobject]@{ resourceId = 'sp-mdatp'; appRoleId = 'role-vuln' } ) } } It 'grants nothing and does not tell you to reconnect' { # Idempotence: a no-op re-run must be visibly a no-op, or the reconnect notice # becomes noise that gets ignored on the run where it matters. $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) $text = New-MsecApp -KeyVaultName 'kv-test' 6>&1 | Out-String [pscustomobject]@{ Text = $text; Calls = @($script:Calls) } } @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' }).Count | Should -Be 0 $out.Text | Should -Match '0 granted now' $out.Text | Should -Not -Match 'Disconnect-Msec' } } Context 'a cloud that does not offer every role' { BeforeEach { InModuleScope Msec { # Azure China exposes a reduced set of Graph app roles. $script:GraphRoleValues = @('SecurityEvents.Read.All', 'Policy.Read.All') $script:ExistingRRA = @() $script:ExistingGrants = @() } } It 'skips unavailable roles, reports them, and still configures the rest' { $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) $result = New-MsecApp -KeyVaultName 'kv-test' -WarningVariable w -WarningAction SilentlyContinue 6>$null [pscustomobject]@{ Result = $result; Warnings = @($w) } } # Two Graph roles exist here, plus the three Defender ones - the reduced set this # context stands for is a GRAPH reduction; a cloud with no Defender at all skips # that resource entirely rather than finding its roles missing. @($out.Result.GrantedNow).Count | Should -Be 5 # The eighteen Graph roles that do not exist in this cloud are named rather than # silently lost. # This count is deliberately literal: it has to be updated by hand whenever a # permission is added to $resources, which is the point - a role that vanishes # from the list should fail a test rather than quietly stop being requested. @($out.Result.UnavailableRoles).Count | Should -Be 18 ($out.Warnings -join "`n") | Should -Match 'not available' ($out.Warnings -join "`n") | Should -Match 'Group\.Read\.All' } } Context 'a grant the directory refuses' { It 'reports a refused permission as refused, never as granted' { # REGRESSION. A refused appRoleAssignment used to print a raw REST error, carry on, # and add the label to GrantedNow anyway - so the summary claimed permissions the # service principal did not have. Measured on one tenant: reported 19 in place while # the SP carried 14, which turned every later 403 into a different-looking problem. $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) # Let everything through except the consent POST, which the directory refuses. Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'POST' -and "$Uri" -match 'appRoleAssignments' } -MockWith { $e = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new('Response status code does not indicate success: 403 (Forbidden).'), 'Denied', 'PermissionDenied', $null) throw $e } $w = @() $r = New-MsecApp -KeyVaultName 'kv-test' -WarningVariable w -WarningAction SilentlyContinue 6>$null [pscustomobject]@{ Result = $r; Warnings = "$($w -join ' ')" } } @($out.Result.FailedGrants).Count | Should -BeGreaterThan 0 # The decisive assertion: nothing refused may appear as granted. foreach ($f in @($out.Result.FailedGrants)) { $name = ($f -split ' - ')[0] @($out.Result.GrantedNow) | Should -Not -Contain $name } $out.Warnings | Should -Match 'REFUSED' # Names the roles that can actually write an app role assignment. $out.Warnings | Should -Match 'Application Administrator' $out.Warnings | Should -Match '403' } } Context 'workloads beyond Graph and Defender' { BeforeEach { InModuleScope Msec { $script:GraphRoleValues = @('SecurityEvents.Read.All', 'Policy.Read.All', 'Sites.Read.All', 'SharePointTenantSettings.Read.All') $script:ExistingRRA = @() $script:ExistingGrants = @() $script:ExistingRoleAssignments = @() $script:RoleAssignDenied = $false $script:RoleDefinitionMissing = $false $script:ExchangeSpMissing = $false # Shared across the whole file and never cleared otherwise, so "no calls of # this kind were made" would be answered by an earlier test's calls. $script:Calls = [System.Collections.Generic.List[object]]::new() } } It 'grants nothing extra unless a workload is asked for' { $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) New-MsecApp -KeyVaultName 'kv-test' -WarningAction SilentlyContinue 6>$null [pscustomobject]@{ Calls = @($script:Calls) } } # Exchange needs a directory role and SharePoint needs fresh consent, so neither is # a side effect of an ordinary bootstrap. @($out.Calls | Where-Object Uri -match '0ff1-ce00').Count | Should -Be 0 # The DIRECTORY role path specifically. Matching bare 'roleAssignments' also # catches '/appRoleAssignments', which every ordinary grant uses - so the loose # pattern counts six routine calls and reads as a failure. @($out.Calls | Where-Object Uri -match '/roleManagement/directory/roleAssignments').Count | Should -Be 0 } It 'grants SharePoint on the SharePoint service principal, not on Graph' { $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) $result = New-MsecApp -KeyVaultName 'kv-test' -Workload SharePoint -WarningAction SilentlyContinue 6>$null [pscustomobject]@{ Result = $result; Calls = @($script:Calls) } } # Sites.Read.All exists on BOTH Microsoft Graph and SharePoint with the same name. # PnP presents a SharePoint-audience token, so the grant must target sp-spo - # granting the Graph one looks right in the portal and still fails. $grant = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' -and $_.Body.appRoleId -eq 'role-spo-sites' }) @($grant).Count | Should -Be 1 $grant[0].Body.resourceId | Should -Be 'sp-spo' $out.Result.GrantedNow | Should -Contain 'Office 365 SharePoint Online: Sites.Read.All' # The tenant-wide sharing posture - SharingCapability, the domain allow-list, the # restriction mode - lives at /admin/sharepoint/settings and needs a permission of # its own. Sites.Read.All reads SITES, not tenant settings, and the call 403s with # 'Caller does not have required permissions for this API', naming nothing. $tenantGrant = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' -and $_.Body.appRoleId -eq 'role-SharePointTenantSettings.Read.All' }) @($tenantGrant).Count | Should -Be 1 $tenantGrant[0].Body.resourceId | Should -Be 'sp-graph' # SharePoint needs no directory role - that is Exchange's problem alone. $out.Result.DirectoryRole | Should -BeNullOrEmpty } It 'assigns the directory role for Exchange, because the app role alone is not enough' { $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) $result = New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange -WarningAction SilentlyContinue 6>$null [pscustomobject]@{ Result = $result; Calls = @($script:Calls) } } # The app role... @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Body.appRoleId -eq 'role-exo' }).Count | Should -Be 1 # ...AND the directory role. Without the second, every Get-EXO* fails with an # authorisation error that names no missing permission. $assign = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/roleManagement/directory/roleAssignments' }) @($assign).Count | Should -Be 1 $assign[0].Body.roleDefinitionId | Should -Be 'roledef-globalreader' $assign[0].Body.principalId | Should -Be 'sp-app-1' $assign[0].Body.directoryScopeId | Should -Be '/' $out.Result.DirectoryRole | Should -Be 'Global Reader' } It 'grants Teams on the Teams admin API and assigns the directory role' { $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) $result = New-MsecApp -KeyVaultName 'kv-test' -Workload Teams -WarningAction SilentlyContinue 6>$null [pscustomobject]@{ Result = $result; Calls = @($script:Calls) } } # Teams is a SEPARATE audience from Graph. Connect-MicrosoftTeams presents a token # for the Skype and Teams Tenant Admin API, which carries only the roles granted on # THAT resource - so Graph permissions, however broad, buy nothing here. $grant = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/appRoleAssignments' -and $_.Body.appRoleId -eq 'role-teams' }) @($grant).Count | Should -Be 1 $grant[0].Body.resourceId | Should -Be 'sp-teams' # And the directory role, for the same reason Exchange needs one: without it # Connect-MicrosoftTeams succeeds and every Get-Cs* call then fails. $assign = @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/roleManagement/directory/roleAssignments' }) @($assign).Count | Should -Be 1 $out.Result.DirectoryRole | Should -Be 'Global Reader' } It 'takes the directory role under its old Exchange-specific name' { # -ExchangeDirectoryRole shipped in 0.2.0. Renaming it outright would break every # caller that already passes it, and silently: a bootstrap script would fail at the # parameter binder rather than at anything to do with permissions. $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange ` -ExchangeDirectoryRole 'Exchange Administrator' -WarningAction SilentlyContinue 6>$null } $out.DirectoryRole | Should -Be 'Exchange Administrator' } It 'does not re-assign a directory role the app already holds' { $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) $script:ExistingRoleAssignments = @([pscustomobject]@{ id = 'existing'; principalId = 'sp-app-1'; roleDefinitionId = 'roledef-globalreader' }) $result = New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange -WarningAction SilentlyContinue 6>$null [pscustomobject]@{ Result = $result; Calls = @($script:Calls) } } # Re-running must be a no-op, like the rest of this command. @($out.Calls | Where-Object { $_.Method -eq 'POST' -and $_.Uri -match '/roleManagement/directory/roleAssignments' }).Count | Should -Be 0 $out.Result.DirectoryRole | Should -Match 'already assigned' } It 'configures everything else when the caller cannot assign a directory role' { $warnings = @() $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) # Creating a role assignment needs Privileged Role Administrator - a higher bar # than the rest of this command, so a caller may legitimately not have it. $script:RoleAssignDenied = $true New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange 6>$null } -WarningVariable warnings -WarningAction SilentlyContinue # The app roles still landed; only the role assignment failed. $out.GrantedNow | Should -Contain 'Office 365 Exchange Online: Exchange.ManageAsApp' $out.DirectoryRole | Should -BeNullOrEmpty ($warnings -join ' ') | Should -Match 'Privileged Role Administrator' ($warnings -join ' ') | Should -Match 'NOT enough' } It 'skips a workload whose service principal does not exist in the tenant' { $warnings = @() $out = InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) # A tenant with no Exchange Online. Must not abort the whole bootstrap. $script:ExchangeSpMissing = $true New-MsecApp -KeyVaultName 'kv-test' -Workload Exchange 6>$null } -WarningVariable warnings -WarningAction SilentlyContinue $out.UnavailableRoles -join ' ' | Should -Match 'service principal not found' ($warnings -join ' ') | Should -Match 'could not be resolved' # The Graph roles were still configured. @($out.GrantedNow).Count | Should -BeGreaterThan 0 } } } Describe 'New-MsecApp key vault provisioning' { # Every test runs InModuleScope and rebuilds the shared mocks from text, the same way the # Describe above does: a plain Mock at file scope does NOT intercept a call made from # inside the module, and here that failure mode is expensive - an unmocked New-AzKeyVault # reaches real Azure and tries to create a real vault. It 'uses an existing vault and never tries to create one' { # The common case. -ResourceGroupName and -Location must not become required just # because the command gained the ability to create a vault. InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) New-MsecApp -KeyVaultName 'kv-test' -InformationAction SilentlyContinue 6>$null | Out-Null Should -Invoke New-AzKeyVault -Times 0 -Exactly } } It 'does not reconfigure an existing vault even when creation options are passed' { # Re-running the bootstrap with the original arguments must not silently re-open a # vault that has since been locked down. InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) New-MsecApp -KeyVaultName 'kv-test' -ResourceGroupName 'rg-test' -Location 'westeurope' ` -SoftDeleteRetentionInDays 7 -InformationAction SilentlyContinue 6>$null | Out-Null Should -Invoke New-AzKeyVault -Times 0 -Exactly } } It 'refuses to guess a resource group or location, and says not-found is ambiguous' { # The message has to carry the ambiguity: Get-AzKeyVault returns nothing both for a # missing vault and for one the caller cannot see, so telling a user without Reader # to "create it" sends them straight to a name-already-taken error. InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) Mock Get-AzKeyVault -MockWith { $null } { New-MsecApp -KeyVaultName 'kv-missing' -InformationAction SilentlyContinue 6>$null } | Should -Throw '*-ResourceGroupName and -Location*' { New-MsecApp -KeyVaultName 'kv-missing' -InformationAction SilentlyContinue 6>$null } | Should -Throw '*exists but not visible to you*' Should -Invoke New-AzKeyVault -Times 0 -Exactly } } It 'creates the vault with 30-day retention, purge protection, and RBAC left at the default' { InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) Mock Get-AzKeyVault -MockWith { $null } New-MsecApp -KeyVaultName 'kv-new' -ResourceGroupName 'rg-test' -Location 'westeurope' ` -InformationAction SilentlyContinue 6>$null | Out-Null Should -Invoke New-AzKeyVault -Times 1 -Exactly -ParameterFilter { # There is no -EnableRbacAuthorization in Az.KeyVault 6.x: RBAC is the default # and -DisableRbacAuthorization opts out, so the assertion is that nothing # opted out. An access-policy vault would reject every role msec documents. -not $PSBoundParameters.ContainsKey('DisableRbacAuthorization') -and # 30, not the Az default of 90 - matching the vault the Bicep template made. $SoftDeleteRetentionInDays -eq 30 -and $EnablePurgeProtection -eq $true } } } It '-NoPurgeProtection omits the flag rather than passing it as false' { # Purge protection is one-way; Azure rejects an explicit false, so the property has to # be absent rather than present-and-false. InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) Mock Get-AzKeyVault -MockWith { $null } New-MsecApp -KeyVaultName 'kv-new' -ResourceGroupName 'rg-test' -Location 'westeurope' ` -NoPurgeProtection -InformationAction SilentlyContinue 6>$null | Out-Null Should -Invoke New-AzKeyVault -Times 1 -Exactly -ParameterFilter { -not $PSBoundParameters.ContainsKey('EnablePurgeProtection') } } } It 'grants the caller Certificates Officer, because an RBAC vault grants its creator nothing' { # Control plane and data plane are separate: without this the certificate step 403s # for the very person who just created the vault. InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) Mock Get-AzKeyVault -MockWith { $null } New-MsecApp -KeyVaultName 'kv-new' -ResourceGroupName 'rg-test' -Location 'westeurope' ` -InformationAction SilentlyContinue 6>$null | Out-Null Should -Invoke New-AzRoleAssignment -Times 1 -Exactly -ParameterFilter { $RoleDefinitionName -eq 'Key Vault Certificates Officer' -and $ObjectId -eq 'signed-in-user' } } } It 'treats an already-assigned role as success' { InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) Mock Get-AzKeyVault -MockWith { $null } Mock New-AzRoleAssignment -MockWith { throw 'The role assignment already exists.' } { New-MsecApp -KeyVaultName 'kv-new' -ResourceGroupName 'rg-test' -Location 'westeurope' ` -InformationAction SilentlyContinue 6>$null } | Should -Not -Throw } } It 'warns rather than fails when the caller cannot grant roles' { # Creating the vault without User Access Administrator is real and recoverable: # everything else still configures, and the warning names the role to grant. InModuleScope Msec -Parameters @{ MockText = $script:MockText } { param($MockText) & ([scriptblock]::Create($MockText)) Mock Get-AzKeyVault -MockWith { $null } Mock New-AzRoleAssignment -MockWith { throw 'AuthorizationFailed' } $warnings = @() New-MsecApp -KeyVaultName 'kv-new' -ResourceGroupName 'rg-test' -Location 'westeurope' ` -WarningVariable warnings -WarningAction SilentlyContinue ` -InformationAction SilentlyContinue 6>$null | Out-Null ($warnings -join ' ') | Should -Match 'User Access Administrator' } } } |