Setup/New-TenantReportsAppRegistration.ps1
|
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement <# .SYNOPSIS Creates an Azure App Registration for Microsoft 365/Azure security reporting. .DESCRIPTION This script creates an Azure App Registration specifically designed for generating periodic security reports for Microsoft 365/Azure tenants. It configures both Microsoft Graph and Office 365 Exchange Online Application permissions and grants admin consent automatically. The script supports: - Creating new app registrations or updating existing ones - Automatic admin consent for all configured API permissions - Optional client secret generation with configurable expiration - Optional Azure AD directory role assignment for Exchange Online management - Intelligent connection handling (reuses existing sessions when possible) - Idempotent permission grants (safe to re-run) Configured Microsoft Graph permissions include: - Directory, User, Group, and Organization read access - Audit logs and security events - Identity risk and protection data - Intune device management (read-only) - Mail and calendar access (read-only) - Reports and policies Configured Exchange Online permissions include: - Exchange.ManageAsApp for application-based management - Mailbox settings and calendar read access .PARAMETER ApplicationName The display name for the Azure App Registration. Default: "TenantReports" .PARAMETER TenantId The Azure AD Tenant ID where the app registration will be created. This parameter is mandatory. .PARAMETER CreateClientSecret Switch parameter to create a client secret for service principal authentication. If specified, a client secret will be generated and included in the output. IMPORTANT: The secret value is only displayed once and cannot be retrieved later. .PARAMETER ClientSecretDescription Description for the client secret, visible in the Azure portal. Default: "TenantReports Client Secret" .PARAMETER ClientSecretExpirationMonths Number of months until the client secret expires. Valid range: 1-24 months. Default: 24 months .PARAMETER AssignDirectoryRoles Switch parameter to assign Azure AD directory roles to the service principal. Required for Exchange Online management operations. .PARAMETER DirectoryRoles Array of directory role names to assign when -AssignDirectoryRoles is specified. Default: @('Exchange Administrator') Common roles for security reporting: - 'Exchange Administrator' - Required for Exchange Online management - 'Security Administrator' - Access to security-related features - 'Security Reader' - Read-only access to security features - 'Global Reader' - Read-only access to all administrative features .EXAMPLE .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" Creates an app registration named "TenantReports" with default permissions. No client secret is created. .EXAMPLE .\New-TenantReportsAppRegistration.ps1 -ApplicationName "Contoso-SecurityReports" -TenantId "12345678-1234-1234-1234-123456789012" -CreateClientSecret Creates an app registration with a custom name and generates a client secret that expires in 24 months (default). .EXAMPLE .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -CreateClientSecret -ClientSecretExpirationMonths 12 Creates an app registration with a client secret that expires in 12 months. .EXAMPLE .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -AssignDirectoryRoles Creates an app registration and assigns the default 'Exchange Administrator' role to the service principal. .EXAMPLE .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -AssignDirectoryRoles -DirectoryRoles @('Exchange Administrator', 'Security Reader') Creates an app registration and assigns multiple directory roles. .EXAMPLE $result = .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -CreateClientSecret $result | Export-Csv -Path ".\AppRegistration.csv" -NoTypeInformation Creates an app registration and exports the results to a CSV file for documentation. .EXAMPLE .\New-TenantReportsAppRegistration.ps1 -TenantId "12345678-1234-1234-1234-123456789012" -WhatIf Shows what actions would be performed without making any changes. .INPUTS None. This script does not accept pipeline input. .OUTPUTS PSCustomObject Returns an object containing: - ApplicationName: Display name of the app registration - ApplicationId: The Application (client) ID - ObjectId: The Object ID of the app registration - TenantId: The Azure AD tenant ID - ServicePrincipal: The Service Principal Object ID - ClientSecret: The client secret value (if created) - SecretExpires: Client secret expiration date (if created) - GraphPermissions: Count of granted/total Graph permissions - ExoPermissions: Count of granted/total Exchange Online permissions - DirectoryRoles: Comma-separated list of assigned directory roles .NOTES Author: Tom de Leeuw Website: https://systom.dev Prerequisites: - Microsoft Graph PowerShell SDK modules: - Microsoft.Graph.Authentication - Microsoft.Graph.Applications - Microsoft.Graph.Identity.DirectoryManagement Required Permissions (for the user running this script): - Application.ReadWrite.All - AppRoleAssignment.ReadWrite.All - RoleManagement.ReadWrite.Directory (if using -AssignDirectoryRoles) The script automatically handles: - Connection to Microsoft Graph (or reuses existing connection) - Service principal creation for the app registration - Admin consent for all API permissions - Directory role activation if not already active .LINK https://systom.dev .LINK https://learn.microsoft.com/en-us/graph/permissions-reference .LINK https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(Position = 0, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('Name', 'DisplayName')] [string]$ApplicationName = 'TenantReports', [Parameter(Mandatory = $true, Position = 1, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] [Alias('Tenant')] [string]$TenantId, [Parameter(ValueFromPipelineByPropertyName = $true)] [switch]$CreateClientSecret, [Parameter(ValueFromPipelineByPropertyName = $true)] [string]$ClientSecretDescription = 'TenantReports Client Secret', [Parameter(ValueFromPipelineByPropertyName = $true)] [ValidateRange(1, 24)] [int]$ClientSecretExpirationMonths = 24, [Parameter(ValueFromPipelineByPropertyName = $true)] [switch]$AssignDirectoryRoles, [Parameter(ValueFromPipelineByPropertyName = $true)] [string[]]$DirectoryRoles = @('Exchange Administrator') ) begin { Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $GraphAppId = '00000003-0000-0000-c000-000000000000' $ExoAppId = '00000002-0000-0ff1-ce00-000000000000' $Permissions = @{ $GraphAppId = @( '7ab1d382-f21e-4acd-a863-ba3e13f7da61' # Directory.Read.All 'df021288-bdef-4463-88db-98f22de89214' # User.Read.All 'b0afded3-3588-46d8-8b3d-9842eff778da' # AuditLog.Read.All '19dbc75e-c2e2-444c-a770-ec69d8559fc7' # DeviceManagementConfiguration.Read.All '5b567255-7703-4780-807c-7be8301ae99b' # Group.Read.All '483bed4a-2ad3-4361-a73b-c83ccdbdc53c' # RoleManagement.Read.All '6e472fd1-ad78-48da-a0f0-97ab2c6b769e' # IdentityRiskEvent.Read.All '607c7344-0eed-41e5-823a-9695ebe1b7b0' # IdentityRiskyServicePrincipal.Read.All 'dc5007c0-2d7d-4c42-879c-2dab87571379' # IdentityRiskyUser.Read.All 'bf394140-e372-4bf9-a898-299cfc7564e5' # SecurityEvents.Read.All '246dd0d5-5bd0-4def-940b-0421030a5b68' # Policy.Read.All '197ee4e9-b993-4066-898f-d6aecc55125b' # ThreatIndicators.Read.All 'f8f035bb-2cce-47fb-8bf5-7baf3ecbee48' # ThreatAssessment.Read.All 'd72bdbf4-a59b-405c-8b04-5995895819ac' # ThreatSubmission.ReadWrite.All '5e0edab9-c148-49d0-b423-ac253e121825' # SecurityActions.Read.All '810c84a8-4a9e-49e6-bf7d-12d183f40d01' # Mail.Read '2f51be20-0bb4-4fed-bf7b-db946066c75e' # DeviceManagementManagedDevices.Read.All '45cc0394-e837-488b-a098-1918f48d186c' # SecurityIncident.Read.All '38d9df27-64da-44fd-b7c5-a6fbac20248f' # UserAuthenticationMethod.Read.All '06a5fe6d-c49d-46a7-b082-56b1b14103c7' # DeviceManagementServiceConfig.Read.All '8ba4a692-bc31-4128-9094-475872af8a53' # Calendars.ReadBasic.All '230c1aed-a721-4c5d-9cb4-a90514e508ef' # Reports.Read.All '498476ce-e0fe-48b0-b801-37ba7e2685c6' # Organization.Read.All ) $ExoAppId = @( 'dc50a0fb-09a3-484d-be87-e023b12c6440' # Exchange.ManageAsApp '2dfdc6dc-2fa7-4a2c-a922-dbd4f85d17be' # Calendars.Read 'd45fa9f8-36e5-4cd2-b601-b063c7cf9ac2' # MailboxSettings.Read 'bf24470f-10c1-436d-8d53-7b997eb473be' # User.Read.All '15f260d6-f874-4366-8672-6b3658c5a09b' # Organization.Read.All ) } } process { #region Connection $Context = Get-MgContext -ErrorAction SilentlyContinue if (-not $Context -or $Context.TenantId -ne $TenantId) { Write-Host '[*] Connecting to tenant: ' -ForegroundColor Cyan -NoNewline Write-Host $TenantId -ForegroundColor White Connect-MgGraph -TenantId $TenantId -Scopes 'Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All', 'RoleManagement.ReadWrite.Directory' -NoWelcome } else { Write-Host '[*] Using existing connection: ' -ForegroundColor Cyan -NoNewline Write-Host $TenantId -ForegroundColor White } #endregion #region App Registration $App = Get-MgApplication -Filter "displayName eq '$ApplicationName'" -ErrorAction SilentlyContinue if ($App) { Write-Host "[!] App '$ApplicationName' exists - updating permissions" -ForegroundColor Yellow $RequiredAccess = foreach ($AppId in $Permissions.Keys) { @{ ResourceAppId = $AppId ResourceAccess = $Permissions[$AppId] | ForEach-Object { @{ Id = $_; Type = 'Role' } } } } Update-MgApplication -ApplicationId $App.Id -RequiredResourceAccess $RequiredAccess } elseif ($PSCmdlet.ShouldProcess($ApplicationName, 'Create App Registration')) { Write-Host '[*] Creating app registration: ' -ForegroundColor Cyan -NoNewline Write-Host $ApplicationName -ForegroundColor White $RequiredAccess = foreach ($AppId in $Permissions.Keys) { @{ ResourceAppId = $AppId ResourceAccess = $Permissions[$AppId] | ForEach-Object { @{ Id = $_; Type = 'Role' } } } } $App = New-MgApplication -DisplayName $ApplicationName -SignInAudience 'AzureADMyOrg' -RequiredResourceAccess $RequiredAccess } #endregion #region Service Principal $Sp = Get-MgServicePrincipal -Filter "appId eq '$($App.AppId)'" -ErrorAction SilentlyContinue if (-not $Sp) { Write-Host '[*] Creating service principal...' -ForegroundColor Cyan $Sp = New-MgServicePrincipal -AppId $App.AppId Start-Sleep -Seconds 10 } #endregion #region Client Secret $Secret = $null if ($CreateClientSecret -and $PSCmdlet.ShouldProcess('Client Secret', 'Create')) { Write-Host '[*] Creating client secret...' -ForegroundColor Cyan $Secret = Add-MgApplicationPassword -ApplicationId $App.Id -PasswordCredential @{ DisplayName = $ClientSecretDescription EndDateTime = (Get-Date).AddMonths($ClientSecretExpirationMonths) } } #endregion #region Grant Permissions $GrantResults = @{ Graph = @{ Granted = 0; Failed = 0 } Exchange = @{ Granted = 0; Failed = 0 } } if ($PSCmdlet.ShouldProcess('API Permissions', 'Grant Admin Consent')) { Write-Host '[*] Granting admin consent...' -ForegroundColor Cyan foreach ($ResourceAppId in $Permissions.Keys) { $ResourceSp = Get-MgServicePrincipal -Filter "appId eq '$ResourceAppId'" -ErrorAction SilentlyContinue if (-not $ResourceSp) { continue } $KeyName = if ($ResourceAppId -eq $GraphAppId) { 'Graph' } else { 'Exchange' } $Existing = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $Sp.Id -All -ErrorAction SilentlyContinue foreach ($RoleId in $Permissions[$ResourceAppId]) { if ($Existing | Where-Object { $_.AppRoleId -eq $RoleId -and $_.ResourceId -eq $ResourceSp.Id }) { $GrantResults[$KeyName].Granted++ continue } try { $null = New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $Sp.Id -PrincipalId $Sp.Id -ResourceId $ResourceSp.Id -AppRoleId $RoleId -ErrorAction Stop $GrantResults[$KeyName].Granted++ } catch { $GrantResults[$KeyName].Failed++ Write-Verbose "Failed to grant $RoleId : $_" } } } Write-Host '[+] Graph: ' -ForegroundColor Green -NoNewline Write-Host "$($GrantResults.Graph.Granted) granted, $($GrantResults.Graph.Failed) failed" -ForegroundColor White Write-Host '[+] Exchange: ' -ForegroundColor Green -NoNewline Write-Host "$($GrantResults.Exchange.Granted) granted, $($GrantResults.Exchange.Failed) failed" -ForegroundColor White } #endregion #region Directory Roles $RoleResults = @{ Assigned = @(); Failed = @() } if ($AssignDirectoryRoles -and $PSCmdlet.ShouldProcess('Directory Roles', 'Assign')) { Write-Host '[*] Assigning directory roles...' -ForegroundColor Cyan foreach ($RoleName in $DirectoryRoles) { try { $Role = Get-MgDirectoryRole -Filter "displayName eq '$RoleName'" -ErrorAction SilentlyContinue if (-not $Role) { $Template = Get-MgDirectoryRoleTemplate -Filter "displayName eq '$RoleName'" if (-not $Template) { throw "Directory Role Template '$RoleName' not found." } $Role = New-MgDirectoryRole -RoleTemplateId $Template.Id Start-Sleep -Seconds 5 } # Fail-Soft Strategy: Attempt assignment and handle "Already Exists" error # This is more robust than checking membership which can fail due to API paging or object property mapping. try { $null = New-MgDirectoryRoleMemberByRef -DirectoryRoleId $Role.Id -BodyParameter @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($Sp.Id)" } -ErrorAction Stop Write-Host "[+] $RoleName : " -ForegroundColor Green -NoNewline Write-Host 'Assigned' -ForegroundColor White $RoleResults.Assigned += $RoleName } catch { # 400 BadRequest with "already exist" is a success state in an idempotent script if ($_.Exception.Message -like "*already exist*" -or $_.Exception.Message -like "*400*") { Write-Host "[+] $RoleName : " -ForegroundColor Green -NoNewline Write-Host 'Already assigned' -ForegroundColor White $RoleResults.Assigned += $RoleName } else { # Re-throw genuine errors (Access Denied, etc.) throw $_ } } } catch { Write-Host "[-] $RoleName : " -ForegroundColor Red -NoNewline Write-Host "Failed - $($_.Exception.Message)" -ForegroundColor White $RoleResults.Failed += $RoleName } } } #endregion #region Output [PSCustomObject]@{ ApplicationName = $App.DisplayName ApplicationId = $App.AppId ObjectId = $App.Id TenantId = $TenantId ServicePrincipal = $Sp.Id ClientSecret = if ($Secret) { $Secret.SecretText } else { $null } SecretExpires = if ($Secret) { $Secret.EndDateTime } else { $null } GraphPermissions = "$($GrantResults.Graph.Granted)/$($Permissions[$GraphAppId].Count)" ExoPermissions = "$($GrantResults.Exchange.Granted)/$($Permissions[$ExoAppId].Count)" DirectoryRoles = if ($RoleResults.Assigned) { $RoleResults.Assigned -join ', ' } else { $null } } if ($Secret) { Write-Host "`n[!] SAVE YOUR SECRET NOW - it won't be shown again!" -ForegroundColor Yellow -BackgroundColor DarkRed } #endregion } end { Disconnect-MgGraph } |