modules/shared/KubeAuth.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Shared helpers for Kubernetes auth-mode handling across the K8s wrappers (Invoke-Kubescape, Invoke-Falco, Invoke-KubeBench). .DESCRIPTION Implements the three auth modes documented in `docs/consumer/k8s-auth.md`: * Default - whatever the kubeconfig already provides (exec plugin, basic auth, cert, az aks get-credentials output, etc). No conversion is performed. * Kubelogin - runs `kubelogin convert-kubeconfig -l <login>` so the kubeconfig uses the modern AAD exec plugin. Default login flow is `azurecli`; when ClientId and TenantId are supplied, `spn` (with optional ServerId) is used. * WorkloadIdentity - federated identity. Sets AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE in process scope and converts the kubeconfig with `kubelogin convert-kubeconfig -l workloadidentity`. Designed for in-cluster use; for local runs the caller must supply a federated token file path. The functions never mutate a user-supplied kubeconfig in place. Callers pass a kubeconfig path and the helpers operate on a working copy when conversion is needed (caller owns the working copy + cleanup). .NOTES All external process launches go through Invoke-WithTimeout for the fixed 300s ceiling enforced project-wide. All log surfaces pass user input through Remove-Credentials before they hit disk. #> Set-StrictMode -Version Latest # --------------------------------------------------------------------------- # Soft dependencies. Wrappers dot-source Sanitize / Installer themselves; we # fall back to no-op shims so this file can be unit-tested in isolation. # --------------------------------------------------------------------------- if (-not (Get-Command -Name Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param([string]$Text) return $Text } } # Set of valid kubelogin login flows. We do not pass arbitrary values to the # kubelogin binary; the wrappers expose only `Default | Kubelogin | # WorkloadIdentity` and KubeAuth.ps1 maps those to the safe subset below. $script:KubeloginValidFlows = @( 'azurecli', 'spn', 'msi', 'workloadidentity', 'devicecode', 'interactive' ) function Test-KubeloginAvailable { <# .SYNOPSIS Returns $true if `kubelogin` is on PATH. #> return [bool](Get-Command -Name kubelogin -ErrorAction SilentlyContinue) } function Assert-KubeAuthMode { <# .SYNOPSIS Validate the requested KubeAuthMode + sub-params. Throws a clear error if a required prerequisite is missing. .PARAMETER Mode One of Default | Kubelogin | WorkloadIdentity. .PARAMETER WorkloadIdentityServiceAccountToken Either a file path (preferred) or the literal token value. Required when Mode is WorkloadIdentity. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateSet('Default', 'Kubelogin', 'WorkloadIdentity')] [string] $Mode, [string] $KubeloginServerId, [string] $KubeloginClientId, [string] $KubeloginTenantId, [string] $WorkloadIdentityClientId, [string] $WorkloadIdentityTenantId, [string] $WorkloadIdentityServiceAccountToken ) if ($Mode -eq 'Default') { return } if (-not (Test-KubeloginAvailable)) { $remediation = if ($IsWindows) { 'winget install --id Azure.Kubelogin --silent' } elseif ($IsMacOS) { 'brew install Azure/kubelogin/kubelogin' } else { 'az aks install-cli (or download from https://github.com/Azure/kubelogin/releases)' } throw ("[MissingPrerequisite] kubelogin binary is required for KubeAuthMode='{0}' but was not found on PATH. Install via: {1}" -f $Mode, $remediation) } if ($Mode -eq 'Kubelogin') { # Mixed sub-param validation: ClientId and TenantId travel together # for the spn flow. Reject one-without-the-other to avoid silently # falling back to azurecli with a ClientId that the user expects to # be used. $hasClient = -not [string]::IsNullOrWhiteSpace($KubeloginClientId) $hasTenant = -not [string]::IsNullOrWhiteSpace($KubeloginTenantId) if ($hasClient -xor $hasTenant) { throw '[InvalidArgument] KubeloginClientId and KubeloginTenantId must be supplied together (spn login flow).' } } if ($Mode -eq 'WorkloadIdentity') { if ([string]::IsNullOrWhiteSpace($WorkloadIdentityClientId) -or [string]::IsNullOrWhiteSpace($WorkloadIdentityTenantId) -or [string]::IsNullOrWhiteSpace($WorkloadIdentityServiceAccountToken)) { throw '[InvalidArgument] WorkloadIdentity mode requires WorkloadIdentityClientId, WorkloadIdentityTenantId, and WorkloadIdentityServiceAccountToken.' } # Reject obviously broken tenant / client values upfront. We accept # GUIDs only; this also blocks shell-injection style values. $guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' if ($WorkloadIdentityClientId -notmatch $guidPattern) { throw '[InvalidArgument] WorkloadIdentityClientId must be a GUID.' } if ($WorkloadIdentityTenantId -notmatch $guidPattern) { throw '[InvalidArgument] WorkloadIdentityTenantId must be a GUID.' } } } function Resolve-WorkloadIdentityTokenFile { <# .SYNOPSIS Returns a tuple { Path; Owned } for the federated token file. If the supplied value resolves to an existing file, it is used as-is (Owned = $false). Otherwise the value is treated as the token itself and written to a temp file with restrictive ACLs (Owned = $true; caller must delete on cleanup). #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $PathOrValue ) if (Test-Path -LiteralPath $PathOrValue -PathType Leaf -ErrorAction SilentlyContinue) { return [pscustomobject]@{ Path = (Resolve-Path -LiteralPath $PathOrValue).ProviderPath Owned = $false } } $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("aa-fedtoken-{0}" -f ([guid]::NewGuid().ToString('N'))) Set-Content -Path $tmp -Value $PathOrValue -NoNewline -Encoding ascii try { if ($IsWindows) { $acl = Get-Acl -Path $tmp $acl.SetAccessRuleProtection($true, $false) $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( $sid, 'Read,Write,Delete', 'Allow') $acl.AddAccessRule($rule) Set-Acl -Path $tmp -AclObject $acl } else { & chmod 600 $tmp 2>&1 | Out-Null } } catch { # Best-effort hardening; the file is in $env:TEMP either way. } return [pscustomobject]@{ Path = $tmp; Owned = $true } } function Copy-KubeconfigForAuthConversion { <# .SYNOPSIS Copy the user-supplied kubeconfig to a process-private temp file so kubelogin convert-kubeconfig can mutate it without touching the original. Returns the temp path. Caller is responsible for delete. When KubeconfigPath was generated by `az aks get-credentials` (the wrapper-owned temp), the caller may pass -InPlace to skip the copy. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $KubeconfigPath, [switch] $InPlace ) if ($InPlace) { return (Resolve-Path -LiteralPath $KubeconfigPath).ProviderPath } if (-not (Test-Path -LiteralPath $KubeconfigPath -PathType Leaf)) { throw "Cannot copy kubeconfig: '$(Remove-Credentials -Text $KubeconfigPath)' does not exist." } $dest = Join-Path ([System.IO.Path]::GetTempPath()) ("aa-kubeconfig-{0}.yaml" -f ([guid]::NewGuid().ToString('N'))) Copy-Item -LiteralPath $KubeconfigPath -Destination $dest -Force return $dest } function Invoke-KubeloginConvert { <# .SYNOPSIS Run `kubelogin convert-kubeconfig` against the supplied kubeconfig with the AAD args derived from the requested auth mode. .PARAMETER KubeconfigPath Path to a writable kubeconfig (NOT the user's original). .PARAMETER LoginFlow One of azurecli | spn | msi | workloadidentity | devicecode | interactive. Mapped from KubeAuthMode + sub-param presence by the caller. .OUTPUTS $true on exit code 0; $false otherwise. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $KubeconfigPath, [Parameter(Mandatory)] [ValidateScript({ $script:KubeloginValidFlows -contains $_ })] [string] $LoginFlow, [string] $ServerId, [string] $ClientId, [string] $TenantId, [string] $KubeContext ) if (-not (Test-KubeloginAvailable)) { throw '[MissingPrerequisite] kubelogin binary not on PATH.' } $kArgs = @('convert-kubeconfig', '--kubeconfig', $KubeconfigPath, '-l', $LoginFlow) if ($KubeContext) { $kArgs += @('--context', $KubeContext) } if ($ServerId -and $LoginFlow -ne 'msi' -and $LoginFlow -ne 'workloadidentity') { $kArgs += @('--server-id', $ServerId) } if ($ClientId) { $kArgs += @('--client-id', $ClientId) } if ($TenantId) { $kArgs += @('--tenant-id', $TenantId) } try { & kubelogin @kArgs 2>&1 | Out-Null return ($LASTEXITCODE -eq 0) } catch { Write-Warning ("kubelogin convert-kubeconfig failed: {0}" -f (Remove-Credentials -Text ([string]$_.Exception.Message))) return $false } } function Set-WorkloadIdentityEnv { <# .SYNOPSIS Set AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_FEDERATED_TOKEN_FILE in process scope. Returns a snapshot the caller can hand to Restore-WorkloadIdentityEnv to undo the changes after the K8s call. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $ClientId, [Parameter(Mandatory)] [string] $TenantId, [Parameter(Mandatory)] [string] $TokenFile ) $snapshot = [pscustomobject]@{ AZURE_CLIENT_ID = $env:AZURE_CLIENT_ID AZURE_TENANT_ID = $env:AZURE_TENANT_ID AZURE_FEDERATED_TOKEN_FILE = $env:AZURE_FEDERATED_TOKEN_FILE } $env:AZURE_CLIENT_ID = $ClientId $env:AZURE_TENANT_ID = $TenantId $env:AZURE_FEDERATED_TOKEN_FILE = $TokenFile return $snapshot } function Restore-WorkloadIdentityEnv { <# .SYNOPSIS Restore env vars previously captured via Set-WorkloadIdentityEnv. #> [CmdletBinding()] param ([Parameter(Mandatory)] $Snapshot) foreach ($name in 'AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_FEDERATED_TOKEN_FILE') { $prev = $Snapshot.$name if ([string]::IsNullOrEmpty($prev)) { Remove-Item -Path ("Env:\{0}" -f $name) -ErrorAction SilentlyContinue } else { Set-Item -Path ("Env:\{0}" -f $name) -Value $prev } } } function Resolve-KubeloginFlow { <# .SYNOPSIS Map (KubeAuthMode + sub-param presence) to the kubelogin -l value. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateSet('Default', 'Kubelogin', 'WorkloadIdentity')] [string] $Mode, [string] $KubeloginClientId, [string] $KubeloginTenantId ) switch ($Mode) { 'Default' { return $null } 'WorkloadIdentity' { return 'workloadidentity' } 'Kubelogin' { $hasSpn = -not [string]::IsNullOrWhiteSpace($KubeloginClientId) -and -not [string]::IsNullOrWhiteSpace($KubeloginTenantId) return ($hasSpn ? 'spn' : 'azurecli') } } } function Initialize-KubeAuth { <# .SYNOPSIS End-to-end auth-mode preparation for a single wrapper invocation. Combines: Assert-KubeAuthMode, Copy-KubeconfigForAuthConversion (BYO), Invoke-KubeloginConvert (Kubelogin/WorkloadIdentity), and Set-WorkloadIdentityEnv (WorkloadIdentity only). .PARAMETER KubeconfigOwned $true when KubeconfigPath was generated by the wrapper itself (e.g. `az aks get-credentials` temp). When $false (BYO), a private copy is produced before kubelogin convert mutates the file. .OUTPUTS [pscustomobject]@{ KubeconfigPath = <effective kubeconfig path> Cleanup = <scriptblock to invoke in finally{}> } #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateSet('Default', 'Kubelogin', 'WorkloadIdentity')] [string] $Mode, [Parameter(Mandatory)] [string] $KubeconfigPath, [switch] $KubeconfigOwned, [string] $KubeContext, [string] $KubeloginServerId, [string] $KubeloginClientId, [string] $KubeloginTenantId, [string] $WorkloadIdentityClientId, [string] $WorkloadIdentityTenantId, [string] $WorkloadIdentityServiceAccountToken ) Assert-KubeAuthMode ` -Mode $Mode ` -KubeloginServerId $KubeloginServerId ` -KubeloginClientId $KubeloginClientId ` -KubeloginTenantId $KubeloginTenantId ` -WorkloadIdentityClientId $WorkloadIdentityClientId ` -WorkloadIdentityTenantId $WorkloadIdentityTenantId ` -WorkloadIdentityServiceAccountToken $WorkloadIdentityServiceAccountToken if ($Mode -eq 'Default') { return [pscustomobject]@{ KubeconfigPath = $KubeconfigPath Cleanup = { }.GetNewClosure() } } $workingPath = Copy-KubeconfigForAuthConversion -KubeconfigPath $KubeconfigPath -InPlace:$KubeconfigOwned $workingOwned = -not $KubeconfigOwned.IsPresent $tokenFileResult = $null $envSnapshot = $null if ($Mode -eq 'WorkloadIdentity') { $tokenFileResult = Resolve-WorkloadIdentityTokenFile -PathOrValue $WorkloadIdentityServiceAccountToken $envSnapshot = Set-WorkloadIdentityEnv ` -ClientId $WorkloadIdentityClientId ` -TenantId $WorkloadIdentityTenantId ` -TokenFile $tokenFileResult.Path } $flow = Resolve-KubeloginFlow -Mode $Mode -KubeloginClientId $KubeloginClientId -KubeloginTenantId $KubeloginTenantId $effectiveClientId = if ($Mode -eq 'WorkloadIdentity') { $WorkloadIdentityClientId } else { $KubeloginClientId } $effectiveTenantId = if ($Mode -eq 'WorkloadIdentity') { $WorkloadIdentityTenantId } else { $KubeloginTenantId } $convertOk = Invoke-KubeloginConvert ` -KubeconfigPath $workingPath ` -LoginFlow $flow ` -ServerId $KubeloginServerId ` -ClientId $effectiveClientId ` -TenantId $effectiveTenantId ` -KubeContext $KubeContext $cleanupScript = { param($_workingPath, $_workingOwned, $_envSnapshot, $_tokenFileResult) if ($_envSnapshot) { Restore-WorkloadIdentityEnv -Snapshot $_envSnapshot } if ($_tokenFileResult -and $_tokenFileResult.Owned -and (Test-Path -LiteralPath $_tokenFileResult.Path)) { try { Remove-Item -LiteralPath $_tokenFileResult.Path -Force -ErrorAction SilentlyContinue } catch { Write-Verbose ("KubeAuth cleanup: token-file removal failed at '{0}'. Reason: {1}" -f $_tokenFileResult.Path, $_.Exception.Message) } } if ($_workingOwned -and $_workingPath -and (Test-Path -LiteralPath $_workingPath)) { try { Remove-Item -LiteralPath $_workingPath -Force -ErrorAction SilentlyContinue } catch { Write-Verbose ("KubeAuth cleanup: working kubeconfig removal failed at '{0}'. Reason: {1}" -f $_workingPath, $_.Exception.Message) } } } $captured = [pscustomobject]@{ WorkingPath = $workingPath WorkingOwned = $workingOwned EnvSnapshot = $envSnapshot TokenFile = $tokenFileResult } $cleanup = { & $cleanupScript $captured.WorkingPath $captured.WorkingOwned $captured.EnvSnapshot $captured.TokenFile }.GetNewClosure() if (-not $convertOk) { # Best effort: surface a warning but still return the (un-converted) # working kubeconfig so the wrapper can emit Failed status itself. Write-Warning ("kubelogin convert-kubeconfig (-l {0}) returned non-zero; auth may fail." -f $flow) } return [pscustomobject]@{ KubeconfigPath = $workingPath Cleanup = $cleanup } } |