Public/New-PSUAzureServicePrincipal.ps1
|
function New-PSUAzureServicePrincipal { <# .SYNOPSIS Creates an Azure service principal with all permissions required for CIEM security checks. .DESCRIPTION Creates an Azure AD app registration and service principal configured with all permissions required to run Devolutions CIEM security checks. This includes: - Microsoft Graph API application permissions - Azure Resource Manager RBAC role assignment (Reader) - Key Vault data plane permissions (documented for manual configuration) The function uses Get-CIEMRequiredPermission to determine the exact permissions needed. .PARAMETER DisplayName The display name for the app registration and service principal. Default: "Devolutions-CIEM-Scanner" .PARAMETER Scope The scope for the ARM RBAC role assignment. Can be a subscription or management group. Examples: - "/subscriptions/<subscription-id>" - "/providers/Microsoft.Management/managementGroups/<mg-name>" .PARAMETER CredentialType The type of credential to create for authentication. - ClientSecret: Creates a client secret (default) - Certificate: Creates a self-signed certificate .PARAMETER CertificateValidityYears Number of years the certificate is valid. Only used when CredentialType is Certificate. Default: 1 .PARAMETER SkipRoleAssignment Skip the ARM RBAC role assignment. Useful if you want to assign at a different scope later. .PARAMETER SkipAdminConsent Skip granting admin consent for Graph API permissions. You will need to grant consent manually in the Azure Portal. .OUTPUTS [PSCustomObject] Object containing: - ApplicationId: The app registration client ID - ObjectId: The app registration object ID - ServicePrincipalId: The service principal object ID - TenantId: The Azure AD tenant ID - ClientSecret: The client secret (if CredentialType is ClientSecret) - CertificateThumbprint: The certificate thumbprint (if CredentialType is Certificate) - Permissions: The permissions configured .EXAMPLE New-PSUAzureServicePrincipal -Scope "/subscriptions/12345678-1234-1234-1234-123456789012" # Creates a service principal with client secret for the specified subscription .EXAMPLE New-PSUAzureServicePrincipal -DisplayName "CIEM-Prod" -CredentialType Certificate -Scope "/subscriptions/12345678-1234-1234-1234-123456789012" # Creates a service principal with certificate authentication .EXAMPLE New-PSUAzureServicePrincipal -Scope "/providers/Microsoft.Management/managementGroups/my-mg" -SkipAdminConsent # Creates a service principal at management group scope, skipping admin consent .NOTES Required modules: - Az.Accounts (for Connect-AzAccount) - Az.Resources (for app/SP/role cmdlets) - Microsoft.Graph.Applications (for Graph API permission grants, unless -SkipAdminConsent) Required Azure permissions: - Global Administrator or Privileged Role Administrator role (for admin consent) - Owner or User Access Administrator role on the target scope (for role assignment) #> [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Token is already in memory from Az context; conversion to SecureString required for MgGraph interop')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialType', Justification = 'CredentialType is an enum (ClientSecret/Certificate), not a password value')] [OutputType([PSCustomObject])] param( [Parameter()] [string]$DisplayName = "Devolutions-CIEM-Scanner", [Parameter(Mandatory)] [ValidatePattern('^/(subscriptions/[a-f0-9-]+|providers/Microsoft\.Management/managementGroups/.+)$')] [string]$Scope, [Parameter()] [ValidateSet('ClientSecret', 'Certificate')] [string]$CredentialType = 'ClientSecret', [Parameter()] [ValidateRange(1, 10)] [int]$CertificateValidityYears = 1, [Parameter()] [switch]$SkipRoleAssignment, [Parameter()] [switch]$SkipAdminConsent ) $ErrorActionPreference = 'Stop' #region Prerequisites Check Write-Verbose "Checking prerequisites..." # Check Azure connection $context = Get-AzContext if (-not $context) { throw "Not connected to Azure. Run Connect-AzAccount first." } $tenantId = $context.Tenant.Id Write-Verbose "Connected to tenant: $tenantId" #endregion #region Get Required Permissions Write-Verbose "Getting required permissions from CIEM checks..." $permissions = Get-CIEMRequiredPermission Write-Verbose "Found $($permissions.CheckCount) checks requiring permissions" Write-Verbose "Graph permissions: $($permissions.Graph.Count)" Write-Verbose "ARM permissions: $($permissions.ARM.Count)" Write-Verbose "Key Vault data plane permissions: $($permissions.KeyVaultDataPlane.Count)" #endregion #region Create App Registration if ($PSCmdlet.ShouldProcess($DisplayName, "Create Azure AD app registration")) { Write-Verbose "Creating app registration: $DisplayName" # Check if app already exists $existingApp = Get-AzADApplication -DisplayName $DisplayName -ErrorAction SilentlyContinue if ($existingApp) { throw "An app registration with name '$DisplayName' already exists (AppId: $($existingApp.AppId)). Choose a different name or remove the existing app." } # Create the app registration $appParams = @{ DisplayName = $DisplayName SignInAudience = 'AzureADMyOrg' } $app = New-AzADApplication @appParams Write-Verbose "Created app registration: $($app.AppId)" } #endregion #region Create Service Principal if ($PSCmdlet.ShouldProcess($DisplayName, "Create service principal")) { Write-Verbose "Creating service principal..." $sp = New-AzADServicePrincipal -ApplicationId $app.AppId Write-Verbose "Created service principal: $($sp.Id)" # Wait for replication Write-Verbose "Waiting for Azure AD replication..." Start-Sleep -Seconds 10 } #endregion #region Create Credential $credential = $null $certificateThumbprint = $null if ($CredentialType -eq 'ClientSecret') { if ($PSCmdlet.ShouldProcess($DisplayName, "Create client secret")) { Write-Verbose "Creating client secret..." $endDate = (Get-Date).AddYears(1) $secretCredential = New-AzADAppCredential -ApplicationId $app.AppId -EndDate $endDate $credential = $secretCredential.SecretText Write-Verbose "Created client secret (expires: $endDate)" } } else { if ($PSCmdlet.ShouldProcess($DisplayName, "Create self-signed certificate")) { Write-Verbose "Creating self-signed certificate..." $certName = "CN=$DisplayName" $endDate = (Get-Date).AddYears($CertificateValidityYears) # Create self-signed certificate $certParams = @{ Subject = $certName CertStoreLocation = "Cert:\CurrentUser\My" KeyExportPolicy = 'Exportable' KeySpec = 'Signature' KeyLength = 2048 KeyAlgorithm = 'RSA' HashAlgorithm = 'SHA256' NotAfter = $endDate } $cert = New-SelfSignedCertificate @certParams $certificateThumbprint = $cert.Thumbprint Write-Verbose "Created certificate with thumbprint: $certificateThumbprint" # Upload certificate to app registration $certBase64 = [System.Convert]::ToBase64String($cert.GetRawCertData()) New-AzADAppCredential -ApplicationId $app.AppId -CertValue $certBase64 -EndDate $endDate Write-Verbose "Uploaded certificate to app registration" } } #endregion #region Add Graph API Permissions if ($permissions.Graph.Count -gt 0) { if ($PSCmdlet.ShouldProcess($DisplayName, "Add Microsoft Graph API permissions")) { Write-Verbose "Adding Microsoft Graph API permissions..." # Microsoft Graph app ID (well-known) $graphAppId = "00000003-0000-0000-c000-000000000000" # Get Microsoft Graph service principal to look up permission IDs $graphSp = Get-AzADServicePrincipal -ApplicationId $graphAppId foreach ($permissionName in $permissions.Graph) { Write-Verbose " Adding permission: $permissionName" # Find the permission ID from the Graph service principal's app roles $appRole = $graphSp.AppRole | Where-Object { $_.Value -eq $permissionName } if (-not $appRole) { Write-Warning "Could not find Graph permission: $permissionName" continue } # Add the permission to the app registration $permParams = @{ ApplicationId = $app.AppId ApiId = $graphAppId PermissionId = $appRole.Id Type = 'Role' } Add-AzADAppPermission @permParams } Write-Verbose "Added $($permissions.Graph.Count) Graph API permissions" } } #endregion #region Grant Admin Consent if (-not $SkipAdminConsent -and $permissions.Graph.Count -gt 0) { if ($PSCmdlet.ShouldProcess($DisplayName, "Grant admin consent for Graph API permissions")) { Write-Verbose "Granting admin consent for Graph API permissions..." Write-Verbose "Getting access token from current Az context..." try { # Get Graph token from current Az context (admin user or SP with admin permissions) $graphToken = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com" -ErrorAction Stop # Handle both string and SecureString token formats (Az.Accounts version differences) # Note: ConvertTo-SecureString with -AsPlainText is required because the token # is already in memory from Az context - no way to avoid this for Az/MgGraph interop if ($graphToken.Token -is [System.Security.SecureString]) { $secureToken = $graphToken.Token } else { # Suppressing PSAvoidUsingConvertToSecureStringWithPlainText - token is already in memory $secureToken = $graphToken.Token | ConvertTo-SecureString -AsPlainText -Force } Connect-MgGraph -AccessToken $secureToken -NoWelcome -ErrorAction Stop # Microsoft Graph service principal $graphAppId = "00000003-0000-0000-c000-000000000000" $graphSp = Get-MgServicePrincipal -Filter "appId eq '$graphAppId'" # Get our service principal $ourSp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'" foreach ($permissionName in $permissions.Graph) { $appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $permissionName } if (-not $appRole) { Write-Warning "Could not find Graph permission for consent: $permissionName" continue } Write-Verbose " Granting consent for: $permissionName" try { $roleAssignmentParams = @{ ServicePrincipalId = $ourSp.Id PrincipalId = $ourSp.Id ResourceId = $graphSp.Id AppRoleId = $appRole.Id ErrorAction = 'Stop' } New-MgServicePrincipalAppRoleAssignment @roleAssignmentParams | Out-Null } catch { if ($_.Exception.Message -notlike "*already exists*") { Write-Warning "Failed to grant consent for $permissionName : $_" } } } Write-Verbose "Admin consent granted for Graph API permissions" Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch { Write-Warning "Failed to grant admin consent: $_" Write-Warning "Ensure you are logged in as an admin user (Connect-AzAccount) with permission to grant consent." Write-Warning "Or grant admin consent manually: Azure Portal > App registrations > $DisplayName > API permissions > Grant admin consent" } } } elseif ($permissions.Graph.Count -gt 0) { Write-Warning @" Admin consent was skipped. You must grant admin consent manually: 1. Go to Azure Portal > App registrations > $DisplayName > API permissions 2. Click 'Grant admin consent for <tenant>' Or run: Connect-MgGraph -Scopes 'Application.ReadWrite.All' and grant consent programmatically. "@ } #endregion #region Assign Azure RBAC Roles if (-not $SkipRoleAssignment -and $permissions.AzureRoles.Count -gt 0) { foreach ($roleName in $permissions.AzureRoles) { if ($PSCmdlet.ShouldProcess($Scope, "Assign $roleName role to service principal")) { Write-Verbose "Assigning $roleName role at scope: $Scope" $getAssignmentParams = @{ ObjectId = $sp.Id Scope = $Scope RoleDefinitionName = $roleName ErrorAction = 'SilentlyContinue' } $existingAssignment = Get-AzRoleAssignment @getAssignmentParams if ($existingAssignment) { Write-Verbose "$roleName role already assigned at this scope" } else { $newAssignmentParams = @{ ObjectId = $sp.Id Scope = $Scope RoleDefinitionName = $roleName } New-AzRoleAssignment @newAssignmentParams | Out-Null Write-Verbose "$roleName role assigned successfully" } } } # Note about Key Vault RBAC mode $kvRoles = $permissions.AzureRoles | Where-Object { $_ -like 'Key Vault*' } if ($kvRoles.Count -gt 0) { Write-Verbose "Key Vault RBAC roles assigned. Note: This only works for vaults using Azure RBAC authorization mode." } } elseif ($SkipRoleAssignment -and $permissions.AzureRoles.Count -gt 0) { Write-Warning @" Role assignment was skipped. You must assign these roles manually: $($permissions.AzureRoles | ForEach-Object { " New-AzRoleAssignment -ObjectId '$($sp.Id)' -Scope '<scope>' -RoleDefinitionName '$_'" } | Out-String) "@ } #endregion #region Store Credentials in PSU Secrets $secretsCreated = $false $inPSUContext = $null -ne (Get-PSDrive -Name 'Secret' -ErrorAction SilentlyContinue) if ($inPSUContext) { if ($PSCmdlet.ShouldProcess("PSU Secrets", "Create CIEM_Azure credentials")) { Write-Verbose "PSU context detected, creating secrets..." try { # Create secrets in Database vault New-PSUVariable -Name 'CIEM_Azure_TenantId' -Value $tenantId -Vault 'Database' -ErrorAction Stop New-PSUVariable -Name 'CIEM_Azure_ClientId' -Value $app.AppId -Vault 'Database' -ErrorAction Stop if ($CredentialType -eq 'ClientSecret') { New-PSUVariable -Name 'CIEM_Azure_ClientSecret' -Value $credential -Vault 'Database' -ErrorAction Stop } else { New-PSUVariable -Name 'CIEM_Azure_CertThumbprint' -Value $certificateThumbprint -Vault 'Database' -ErrorAction Stop } $secretsCreated = $true Write-Verbose "PSU secrets created successfully" Write-Information "`nSecrets stored in PSU Database vault:" -InformationAction Continue Write-Information " - CIEM_Azure_TenantId" -InformationAction Continue Write-Information " - CIEM_Azure_ClientId" -InformationAction Continue if ($CredentialType -eq 'ClientSecret') { Write-Information " - CIEM_Azure_ClientSecret" -InformationAction Continue } else { Write-Information " - CIEM_Azure_CertThumbprint" -InformationAction Continue } } catch { Write-Warning "Failed to create PSU secrets: $_" Write-Warning "You may need to create secrets manually via PSU Admin UI." } } } else { # Write TenantId and ClientId to CIEM config (PSU cache) $configSettings = @{ 'azure.authentication.tenantId' = $tenantId 'azure.authentication.servicePrincipal.clientId' = $app.AppId } if ($PSCmdlet.ShouldProcess('CIEM config', "Update with TenantId and ClientId")) { Write-Verbose "Writing TenantId and ClientId to CIEM config..." Set-CIEMConfig -Settings $configSettings $secretsCreated = $true Write-Information "`nTenantId and ClientId written to CIEM config" -InformationAction Continue } Write-Warning "`nFor PSU deployment, create this secret in PSU Admin UI:" Write-Verbose " Platform > Variables > Create Secret Variable (Database vault)" if ($CredentialType -eq 'ClientSecret') { Write-Information " - CIEM_Azure_ClientSecret = $credential" -InformationAction Continue } else { Write-Information " - CIEM_Azure_CertThumbprint = $certificateThumbprint" -InformationAction Continue } } #endregion #region Build Output $output = [PSCustomObject]@{ DisplayName = $DisplayName ApplicationId = $app.AppId ObjectId = $app.Id ServicePrincipalId = $sp.Id TenantId = $tenantId ClientSecret = $credential CertificateThumbprint = $certificateThumbprint Scope = $Scope SecretsCreated = $secretsCreated Permissions = [PSCustomObject]@{ Graph = $permissions.Graph ARM = $permissions.ARM KeyVaultDataPlane = $permissions.KeyVaultDataPlane AzureRoles = $permissions.AzureRoles CheckCount = $permissions.CheckCount } PSUSecrets = @" # Credentials are stored as PSU secrets (Database vault): # - CIEM_Azure_TenantId # - CIEM_Azure_ClientId # - CIEM_Azure_ClientSecret (or CIEM_Azure_CertThumbprint) # # Connect-CIEM will automatically use these secrets when running in PSU. # For local development, credentials use in-memory defaults. "@ } Write-Information "`nService principal created successfully!" -InformationAction Continue Write-Information "Application (client) ID: $($app.AppId)" -InformationAction Continue Write-Information "Tenant ID: $tenantId" -InformationAction Continue if ($secretsCreated) { Write-Information "`nCredentials stored in PSU secrets - ready to use with Connect-CIEM" -InformationAction Continue } elseif ($credential) { Write-Warning "`nIMPORTANT: Save the client secret now - it cannot be retrieved later!" Write-Information "Client Secret: $credential" -InformationAction Continue } if ($certificateThumbprint) { Write-Information "Certificate Thumbprint: $certificateThumbprint" -InformationAction Continue Write-Information "Certificate Location: Cert:\CurrentUser\My\$certificateThumbprint" -InformationAction Continue } $output #endregion } |