modules/Legacy/Azure.Implementation.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Creates or updates the Microsoft Entra ID application used by the SailPoint Identity Security Cloud Microsoft Entra ID connector (SaaS "Microsoft Entra" and VA "Azure Active Directory"). .DESCRIPTION Registers an Entra ID application, assigns the Microsoft Graph application permissions documented by SailPoint, grants admin consent, assigns the directory roles the connector needs for Set Password and Delete User, and issues a client secret. All permissions from SailPoint's required-permissions table are granted by default. Optional feature packs (access packages, MFA management, CIEM, NHI discovery, Teams / SharePoint scanning, Copilot discovery, Agent 365, Defender hunting) are opt-in because they map to features that must be licensed or enabled. The Agent 365 pack also grants delegated permissions and runs an authorization-code flow, because the Microsoft Agent 365 catalog rejects application tokens and ISC needs a refresh token for it. Existing applications with the same display name are updated in place rather than duplicated. Reference: https://documentation.sailpoint.com/connectors/saas/msentraid/help/saas_connectivity/microsoft_entra_id/administrator_permission.html .PARAMETER TenantId Entra ID tenant ID (GUID) or verified domain. Prompts when omitted. .PARAMETER ApplicationName Display name of the app registration. Prompts when omitted. .PARAMETER PermissionMode Granular - the documented per-API required permission table (default, least privilege). Directory - the documented coarse alternative: Directory.Read.All + Directory.ReadWrite.All. .PARAMETER Feature Optional documented feature packs to add. See the README for the permissions in each pack. .PARAMETER DirectoryRole Directory role for the service principal. Default UserAdministrator, which SailPoint requires for Set Password and Delete User. .PARAMETER SecretDisplayName Display name of the client secret. Default: ISC. .PARAMETER SecretValidityMonths Client secret lifetime in months (1-24). Default: 24. .PARAMETER RotateSecret When updating an existing application, create a new client secret. .PARAMETER OutputDirectory Directory for the Connection Settings file. Default: ./wizard-output/entra-id-isc .PARAMETER Agent365RedirectUri Loopback redirect URI registered on the application and used by the -Feature Agent365 authorization-code flow. Default: http://localhost:8400/ .PARAMETER PowerPlatformEnvironmentUrl Dataverse org URL (https://org.crm.dynamics.com) to configure for Copilot Studio agent discovery. Omit it and the environments are discovered through the Global Discovery Service. .PARAMETER NonInteractive Fail instead of prompting when required values are missing. .EXAMPLE .\Entra ID.ps1 .EXAMPLE .\Entra ID.ps1 -ApplicationName 'SailPoint ISC Entra ID' -Feature AccessPackages,MfaManagement -NonInteractive .EXAMPLE .\Entra ID.ps1 -ApplicationName 'SailPoint ISC Entra ID' -Feature CopilotDiscovery,Agent365 -RotateSecret .NOTES Sign in as an account that can create applications, grant admin consent, and assign directory roles (Application Administrator + Privileged Role Administrator, or Global Administrator). The client secret is displayed once and cannot be retrieved afterwards. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter()] [string]$TenantId, [Parameter()] [string]$ApplicationName, [Parameter()] [ValidateSet('Granular', 'Directory')] [string]$PermissionMode = 'Granular', [Parameter()] [ValidateSet('AccessPackages', 'ActivityInsights', 'AdministrativeUnits', 'Agent365', 'Ciem', 'CopilotDiscovery', 'DefenderHunting', 'ExchangeOnline', 'MfaManagement', 'NhiDiscovery', 'ServicePrincipalProvisioning', 'SharePointScanning', 'TeamsMessaging', 'TeamsSecretScanning')] [string[]]$Feature, [Parameter()] [ValidateSet('None', 'UserAdministrator', 'PrivilegedAdmin', 'GlobalAdministrator')] [string]$DirectoryRole, [Parameter()] [string]$SecretDisplayName = 'ISC', [Parameter()] [ValidateRange(1, 24)] [int]$SecretValidityMonths = 24, [Parameter()] [switch]$RotateSecret, [Parameter()] [string]$OutputDirectory, [Parameter()] [string]$Agent365RedirectUri = 'http://localhost:8400/', [Parameter()] [string]$PowerPlatformEnvironmentUrl, [Parameter()] [switch]$NonInteractive, [Parameter()] [ValidateSet('ReadOnly', 'ReadWrite')] [string]$Access ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:ModuleRoot = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'modules' Import-Module (Join-Path $script:ModuleRoot 'ISC.OperatorConsole.psm1') -Force -WarningAction SilentlyContinue Import-IscModule -Path (Join-Path $script:ModuleRoot 'ISC.OperatorToolchain.psm1') -Force Import-IscModule -Path (Join-Path $script:ModuleRoot 'ISC.EntraConnector.psm1') -Force Import-IscModule -Path (Join-Path $script:ModuleRoot 'ISC.EntraCiemConnector.psm1') -Force Import-IscModule -Path (Join-Path $script:ModuleRoot 'ISC.EntraSourceSetup.psm1') -Force Initialize-OperatorConsole -NonInteractive:$NonInteractive Initialize-EntraSourceSetup -ModuleRoot $script:ModuleRoot -NonInteractive:$NonInteractive $catalog = Get-EntraConnectorCatalog # ----------------------------------------------------------------------------- # Console helpers # ----------------------------------------------------------------------------- function Write-Banner { Write-Host '' Write-Host ' SailPoint ISC - Microsoft Entra ID source connection setup' -ForegroundColor Cyan Write-Host ' Creates or updates the app registration used by the Entra ID connector.' -ForegroundColor DarkCyan Write-Host '' } # ----------------------------------------------------------------------------- # Main # ----------------------------------------------------------------------------- try { Write-Banner Ensure-MicrosoftGraphModules $session = $null $connectRequest = $null $targetAppObjectId = $null $createSecret = $true # Whether each value still has to be asked for is decided once, from what the caller passed. # Testing the variables instead would stop a step from asking again when Esc returns to it. $askTenantId = -not $TenantId -and -not $NonInteractive $askApplicationName = -not $ApplicationName $askFeature = -not $PSBoundParameters.ContainsKey('Feature') $askDirectoryRole = -not $DirectoryRole $wizardComplete = $false while (-not $wizardComplete) { Start-WizardPass try { if (Enter-WizardPrompt) { if ($askTenantId) { $TenantId = Read-InputString -Prompt 'Entra ID tenant ID or domain (blank = home tenant from sign-in)' -Default $TenantId } Complete-WizardPrompt } if (-not $session -or $TenantId -ne $connectRequest) { $session = Connect-EntraGraph -RequestedTenantId $TenantId $connectRequest = $TenantId $TenantId = $session.TenantId } if (Enter-WizardPrompt) { if ($askApplicationName) { $ApplicationName = Read-InputString -Prompt 'Application display name' -Default $(if ($ApplicationName) { $ApplicationName } else { 'SailPoint ISC Entra ID' }) -Required } Complete-WizardPrompt } # @() is required on every call site below: PowerShell unrolls an array returned from a # function, so a zero-result lookup arrives as $null and $null.Count throws under StrictMode. $existingApps = @(Find-EntraApplicationByName -DisplayName $ApplicationName) if (Enter-WizardPrompt) { $targetAppObjectId = $null if ($existingApps.Count -eq 1) { Write-Step "An application named '$ApplicationName' already exists" Write-Info "Client ID: $($existingApps[0].AppId)" if (Read-YesNo -Prompt 'Update it instead of creating a new one?' -Default $true) { $targetAppObjectId = $existingApps[0].Id } else { $ApplicationName = Read-InputString -Prompt 'New application display name' -Required } } elseif ($existingApps.Count -gt 1) { Write-Step "Multiple applications are named '$ApplicationName'" $options = @('CreateNew') + @($existingApps.Id) $labels = @('Create a new application with a different name') + @($existingApps | ForEach-Object { "Update $($_.DisplayName) ($($_.AppId))" }) $picked = Read-Choice -Prompt 'Choose an application:' -Options $options -Labels $labels -Default $existingApps[0].Id if ($picked -eq 'CreateNew') { $ApplicationName = Read-InputString -Prompt 'New application display name' -Required } else { $targetAppObjectId = $picked } } Complete-WizardPrompt } $isUpdate = [bool]$targetAppObjectId if (Enter-WizardPrompt) { if ($askFeature) { $packNames = @($catalog.FeaturePacks.Keys) $packLabels = @($packNames | ForEach-Object { $catalog.FeaturePacks[$_].Label }) Write-Step 'Permissions' Write-Info "All $($catalog.CorePermissions.Count) required permissions from SailPoint's permission table are always granted." $Feature = @(Read-MultiChoice -Prompt 'Optional feature permissions to add:' -Options $packNames -Labels $packLabels) } Complete-WizardPrompt } if (Enter-WizardPrompt) { if ($askDirectoryRole) { $DirectoryRole = Read-Choice -Prompt 'Directory role for the service principal:' -Options @( 'UserAdministrator', 'PrivilegedAdmin', 'None', 'GlobalAdministrator' ) -Labels @( 'User Administrator - required by SailPoint for Set Password and Delete User' 'User Administrator + Privileged Authentication Administrator - also manage users holding admin roles' 'None - Graph application permissions only (Set Password and Delete User will fail)' 'Global Administrator - excessive; only if your organization mandates it' ) -Default $(if ($DirectoryRole) { $DirectoryRole } else { 'UserAdministrator' }) } Complete-WizardPrompt } if (Enter-WizardPrompt) { if ($DirectoryRole -eq 'GlobalAdministrator') { Write-Warning 'Global Administrator on a connector service principal is high risk. SailPoint only requires User Administrator (plus Privileged Authentication Administrator for admin users).' if (-not (Read-YesNo -Prompt 'Continue with Global Administrator?' -Default $false)) { $DirectoryRole = 'PrivilegedAdmin' Write-Ok 'Using User Administrator + Privileged Authentication Administrator instead.' } } Complete-WizardPrompt } $needsAgent365 = Test-Agent365Selected -FeatureNames $Feature if (Enter-WizardPrompt) { $createSecret = $true if ($isUpdate) { $createSecret = [bool]$RotateSecret if (-not $NonInteractive -and -not $RotateSecret) { # The Agent 365 flow exchanges its authorization code with the client secret, # and Entra never shows an existing one again. if ($needsAgent365) { Write-Info 'Agent 365 needs the client secret to mint the refresh token. Answer No only if you still have the current one.' } $createSecret = Read-YesNo -Prompt 'Create a new client secret?' -Default $needsAgent365 } } Complete-WizardPrompt } if (Enter-WizardPrompt) { if (-not $OutputDirectory) { $OutputDirectory = Join-Path (Get-Location) (Join-Path 'wizard-output' 'entra-id-isc') } $OutputDirectory = Read-InputString -Prompt 'Output directory for connection settings' -Default $OutputDirectory -Required Complete-WizardPrompt } $permissions = @(Get-SelectedPermissions -Mode $PermissionMode -FeatureNames $Feature -Access $Access) $delegated = @(Get-SelectedDelegatedPermissions -FeatureNames $Feature -Access $Access) $roleNames = @($catalog.DirectoryRoleMap[$DirectoryRole]) $planAction = if ($isUpdate) { 'Update existing application' } else { 'Create application' } $planFeatures = if ($Feature) { $Feature -join ', ' } else { 'none' } $planRoles = if ($roleNames.Count -gt 0) { $roleNames -join ', ' } else { 'none' } $planSecret = if ($createSecret) { "$SecretDisplayName, $SecretValidityMonths months" } else { 'unchanged' } Write-Step 'Plan' Write-Host " Action : $planAction" Write-Host " Application : $ApplicationName" Write-Host " Tenant : $TenantId" Write-Host " Permission set : $PermissionMode ($($permissions.Count) permissions)" Write-Host " Feature packs : $planFeatures" Write-Host " Directory role : $planRoles" Write-Host " Client secret : $planSecret" if ($needsAgent365) { Write-Host " Agent 365 : refresh token via $Agent365RedirectUri" } if (Test-CopilotStudioSelected -FeatureNames $Feature) { $planEnvironment = if ($PowerPlatformEnvironmentUrl) { $PowerPlatformEnvironmentUrl } else { 'discovered environments' } Write-Host " Copilot Studio : Dataverse application user in $planEnvironment" } Write-Host '' foreach ($group in ($permissions | Group-Object Resource)) { Write-Info "$($catalog.Resources[$group.Name].Name): $(($group.Group.Value | Sort-Object) -join ', ')" } foreach ($group in ($delegated | Group-Object Resource)) { Write-Info "$($catalog.Resources[$group.Name].Name) (delegated): $(($group.Group.Value | Sort-Object) -join ', ')" } if (Enter-WizardPrompt) { if (-not (Read-YesNo -Prompt 'Proceed?' -Default $true)) { Write-Host 'Cancelled.' -ForegroundColor Yellow return } Complete-WizardPrompt } $wizardComplete = $true } catch { if (-not (Test-PromptBack $_)) { throw } if (-not (Move-WizardBack)) { Write-Host '' Write-Host 'Cancelled.' -ForegroundColor Yellow return } } } $action = if ($isUpdate) { "Update application '$ApplicationName'" } else { "Create application '$ApplicationName'" } if (-not $PSCmdlet.ShouldProcess($ApplicationName, $action)) { return } $resolved = [PSCustomObject]@{ TenantId = $TenantId ApplicationName = $ApplicationName PermissionMode = $PermissionMode Access = $Access Feature = @($Feature) DirectoryRole = $DirectoryRole SecretDisplayName = $SecretDisplayName SecretValidityMonths = $SecretValidityMonths RotateSecret = [bool]$RotateSecret OutputDirectory = $OutputDirectory CreateSecret = [bool]$createSecret Agent365RedirectUri = $Agent365RedirectUri PowerPlatformEnvironmentUrl = $PowerPlatformEnvironmentUrl # Secret references are an agent-path concept; the wizard asks the operator instead. ClientSecretRef = $null Agent365RefreshTokenRef = $null } $applyResult = Invoke-EntraSourceApply -Resolved $resolved -TargetAppObjectId $targetAppObjectId -CreateSecret $createSecret $runDir = Join-Path $OutputDirectory (Join-Path 'agent-runs' 'interactive') if (-not (Test-Path -LiteralPath $runDir)) { New-Item -ItemType Directory -Path $runDir -Force | Out-Null } $result = Build-EntraSourceResult -Resolved $resolved -ApplyResult $applyResult -RunDirectory $runDir $entraSavePath = Join-Path $OutputDirectory 'sailpoint-entra-connection-settings.txt' Invoke-CompletionActionMenu -Title 'Next: complete ISC Connection Settings' ` -Situation $result.situation ` -Items $result.completionItems ` -AllowSaveToDisk ` -SavePath $entraSavePath } catch { if (Test-CancelledNavigation $_) { Write-Host '' Write-Host 'Cancelled.' -ForegroundColor Yellow return } Write-Host '' if (Get-EntraCreatedAppId) { Write-Warning "Application $(Get-EntraCreatedAppId) was created before this failure. Re-run the script with the same display name to finish configuring it." } Write-Error $_ exit 1 } |