Public/Connect-AzLocalServicePrincipal.ps1
|
function Connect-AzLocalServicePrincipal { <# .SYNOPSIS Authenticates to Azure using a Service Principal or Managed Identity for CI/CD automation. .DESCRIPTION Logs into Azure CLI using Service Principal credentials or Managed Identity (MSI), enabling automated operations in GitHub Actions, Azure DevOps Pipelines, or other CI/CD systems. Authentication methods: 1. Managed Identity (-UseManagedIdentity): For Azure-hosted runners/agents with assigned identity 2. Service Principal (default): Using credentials from parameters or environment variables Service Principal credentials can be provided via: - Parameters: -ServicePrincipalId, -ServicePrincipalSecret, -TenantId - Environment variables: AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID If already authenticated (interactively or via SP/MSI), this function will skip login unless -Force is specified. .PARAMETER UseManagedIdentity Use Managed Identity (MSI) authentication instead of Service Principal. This is useful for Azure-hosted runners, VMs, or Azure Container Instances that have a system-assigned or user-assigned managed identity. .PARAMETER ManagedIdentityClientId Optional. The client ID of a user-assigned managed identity to use. If not specified, the system-assigned managed identity will be used. .PARAMETER ServicePrincipalId The Application (client) ID of the Service Principal. Can also be set via AZURE_CLIENT_ID environment variable. Not used when -UseManagedIdentity is specified. .PARAMETER ServicePrincipalSecret The client secret for the Service Principal. Can also be set via AZURE_CLIENT_SECRET environment variable. For security, prefer Managed Identity (-UseManagedIdentity), OIDC/federated credentials in CI/CD, a [SecureString], or the AZURE_CLIENT_SECRET environment variable. Accepts both [string] (plaintext, logs a security warning) and [SecureString]. Plaintext passing via command line is discouraged because the value lives in the caller's process memory; once received, this cmdlet hands the secret to `az login` via a temp file (the CLI's `--password @<file>` argument-file syntax, owner-only ACL, zero-overwrite + delete in finally) so the secret never appears in the child process command line. Not used when -UseManagedIdentity is specified. .PARAMETER TenantId The Azure AD tenant ID. Can also be set via AZURE_TENANT_ID environment variable. Not used when -UseManagedIdentity is specified. .PARAMETER Force Force re-authentication even if already logged in. .OUTPUTS Returns $true if authentication succeeded, $false otherwise. .EXAMPLE # Using Managed Identity (system-assigned) - recommended for Azure-hosted agents Connect-AzLocalServicePrincipal -UseManagedIdentity .EXAMPLE # Using Managed Identity (user-assigned) with specific client ID Connect-AzLocalServicePrincipal -UseManagedIdentity -ManagedIdentityClientId "12345678-1234-1234-1234-123456789012" .EXAMPLE # Using Service Principal with SecureString (preferred when not using env vars) $secret = Read-Host -AsSecureString -Prompt 'Service Principal Secret' Connect-AzLocalServicePrincipal -ServicePrincipalId $appId -ServicePrincipalSecret $secret -TenantId $tenant .EXAMPLE # Using environment variables (recommended for CI/CD with Service Principal) $env:AZURE_CLIENT_ID = 'your-app-id' $env:AZURE_CLIENT_SECRET = 'your-secret' $env:AZURE_TENANT_ID = 'your-tenant-id' Connect-AzLocalServicePrincipal .EXAMPLE # GitHub Actions workflow - credentials from secrets # env: # AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} # AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} # AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} Connect-AzLocalServicePrincipal .NOTES The Service Principal or Managed Identity requires the following permissions: - Microsoft.AzureStackHCI/clusters/read - Microsoft.AzureStackHCI/clusters/updates/read - Microsoft.AzureStackHCI/clusters/updates/apply/action - Microsoft.AzureStackHCI/clusters/updateSummaries/read - Microsoft.AzureStackHCI/clusters/updates/updateRuns/read - Microsoft.Resources/subscriptions/resources/read (for Azure Resource Graph queries) - Tag Contributor role (for Set-AzLocalClusterUpdateRingTag) #> [CmdletBinding(DefaultParameterSetName = 'ServicePrincipal')] [OutputType([bool])] param( [Parameter(Mandatory = $true, ParameterSetName = 'ManagedIdentity')] [switch]$UseManagedIdentity, [Parameter(Mandatory = $false, ParameterSetName = 'ManagedIdentity')] [string]$ManagedIdentityClientId, [Parameter(Mandatory = $false, ParameterSetName = 'ServicePrincipal')] [string]$ServicePrincipalId, [Parameter(Mandatory = $false, ParameterSetName = 'ServicePrincipal')] # Accept either [string] (plaintext - backward compatible, warns) or [SecureString]. [object]$ServicePrincipalSecret, [Parameter(Mandatory = $false, ParameterSetName = 'ServicePrincipal')] [string]$TenantId, [Parameter(Mandatory = $false)] [switch]$Force ) # Check for existing authentication unless Force is specified if (-not $Force) { try { $accountInfo = az account show 2>$null | ConvertFrom-Json if ($LASTEXITCODE -eq 0 -and $accountInfo) { Write-Verbose "Already authenticated as: $($accountInfo.user.name) (Type: $($accountInfo.user.type))" return $true } } catch { # Not authenticated, continue with login - this is expected behavior Write-Verbose "No existing Azure CLI session, proceeding with authentication" } } # Managed Identity authentication if ($UseManagedIdentity) { Write-Log -Message "Authenticating with Managed Identity..." -Level Warning try { if ($ManagedIdentityClientId) { # User-assigned managed identity Write-Log -Message "Using user-assigned managed identity: $ManagedIdentityClientId" -Level Verbose $loginResult = az login --identity --username $ManagedIdentityClientId --output none 2>&1 } else { # System-assigned managed identity Write-Log -Message "Using system-assigned managed identity" -Level Verbose $loginResult = az login --identity --output none 2>&1 } if ($LASTEXITCODE -ne 0) { Write-Error "Managed Identity authentication failed: $loginResult" Write-Error "Ensure this environment has a managed identity assigned and it has the required permissions." return $false } # Verify authentication $accountInfo = az account show 2>$null | ConvertFrom-Json if ($LASTEXITCODE -eq 0 -and $accountInfo) { Write-Log -Message "Successfully authenticated with Managed Identity" -Level Success Write-Log -Message "Subscription: $($accountInfo.name) ($($accountInfo.id))" -Level Verbose $script:ManagedIdentityAuthenticated = $true return $true } else { Write-Error "Authentication succeeded but account verification failed." return $false } } catch { Write-Error "Managed Identity authentication error: $($_.Exception.Message)" return $false } } # Service Principal authentication (default) # Get credentials from parameters or environment variables $clientId = if ($ServicePrincipalId) { $ServicePrincipalId } else { $env:AZURE_CLIENT_ID } # Resolve secret: [SecureString] preferred, [string] accepted for backward compat (with warning) $clientSecretPlain = $null $secretBstr = [IntPtr]::Zero try { if ($ServicePrincipalSecret -is [System.Security.SecureString]) { $secretBstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServicePrincipalSecret) $clientSecretPlain = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($secretBstr) } elseif ($ServicePrincipalSecret -is [string] -and $ServicePrincipalSecret) { Write-Log -Message "SECURITY: -ServicePrincipalSecret was supplied as plaintext [string]. The value is now in this process's memory; the secret is passed to 'az' via a temp file (not the command line). For stronger isolation prefer -UseManagedIdentity, OIDC/federated credentials in CI/CD, a [SecureString], or the AZURE_CLIENT_SECRET environment variable." -Level Warning $clientSecretPlain = $ServicePrincipalSecret } elseif ($null -ne $ServicePrincipalSecret) { throw "-ServicePrincipalSecret must be a [string] or [SecureString]. Got: $($ServicePrincipalSecret.GetType().FullName)" } else { $clientSecretPlain = $env:AZURE_CLIENT_SECRET } $tenant = if ($TenantId) { $TenantId } else { $env:AZURE_TENANT_ID } # Validate required credentials if (-not $clientId) { throw "Service Principal ID not provided. Set -ServicePrincipalId parameter or AZURE_CLIENT_ID environment variable." } if (-not $clientSecretPlain) { throw "Service Principal Secret not provided. Set -ServicePrincipalSecret parameter or AZURE_CLIENT_SECRET environment variable." } if (-not $tenant) { throw "Tenant ID not provided. Set -TenantId parameter or AZURE_TENANT_ID environment variable." } Write-Log -Message "Authenticating with Service Principal..." -Level Warning # Security (v0.7.76, Finding 3): pass the secret to `az` via a temp # file using the CLI's documented `@<file>` argument-file syntax # (https://learn.microsoft.com/cli/azure/use-cli-effectively#use-file-input-for-cli-parameters) # instead of inlining the secret as `--password $plain`. The latter # makes the secret visible to anyone who can enumerate processes on # the host (tasklist /v, ps -ef, EDR command-line capture). # The temp file is created with owner-only ACL where possible, # overwritten with zero bytes, then deleted in finally. $secretFile = $null try { $secretFile = [IO.Path]::Combine([IO.Path]::GetTempPath(), [IO.Path]::GetRandomFileName()) # Create empty file first, then tighten ACL before writing the secret. ([IO.File]::Create($secretFile)).Dispose() try { $acl = Get-Acl -LiteralPath $secretFile # Disable inheritance, dropping inherited rules (preserveInheritance = $false). $acl.SetAccessRuleProtection($true, $false) # Strip any non-inherited rules left behind (creator default). foreach ($existing in @($acl.Access)) { [void]$acl.RemoveAccessRule($existing) } $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $currentUser, 'FullControl', 'Allow') $acl.AddAccessRule($rule) Set-Acl -LiteralPath $secretFile -AclObject $acl } catch { Write-Log -Message "Could not tighten ACL on temp secret file '$secretFile': $($_.Exception.Message). Proceeding (file will still be deleted in finally)." -Level Warning } # Write the raw secret bytes (no BOM, no trailing newline). [IO.File]::WriteAllText($secretFile, $clientSecretPlain, [Text.UTF8Encoding]::new($false)) try { # Login using Service Principal. `--password @<file>` makes az # read the secret from disk instead of taking it on argv. $loginResult = az login --service-principal ` --username $clientId ` --password "@$secretFile" ` --tenant $tenant ` --output none 2>&1 if ($LASTEXITCODE -ne 0) { $scrubbed = ConvertTo-ScrubbedCliOutput -Text (($loginResult | Out-String).Trim()) Write-Error "Service Principal authentication failed: $scrubbed" return $false } # Verify authentication $accountInfo = az account show 2>$null | ConvertFrom-Json if ($LASTEXITCODE -eq 0 -and $accountInfo) { Write-Log -Message "Successfully authenticated as Service Principal: $($accountInfo.user.name)" -Level Success Write-Log -Message "Subscription: $($accountInfo.name) ($($accountInfo.id))" -Level Verbose $script:ServicePrincipalAuthenticated = $true return $true } else { Write-Error "Authentication succeeded but account verification failed." return $false } } catch { Write-Error "Service Principal authentication error: $($_.Exception.Message)" return $false } } finally { # Best-effort secure-delete of the temp secret file: overwrite # contents with zero bytes, then remove. Errors are swallowed so # cleanup never masks the original outcome. if ($secretFile -and (Test-Path -LiteralPath $secretFile)) { try { $len = (Get-Item -LiteralPath $secretFile -ErrorAction SilentlyContinue).Length if ($len -gt 0) { [IO.File]::WriteAllBytes($secretFile, [byte[]]::new($len)) } } catch { } Remove-Item -LiteralPath $secretFile -Force -ErrorAction SilentlyContinue } } } finally { # Scrub plaintext secret from memory as soon as az login returns if ($secretBstr -ne [IntPtr]::Zero) { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($secretBstr) } $clientSecretPlain = $null } } |