diff --git a/source/Public/Grant-M365SecurityAuditConsent.ps1 b/source/Public/Grant-M365SecurityAuditConsent.ps1 new file mode 100644 index 0000000..0b13d9c --- /dev/null +++ b/source/Public/Grant-M365SecurityAuditConsent.ps1 @@ -0,0 +1,182 @@ +<# + .SYNOPSIS + Grants Microsoft Graph permissions for an auditor. + .DESCRIPTION + This function grants the specified Microsoft Graph permissions to a user, allowing the user to perform audits. It connects to Microsoft Graph, checks if a service principal exists for the client application, creates it if it does not exist, and then grants the specified permissions. Finally, it assigns the app to the user. + .PARAMETER UserPrincipalNameForConsent + The UPN or ID of the user to grant consent for. + .PARAMETER SkipGraphConnection + If specified, skips connecting to Microsoft Graph. + .PARAMETER DoNotDisconnect + If specified, does not disconnect from Microsoft Graph after granting consent. + .PARAMETER SkipModuleCheck + If specified, skips the check for the Microsoft.Graph module. + .PARAMETER SuppressRevertOutput + If specified, suppresses the output of the revert commands. + .EXAMPLE + Grant-M365SecurityAuditConsent -UserPrincipalNameForConsent user@example.com + + Grants Microsoft Graph permissions to user@example.com for the client application with the specified Application ID. + .EXAMPLE + Grant-M365SecurityAuditConsent -UserPrincipalNameForConsent user@example.com -SkipGraphConnection + + Grants Microsoft Graph permissions to user@example.com, skipping the connection to Microsoft Graph. + .NOTES + This function requires the Microsoft.Graph module version 2.4.0 or higher. + .LINK + https://criticalsolutionsnetwork.github.io/M365FoundationsCISReport/#Grant-M365SecurityAuditConsent +#> +function Grant-M365SecurityAuditConsent { + [CmdletBinding( + SupportsShouldProcess = $true, + ConfirmImpact = 'High' + )] + [OutputType([void])] + param ( + [Parameter( + Mandatory = $true, + Position = 0, + ValueFromPipeline = $true, + ValueFromPipelineByPropertyName = $true, + HelpMessage = 'Specify the UPN of the user to grant consent for.' + )] + [ValidatePattern('^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')] + [String]$UserPrincipalNameForConsent, + [Parameter( + Mandatory = $false, + HelpMessage = 'Skip connecting to Microsoft Graph.' + )] + [switch]$SkipGraphConnection, + [Parameter( + Mandatory = $false, + HelpMessage = 'Skip the check for the Microsoft.Graph module.' + )] + [switch]$SkipModuleCheck, + [Parameter( + Mandatory = $false, + HelpMessage = 'Suppress the output of the revert commands.' + )] + [switch]$SuppressRevertOutput, + [Parameter( + Mandatory = $false, + HelpMessage = 'Do not disconnect from Microsoft Graph after granting consent.' + )] + [switch]$DoNotDisconnect + ) + begin { + if (!($SkipModuleCheck)) { + Assert-ModuleAvailability -ModuleName Microsoft.Graph -RequiredVersion "2.4.0" + } + # Adjusted from: https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-consent-single-user?pivots=msgraph-powershell + # Needed: A user account with a Privileged Role Administrator, Application Administrator, or Cloud Application Administrator + # The app for which consent is being granted. + $clientAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" # Microsoft Graph PowerShell + # The API to which access will be granted. Microsoft Graph PowerShell makes API + # requests to the Microsoft Graph API, so we'll use that here. + $resourceAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph API + # The permissions to grant. + $permissions = @("Directory.Read.All", "Domain.Read.All", "Policy.Read.All", "Organization.Read.All") + # The user on behalf of whom access will be granted. The app will be able to access + # the API on behalf of this user. + $userUpnOrId = $UserPrincipalNameForConsent + } + process { + try { + if (-not $SkipGraphConnection -and $PSCmdlet.ShouldProcess("Scopes: User.ReadBasic.All, Application.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All, AppRoleAssignment.ReadWrite.All", "Connect-MgGraph")) { + # Step 0. Connect to Microsoft Graph PowerShell. We need User.ReadBasic.All to get + # users' IDs, Application.ReadWrite.All to list and create service principals, + # DelegatedPermissionGrant.ReadWrite.All to create delegated permission grants, + # and AppRoleAssignment.ReadWrite.All to assign an app role. + # WARNING: These are high-privilege permissions! + Write-Host "Connecting to Microsoft Graph with scopes: User.ReadBasic.All, Application.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All, AppRoleAssignment.ReadWrite.All" -ForegroundColor Yellow + Connect-MgGraph -Scopes ("User.ReadBasic.All Application.ReadWrite.All " + "DelegatedPermissionGrant.ReadWrite.All " + "AppRoleAssignment.ReadWrite.All") -NoWelcome + $context = Get-MgContext + Write-Host "Connected to Microsoft Graph with user: $(($context.Account)) with the authtype `"$($context.AuthType)`" for the `"$($context.Environment)`" environment." -ForegroundColor Green + } + } + catch { + throw "Connection execution aborted: $_" + break + } + try { + if ($PSCmdlet.ShouldProcess("Create Microsoft Graph API service princial if not found", "New-MgServicePrincipal")) { + # Step 1. Check if a service principal exists for the client application. + # If one doesn't exist, create it. + $clientSp = Get-MgServicePrincipal -Filter "appId eq '$($clientAppId)'" -ErrorAction SilentlyContinue + if (-not $clientSp) { + Write-Host "Client service principal not found. Creating one." -ForegroundColor Yellow + $clientSp = New-MgServicePrincipal -AppId $clientAppId + } + $user = Get-MgUser -UserId $userUpnOrId + if (!($user)) { + throw "User with UPN or ID `"$userUpnOrId`" not found." + } + Write-Verbose "User: $($user.UserPrincipalName) Found!" + $resourceSp = Get-MgServicePrincipal -Filter "appId eq '$($resourceAppId)'" + $scopeToGrant = $permissions -join " " + $existingGrant = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($clientSp.Id)' and principalId eq '$($user.Id)' and resourceId eq '$($resourceSp.Id)'" + } + if (-not $existingGrant -and $PSCmdlet.ShouldProcess("User: $userUpnOrId for Microsoft Graph PowerShell Scopes: $($permissions -join ', ')", "New-MgOauth2PermissionGrant: Granting Consent")) { + # Step 2. Create a delegated permission that grants the client app access to the + # API, on behalf of the user. + $grant = New-MgOauth2PermissionGrant -ResourceId $resourceSp.Id -Scope $scopeToGrant -ClientId $clientSp.Id -ConsentType "Principal" -PrincipalId $user.Id + Write-Host "Consent granted to user $($user.UserPrincipalName) for Microsoft Graph API with scopes: $((($grant.Scope) -split ' ') -join ', ')" -ForegroundColor Green + } + if ($existingGrant -and $PSCmdlet.ShouldProcess("Update existing Microsoft Graph permissions for user $userUpnOrId", "Update-MgOauth2PermissionGrant")) { + # Step 2. Update the existing permission grant with the new scopes. + Write-Host "Updating existing permission grant for user $($user.UserPrincipalName)." -ForegroundColor Yellow + $updatedGrant = Update-MgOauth2PermissionGrant -PermissionGrantId $existingGrant.Id -Scope $scopeToGrant -Confirm:$false + Write-Host "Updated permission grant with ID $($updatedGrant.Id) for scopes: $scopeToGrant" -ForegroundColor Green + } + if ($PSCmdlet.ShouldProcess("Assigning app to user $userUpnOrId", "New-MgServicePrincipalAppRoleAssignedTo")) { + # Step 3. Assign the app to the user. This ensures that the user can sign in if assignment + # is required, and ensures that the app shows up under the user's My Apps portal. + if ($clientSp.AppRoles | Where-Object { $_.AllowedMemberTypes -contains "User" }) { + Write-Warning "A default app role assignment cannot be created because the client application exposes user-assignable app roles. You must assign the user a specific app role for the app to be listed in the user's My Apps access panel." + } + else { + # The app role ID 00000000-0000-0000-0000-000000000000 is the default app role + # indicating that the app is assigned to the user, but not for any specific + # app role. + $assignment = New-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $clientSp.Id -ResourceId $clientSp.Id -PrincipalId $user.Id -AppRoleId "00000000-0000-0000-0000-000000000000" + # $assignments = Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $assignment.ResourceId -All -WhatIf + } + } + } + catch { + throw "An error occurred while granting consent:`n$_" + } + finally { + if (!($DoNotDisconnect) -and $PSCmdlet.ShouldProcess("Disconnect from Microsoft Graph", "Disconnect")) { + # Clean up sessions + Write-Host "Disconnecting from Microsoft Graph." -ForegroundColor Yellow + Disconnect-MgGraph | Out-Null + } + } + } + end { + if (-not $SuppressRevertOutput -and $PSCmdlet.ShouldProcess("Instructions to undo this change", "Generate Revert Commands")) { + <# + # Instructions to revert the changes made by this script + $resourceAppId = "00000003-0000-0000-c000-000000000000" + $clientAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" + # Get the user object + #$user = Get-MgUser -UserId "user@example.com" + $resourceSp = Get-MgServicePrincipal -Filter "appId eq '$($resourceAppId)'" + # Get the service principal using $clientAppId + $clientSp = Get-MgServicePrincipal -Filter "appId eq '$($clientAppId)'" + $existingGrant = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($clientSp.Id)' and principalId eq '$($user.Id)' and resourceId eq '$($resourceSp.Id)'" + # Get all app role assignments for the service principal + $appRoleAssignments = Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $clientSp.Id -All + # At index of desired user assignment + Remove-MgServicePrincipalAppRoleAssignedTo -AppRoleAssignmentId $appRoleAssignments[1].Id -ServicePrincipalId $clientSp.Id + Remove-MgOAuth2PermissionGrant -OAuth2PermissionGrantId $existingGrant.Id + #> + Write-Host "App assigned to user $($assignment.PrincipalDisplayName) for $($assignment.ResourceDisplayName) at $($assignment.CreatedDateTime)." -ForegroundColor Green + Write-Host "If you made a mistake and would like to remove the assignement for `"$($user.UserPrincipalName)`", you can run the following:`n" -ForegroundColor Yellow + Write-Host "Connect-MgGraph -Scopes (`"User.ReadBasic.All Application.ReadWrite.All `" + `"DelegatedPermissionGrant.ReadWrite.All `" + `"AppRoleAssignment.ReadWrite.All`")" -ForegroundColor Cyan + Write-Host "Remove-MgServicePrincipalAppRoleAssignedTo -AppRoleAssignmentId `"$($assignment.Id)`" -ServicePrincipalId `"$($assignment.ResourceId)`"" -ForegroundColor Cyan + Write-Host "Remove-MgOAuth2PermissionGrant -OAuth2PermissionGrantId `"$($grant.Id)`"" -ForegroundColor Cyan + } + } +} diff --git a/tests/Unit/Public/Grant-M365SecurityAuditConsent.tests.ps1 b/tests/Unit/Public/Grant-M365SecurityAuditConsent.tests.ps1 new file mode 100644 index 0000000..5998a20 --- /dev/null +++ b/tests/Unit/Public/Grant-M365SecurityAuditConsent.tests.ps1 @@ -0,0 +1,71 @@ +BeforeAll { + $script:moduleName = '<% $PLASTER_PARAM_ModuleName %>' + + # If the module is not found, run the build task 'noop'. + if (-not (Get-Module -Name $script:moduleName -ListAvailable)) + { + # Redirect all streams to $null, except the error stream (stream 2) + & "$PSScriptRoot/../../build.ps1" -Tasks 'noop' 2>&1 4>&1 5>&1 6>&1 > $null + } + + # Re-import the module using force to get any code changes between runs. + Import-Module -Name $script:moduleName -Force -ErrorAction 'Stop' + + $PSDefaultParameterValues['InModuleScope:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Mock:ModuleName'] = $script:moduleName + $PSDefaultParameterValues['Should:ModuleName'] = $script:moduleName +} + +AfterAll { + $PSDefaultParameterValues.Remove('Mock:ModuleName') + $PSDefaultParameterValues.Remove('InModuleScope:ModuleName') + $PSDefaultParameterValues.Remove('Should:ModuleName') + + Remove-Module -Name $script:moduleName +} + +Describe Get-Something { + + Context 'Return values' { + BeforeEach { + $return = Get-Something -Data 'value' + } + + It 'Returns a single object' { + ($return | Measure-Object).Count | Should -Be 1 + } + + } + + Context 'Pipeline' { + It 'Accepts values from the pipeline by value' { + $return = 'value1', 'value2' | Get-Something + + $return[0] | Should -Be 'value1' + $return[1] | Should -Be 'value2' + } + + It 'Accepts value from the pipeline by property name' { + $return = 'value1', 'value2' | ForEach-Object { + [PSCustomObject]@{ + Data = $_ + OtherProperty = 'other' + } + } | Get-Something + + + $return[0] | Should -Be 'value1' + $return[1] | Should -Be 'value2' + } + } + + Context 'ShouldProcess' { + It 'Supports WhatIf' { + (Get-Command Get-Something).Parameters.ContainsKey('WhatIf') | Should -Be $true + { Get-Something -Data 'value' -WhatIf } | Should -Not -Throw + } + + + } +} +