diff --git a/.github/workflows/azcore-scheduled.yml b/.github/workflows/azcore-scheduled.yml index 65841293aff4..9011cf77333a 100644 --- a/.github/workflows/azcore-scheduled.yml +++ b/.github/workflows/azcore-scheduled.yml @@ -19,4 +19,4 @@ jobs: version: ${{ needs.version.outputs.version }} short_test: false retention_days: 7 - use_autorest: false + use_azcore: true diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index cec14a63e7c6..88529bc6d75d 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -22,10 +22,10 @@ on: type: number description: The number of days for which we retain assets default: 90 - use_autorest: + use_azcore: type: boolean - description: Whether to use autorest, not azcore - default: true + description: Whether to use the newer azcore+azidentity backend for REST and auth, or the older autorest + default: false env: GITHUB_TOKEN: ${{ secrets.PULUMI_BOT_TOKEN }} @@ -45,7 +45,19 @@ env: ARM_SUBSCRIPTION_ID: 0282681f-7a9e-424b-80b2-96babd57a8a1 ARM_TENANT_ID: 706143bc-e1d4-4593-aee2-c9dc60ab9be7 PULUMI_API: https://api.pulumi-staging.io - PULUMI_USE_AUTOREST: ${{ inputs.use_autorest }} + # Feature toggle that's read in provider.go enableAzcoreBackend() + PULUMI_ENABLE_AZCORE_BACKEND: ${{ inputs.use_azcore }} + # This is the content of a ~/.azure/ folder, zipped and base64-encoded, for CLI auth. + # If/when the contained refresh token expires, someone with access to our subscription needs to + # `az login` on their own computer and repeat the steps below. + # Generated by using @mikhail's .azure folder and running: + # cp -R ~/.azure ~/azure + # cd ~/azure + # rm -rf .DS_Store logs/ commands/* cliextensions/ extensionCommandTree.json + # zip -v azure.zip * + # base64 --input azure.zip | clipcopy + # Paste into repo secret + AZURE_CLI_FOLDER: ${{ secrets.AZURE_CLI_FOLDER }} jobs: prerequisites: @@ -172,6 +184,7 @@ jobs: needs: build_sdks # Use big runner for dotnet and nodejs because we need more memory and more compute, respectively runs-on: ${{ (matrix.language == 'dotnet' || matrix.language == 'nodejs' || matrix.language == 'go') && 'pulumi-ubuntu-8core' || 'ubuntu-latest' }} + environment: env-ci strategy: fail-fast: false matrix: @@ -202,6 +215,18 @@ jobs: run: | echo "${{ secrets.ARM_CLIENT_CERTIFICATE }}" | base64 -d > "${{ runner.temp }}/azure-client-certificate.pfx" + - name: Write .azure.tmp folder + # We write to .azure.tmp, not directly to .azure, because we want only one test to use + # this folder. The test needs to rename it to .azure and then back. This is to avoid other + # tests using it unintentionally since CLI is the fallback auth method. + run: | + set -euxo pipefail + echo "${{ secrets.AZURE_CLI_FOLDER }}" | base64 -d > "${{ runner.temp }}/azure-cli-folder.zip" + # Unzip it to a temp folder to avoid other tests using it unintentionally (since CLI auth is the fallback method). + # We only want one specific test to use it. + unzip -d "$HOME/.azure.tmp" "${{ runner.temp }}/azure-cli-folder.zip" + rm "${{ runner.temp }}/azure-cli-folder.zip" + - name: Run tests if: ${{ ! inputs.short_test }} env: diff --git a/examples/azure-native-sdk-v2/go-azure-in-azure/main.go b/examples/azure-native-sdk-v2/go-azure-in-azure/main.go index a2752826100d..e11bfe6e455c 100644 --- a/examples/azure-native-sdk-v2/go-azure-in-azure/main.go +++ b/examples/azure-native-sdk-v2/go-azure-in-azure/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "os/exec" "github.com/pulumi/pulumi-azure-native-sdk/authorization/v2" "github.com/pulumi/pulumi-azure-native-sdk/compute/v2" @@ -115,6 +116,7 @@ func main() { vmIdentity := &compute.VirtualMachineIdentityArgs{Type: compute.ResourceIdentityTypeSystemAssigned} var umi *managedidentity.UserAssignedIdentity + var umiClientId pulumi.StringOutput = pulumi.String("").ToStringOutput() if os.Getenv("PULUMI_TEST_USER_IDENTITY") == "true" { fmt.Printf("go-azure-in-azure: using user-assigned identity\n") @@ -124,10 +126,20 @@ func main() { if err != nil { return err } + umiClientId = umi.ClientId + + // Create a second user-assigned identity to test multiple identities. With multiple identities, the one to + // use needs to be specified via clientId. + umi2, err := managedidentity.NewUserAssignedIdentity(ctx, "umi2", &managedidentity.UserAssignedIdentityArgs{ + ResourceGroupName: rg.Name, + }) + if err != nil { + return err + } vmIdentity = &compute.VirtualMachineIdentityArgs{ Type: compute.ResourceIdentityTypeUserAssigned, - UserAssignedIdentities: pulumi.StringArray{umi.ID()}, + UserAssignedIdentities: pulumi.StringArray{umi.ID(), umi2.ID()}, } } @@ -228,6 +240,7 @@ func main() { User: pulumi.String("pulumi"), PrivateKey: privateKey.PrivateKeyOpenssh, } + copy, err := remote.NewCopyToRemote(ctx, "copy", &remote.CopyToRemoteArgs{ Connection: sshConn, Source: pulumi.NewFileArchive(innerProgram + "/"), @@ -247,30 +260,62 @@ func main() { return err } + // Copy the provider binary under test (the one on PATH) to the VM. + // We put it in the same directory as the pulumi binary which is on the PATH. + // Note that this can only work if the VM has the same architecture as the local machine. + providerBinaryPath, err := exec.LookPath("pulumi-resource-azure-native") + if err != nil { + return err + } + copyProvider, err := remote.NewCopyToRemote(ctx, "copyProvider", &remote.CopyToRemoteArgs{ + Connection: sshConn, + Source: pulumi.NewFileAsset(providerBinaryPath), + RemotePath: pulumi.String("/home/pulumi/.pulumi/bin/"), + Triggers: pulumi.ToArray([]any{vm.ID()}), + }, pulumi.DependsOn([]pulumi.Resource{poll, installPulumi})) + if err != nil { + return err + } + chmodProvider, err := remote.NewCommand(ctx, "chmodProvider", &remote.CommandArgs{ + Connection: sshConn, + Create: pulumi.String("chmod +x /home/pulumi/.pulumi/bin/pulumi-resource-azure-native"), + Triggers: pulumi.ToArray([]any{vm.ID()}), + }, pulumi.DependsOn([]pulumi.Resource{copyProvider})) + if err != nil { + return err + } + + // Pass feature flags into the VM. + useAutorest := os.Getenv("PULUMI_USE_AUTOREST") + useLegacyAuth := os.Getenv("PULUMI_USE_LEGACY_AUTH") + // We pass the resource group's ID into the inner program via config so the program can // create a resource in the resource group. create := pulumi.Sprintf(`cd %s && \ set -euxo pipefail && \ export ARM_USE_MSI=true && \ export ARM_SUBSCRIPTION_ID=%s && \ -export PATH=~/.pulumi/bin:$PATH && \ +export PATH="$HOME/.pulumi/bin:$PATH" && \ export PULUMI_CONFIG_PASSPHRASE=pass && \ +export PULUMI_USE_AUTOREST=%s && \ +export PULUMI_USE_LEGACY_AUTH=%s && \ rand=$(openssl rand -hex 4) && \ stackname="%s-$rand" && \ pulumi login --local && \ pulumi stack init $stackname && \ +pulumi config set azure-native:clientId "%s" -s $stackname && \ pulumi config set rgId "%s" -s $stackname && \ pulumi config -s $stackname && \ pulumi up -s $stackname --skip-preview --logtostderr --logflow -v=9 && \ pulumi down -s $stackname --skip-preview --logtostderr --logflow -v=9 && \ pulumi stack rm --yes $stackname && \ -pulumi logout --local`, innerProgram, clientConf.SubscriptionId, innerProgram, rg.ID()) +pulumi logout --local`, innerProgram, clientConf.SubscriptionId, useAutorest, useLegacyAuth, innerProgram, umiClientId, rg.ID()) - pulumiPreview, err := remote.NewCommand(ctx, "pulumiPreview", &remote.CommandArgs{ + pulumiPreview, err := remote.NewCommand(ctx, "pulumiUpDown", &remote.CommandArgs{ Connection: sshConn, Triggers: pulumi.ToArray([]any{vm.ID(), principalId, roleAssignment.ID()}), Create: create, - }, pulumi.DependsOn([]pulumi.Resource{roleAssignment, copy, installPulumi})) + }, pulumi.DependsOn([]pulumi.Resource{roleAssignment, copy, copyProvider, chmodProvider, installPulumi})) if err != nil { return err } @@ -281,8 +326,9 @@ pulumi logout --local`, innerProgram, clientConf.SubscriptionId, innerProgram, r ctx.Export("publicIpAddress", ipLookup.IpAddress().Elem()) ctx.Export("installPulumi", installPulumi.Stdout) ctx.Export("installPulumiStderr", installPulumi.Stderr) - ctx.Export("pulumiPreview", pulumiPreview.Stdout) - ctx.Export("pulumiPreviewStderr", pulumiPreview.Stderr) + ctx.Export("providerBinary", copyProvider.Source) + ctx.Export("pulumiStdout", pulumiPreview.Stdout) + ctx.Export("pulumiStderr", pulumiPreview.Stderr) return nil }) diff --git a/examples/examples_nodejs_keyvault_test.go b/examples/examples_nodejs_keyvault_test.go index e63c99f21ea8..768cbb91e738 100644 --- a/examples/examples_nodejs_keyvault_test.go +++ b/examples/examples_nodejs_keyvault_test.go @@ -5,10 +5,12 @@ package examples import ( "os" + "os/user" "path/filepath" "testing" "github.com/pulumi/pulumi/pkg/v3/testing/integration" + "github.com/stretchr/testify/require" ) func TestAccKeyVaultTs(t *testing.T) { @@ -80,9 +82,44 @@ func TestAccKeyVaultTs_ClientCert(t *testing.T) { "ARM_CLIENT_CERTIFICATE_PATH=" + os.Getenv("ARM_CLIENT_CERTIFICATE_PATH_FOR_TEST"), "ARM_CLIENT_CERTIFICATE_PASSWORD=" + os.Getenv("ARM_CLIENT_CERTIFICATE_PASSWORD_FOR_TEST"), // Make sure we test the client cert path + "ACTIONS_ID_TOKEN_REQUEST_TOKEN=", + "ACTIONS_ID_TOKEN_REQUEST_URL=", + "ARM_CLIENT_SECRET=", + "ARM_CLIENT_CERTIFICATE_PATH=", + }, + }) + + integration.ProgramTest(t, &test) +} + +func TestAccKeyVaultTs_CLI(t *testing.T) { + skipIfShort(t) + + usr, err := user.Current() + require.NoError(t, err) + // .azure.tmp is created by the GH workflow build-test.yml, from the GH secret AZURE_CLI_FOLDER + // which is also documented in the workflow. We rename it to .azure so the `az` CLI can find it. + err = os.Rename(filepath.Join(usr.HomeDir, ".azure.tmp"), filepath.Join(usr.HomeDir, ".azure")) + require.NoError(t, err) + + // Prevent later tests from accidentally picking up the .azure folder because authentication + // falls back to CLI when other methods are misconfigured. + defer func() { + _ = os.Rename(filepath.Join(usr.HomeDir, ".azure"), filepath.Join(usr.HomeDir, ".azure.tmp")) + }() + + test := getJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "keyvault"), + Env: []string{ + // Unset auth variables to make sure we're testing the CLI auth path "ARM_CLIENT_SECRET=", + "ARM_CLIENT_CERTIFICATE_PATH=", + "ARM_USE_MSI=false", + "ARM_USE_OIDC=false", }, }) integration.ProgramTest(t, &test) + } diff --git a/provider/go.mod b/provider/go.mod index a1e9a141a189..b48d61a0af67 100644 --- a/provider/go.mod +++ b/provider/go.mod @@ -4,7 +4,8 @@ go 1.21.0 require ( github.com/Azure/azure-sdk-for-go v68.0.0+incompatible - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.15.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.4.0 github.com/Azure/go-autorest/autorest v0.11.29 github.com/blang/semver v3.5.1+incompatible @@ -42,7 +43,6 @@ require ( cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/longrunning v0.5.5 // indirect dario.cat/mergo v1.0.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect @@ -217,14 +217,14 @@ require ( go.uber.org/atomic v1.9.0 // indirect gocloud.dev v0.37.0 // indirect gocloud.dev/secrets/hashivault v0.37.0 // indirect - golang.org/x/crypto v0.26.0 // indirect + golang.org/x/crypto v0.28.0 // indirect golang.org/x/mod v0.18.0 // indirect - golang.org/x/net v0.28.0 // indirect + golang.org/x/net v0.30.0 // indirect golang.org/x/oauth2 v0.18.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.23.0 // indirect - golang.org/x/term v0.23.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.25.0 // indirect + golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect diff --git a/provider/go.sum b/provider/go.sum index 5e8d0963649b..d2a9bfee6292 100644 --- a/provider/go.sum +++ b/provider/go.sum @@ -58,10 +58,12 @@ github.com/Azure/azure-sdk-for-go v45.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo github.com/Azure/azure-sdk-for-go v47.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 h1:nyQWyZvwGTvunIMxi1Y9uXkcyr+I7TeNrr/foo4Kpk8= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.15.0 h1:eXzkOEXbSTOa7cJ7EqeCVi/OFi/ppDrUtQuttCWy74c= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.15.0/go.mod h1:YL1xnZ6QejvQHWJrX/AvhFl4WW4rqHVoKspWNVwFk0M= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvULkDNfdXOgrjtg6UYJPFBJyuEcRCAw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= @@ -107,6 +109,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -190,6 +194,8 @@ github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7N github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= @@ -222,6 +228,8 @@ github.com/davegardnerisme/deephash v0.0.0-20210406090112-6d072427d830/go.mod h1 github.com/deckarep/golang-set/v2 v2.5.0 h1:hn6cEZtQ0h3J8kFrHR/NrzyOoTnjgW1+FmNJzQ7y/sA= github.com/deckarep/golang-set/v2 v2.5.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= @@ -463,6 +471,8 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -573,6 +583,8 @@ github.com/pulumi/pulumi/pkg/v3 v3.136.1 h1:zA8aJZ7qI0QgZkBKjjQaYHEcigK6pZfrbfG3 github.com/pulumi/pulumi/pkg/v3 v3.136.1/go.mod h1:Iz8QIs07AbEdrO52hEIEM5C4VBDUYFH2NdM9u2xxBxY= github.com/pulumi/pulumi/sdk/v3 v3.136.1 h1:VJWTgdBrLvvzIkMbGq/epNEfT65P9gTvw14UF/I7hTI= github.com/pulumi/pulumi/sdk/v3 v3.136.1/go.mod h1:PvKsX88co8XuwuPdzolMvew5lZV+4JmZfkeSjj7A6dI= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= @@ -702,8 +714,8 @@ golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -787,8 +799,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -873,8 +885,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -884,8 +896,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -901,8 +913,8 @@ golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/provider/pkg/azure/azure.go b/provider/pkg/azure/azure.go index 7090ab57b142..8800ccdcfc4e 100644 --- a/provider/pkg/azure/azure.go +++ b/provider/pkg/azure/azure.go @@ -1,3 +1,5 @@ +// Copyright 2016-2024, Pulumi Corporation. + package azure import ( @@ -9,6 +11,7 @@ import ( "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" + azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/azure" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/version" @@ -61,3 +64,21 @@ func AzureError(err error) error { } return err } + +// GetCloudByName returns the azure-sdk-for-go/sdk/azcore/cloud configuration for the given cloud. +// Valid names are as documented in the provider's installation & configuration guide, currently +// public, china, usgovernment, or the empty value for public. +// NOTE: this method doesn't do any validation. If an unknown cloud is given, it falls through to +// the default public cloud. It's assumed that validation of cloud name in the provider's config +// has been done earlier. +func GetCloudByName(cloudName string) azcloud.Configuration { + switch cloudName { + case "china": + return azcloud.AzureChina + case "usgov": + return azcloud.AzureGovernment + case "usgovernment": + return azcloud.AzureGovernment + } + return azcloud.AzurePublic +} diff --git a/provider/pkg/azure/azure_test.go b/provider/pkg/azure/azure_test.go new file mode 100644 index 000000000000..d7cf2a27fc7e --- /dev/null +++ b/provider/pkg/azure/azure_test.go @@ -0,0 +1,25 @@ +// Copyright 2016-2024, Pulumi Corporation. + +package azure + +import ( + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/stretchr/testify/assert" +) + +func TestGetCloudByName(t *testing.T) { + for _, tc := range []struct { + name string + expected cloud.Configuration + }{ + {name: "", expected: cloud.AzurePublic}, + {name: "public", expected: cloud.AzurePublic}, + {name: "china", expected: cloud.AzureChina}, + {name: "usgov", expected: cloud.AzureGovernment}, + {name: "usgovernment", expected: cloud.AzureGovernment}, + } { + assert.Equal(t, tc.expected, GetCloudByName(tc.name), tc.name) + } +} diff --git a/provider/pkg/provider/auth.go b/provider/pkg/provider/auth.go index 01e9f7f990b6..7a0d36068c90 100644 --- a/provider/pkg/provider/auth.go +++ b/provider/pkg/provider/auth.go @@ -329,7 +329,7 @@ func (k *azureNativeProvider) getOAuthToken(ctx context.Context, auth *authConfi type TokenFactory func(ctx context.Context, auth *authConfig, endpoint string) (string, error) -// Implements the `azidentity.TokenCredential` interface. +// Implements the `azcore.TokenCredential` interface. type azCoreTokenCredential struct { p *azureNativeProvider } diff --git a/provider/pkg/provider/auth_azidentity.go b/provider/pkg/provider/auth_azidentity.go new file mode 100644 index 000000000000..bc718846de26 --- /dev/null +++ b/provider/pkg/provider/auth_azidentity.go @@ -0,0 +1,290 @@ +// Copyright 2016-2022, Pulumi Corporation. + +package provider + +import ( + "context" + "crypto" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + azcloud "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/pkg/errors" + "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/azure" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" +) + +// newTokenCredential is the main entry to the new azcore/azidentity-based authenticattion stack. It returns a +// TokenCredential which can be passed into various Azure Go SDKs. +func (k *azureNativeProvider) newTokenCredential() (azcore.TokenCredential, error) { + authConf, err := k.readAuthConfig() + if err != nil { + return nil, err + } + + return newSingleMethodAuthCredential(authConf) +} + +// newSingleMethodAuthCredential creates an azcore.TokenCredential. Depending on the given authConfiguration, it is +// backed by one of several authentication methods such as Service Principal, OIDC, Managed Identity, or Azure CLI. +// +// Note: this function's behavior is written to match the behavior of +// "github.com/hashicorp/go-azure-helpers/authentication".Builder.Build() in some ways, to minimize changes in provider +// behavior when upgrading authentication dependencies from go-azure-helpers to azidentity. Namely: +// - The order in which the the different authentication methods are attempted is the same: +// 1. Service Principal with client certificate +// 2. Service Principal with client secret +// 3. OIDC +// 4. Managed Identity +// 5. Azure CLI +// - When a method is configured but instantiating the credential fails, we return an error and do not fall through to +// the next method. +// - Auxiliary or additional tenants are supported for SP with client secret and CLI authentication, not for others. +func newSingleMethodAuthCredential(authConf *authConfiguration) (azcore.TokenCredential, error) { + baseClientOpts := azcore.ClientOptions{ + Cloud: authConf.cloud, + } + + if authConf.clientCertPath != "" { + logging.V(9).Infof("[auth] Using SP with client certificate credential") + certs, key, err := readCertificate(authConf.clientCertPath, authConf.clientCertPassword) + if err != nil { + return nil, err + } + options := &azidentity.ClientCertificateCredentialOptions{ + AdditionallyAllowedTenants: authConf.auxTenants, // usually empty which is fine + ClientOptions: baseClientOpts, + } + return azidentity.NewClientCertificateCredential(authConf.tenantId, authConf.clientId, certs, key, options) + } else { + logging.V(9).Infof("SP with client certificate credential is not enabled, skipping") + } + + if authConf.clientSecret != "" { + logging.V(9).Infof("[auth] Using SP with client secret credential") + options := &azidentity.ClientSecretCredentialOptions{ + AdditionallyAllowedTenants: authConf.auxTenants, // usually empty which is fine + ClientOptions: baseClientOpts, + } + return azidentity.NewClientSecretCredential(authConf.tenantId, authConf.clientId, authConf.clientSecret, options) + } else { + logging.V(9).Infof("SP with client secret credential is not enabled, skipping") + } + + if authConf.useOidc { + logging.V(9).Infof("[auth] Using OIDC credential") + return newOidcCredential(authConf) + } else { + logging.V(9).Infof("OIDC credential is not enabled, skipping") + } + + if authConf.useMsi { + logging.V(9).Infof("[auth] Using Managed Identity (MSI) credential") + msiOpts := azidentity.ManagedIdentityCredentialOptions{ + ClientOptions: baseClientOpts, + } + if authConf.clientId != "" { + msiOpts.ID = azidentity.ClientID(authConf.clientId) + } + return azidentity.NewManagedIdentityCredential(&msiOpts) + } else { + logging.V(9).Infof("Managed Identity (MSI) credential is not enabled, skipping") + } + + logging.V(9).Infof("[auth] Using Azure CLI credential") + options := &azidentity.AzureCLICredentialOptions{ + AdditionallyAllowedTenants: authConf.auxTenants, // usually empty which is fine + } + cli, err := azidentity.NewAzureCLICredential(options) + if err == nil { + return cli, nil + } + return nil, errors.Errorf("Failed to find any valid credentials") +} + +// newOidcCredential creates a TokenCredential for OpenID Connect (OIDC) authentication. +// An OIDC credential is an azidentity.ClientAssertionCredential that authenticates with a token +// obtained from a callback function. The token itself can be provided in various ways: +// - directly via config/environment variable +// - from a file +// - through a token exchange by making a request to a configured endpoint +// This function configures the client assertion callback according to the above cases. +func newOidcCredential(authConf *authConfiguration) (azcore.TokenCredential, error) { + // The generic client assertion that simply returns the token it was created with. + oidcTokenCredentialCallback := func(token string) (azcore.TokenCredential, error) { + return azidentity.NewClientAssertionCredential( + authConf.tenantId, + authConf.clientId, + func(ctx context.Context) (string, error) { + return token, nil + }, + nil) + } + + if authConf.oidcToken != "" { + return oidcTokenCredentialCallback(authConf.oidcToken) + } + + if authConf.oidcTokenFilePath != "" { + token, err := os.ReadFile(authConf.oidcTokenFilePath) + if err != nil { + return nil, fmt.Errorf("failed to read OIDC token from %s: %w", authConf.oidcTokenFilePath, err) + } + return oidcTokenCredentialCallback(string(token)) + } + + // In this case, we need to obtain the OIDC token first from the configured endpoint. + if authConf.oidcTokenRequestUrl != "" && authConf.oidcTokenRequestToken != "" { + return azidentity.NewClientAssertionCredential( + authConf.tenantId, + authConf.clientId, + getOidcTokenExchangeAssertion(authConf), + &azidentity.ClientAssertionCredentialOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: authConf.cloud, + }, + }) + } + + return nil, errors.New("OIDC token or request URL and token are not provided") +} + +// getOidcTokenExchangeAssertion returns a callback function that implements the OIDC token +// exchange flow on GitHub. The function makes a request to the configured endpoint with the +// configured bearer token and returns the OIDC token from the response. It's intended to be used +// in an azidentity.ClientAssertionCredential. +func getOidcTokenExchangeAssertion(authConf *authConfiguration) func(ctx context.Context) (string, error) { + return func(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authConf.oidcTokenRequestUrl, http.NoBody) + if err != nil { + return "", fmt.Errorf("GitHub OIDC: failed to build request to %s: %w", authConf.oidcTokenRequestUrl, err) + } + + query, err := url.ParseQuery(req.URL.RawQuery) + if err != nil { + return "", fmt.Errorf("githubAssertion: cannot parse URL query") + } + // see https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-azure#adding-the-federated-credentials-to-azure + query.Set("audience", "api://AzureADTokenExchange") + req.URL.RawQuery = query.Encode() + + req.Header.Set("Accept", "application/json") + // see https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect#updating-your-actions-for-oidc + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authConf.oidcTokenRequestToken)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("GitHub OIDC: couldn't request token: %w", err) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("GitHub OIDC: cannot read response: %w", err) + } + + if c := resp.StatusCode; c < 200 || c > 299 { + return "", fmt.Errorf("GitHub OIDC: %d with response: %s", resp.StatusCode, body) + } + + var tokenResponse struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &tokenResponse); err != nil { + return "", fmt.Errorf("GitHub OIDC: cannot unmarshal response: %w", err) + } + + return tokenResponse.Value, nil + } +} + +func readCertificate(certPath, certPassword string) ([]*x509.Certificate, crypto.PrivateKey, error) { + cert, err := os.ReadFile(certPath) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to read certificate from %s", certPath) + } + + var pwBytes []byte + if certPassword != "" { + pwBytes = []byte(certPassword) + } + + certs, key, err := azidentity.ParseCertificates(cert, pwBytes) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to parse certificate from %s", certPath) + } + if len(certs) == 0 { + return nil, nil, errors.Errorf("no certificates found in %s", certPath) + } + + return certs, key, nil +} + +type authConfiguration struct { + cloud azcloud.Configuration + + clientId string + tenantId string + auxTenants []string + + useOidc bool + oidcToken string + oidcTokenFilePath string + oidcTokenRequestToken string + oidcTokenRequestUrl string + + clientSecret string + clientCertPath string + clientCertPassword string + + // Note: for MSI, there used to be a msiEndpoint/ARM_MSI_ENDPOINT and a metadataHost/ + // ARM_METADATA_HOSTNAME configuration. The newer azidentity package handles the MSI endpoint + // automatically: + // https://github.com/Azure/azure-sdk-for-go/blob/sdk/azidentity/v1.8.0/sdk/azidentity/managed_identity_client.go#L143 + useMsi bool +} + +// getAuthConfig collects auth-related configuration from Pulumi config and environment variables +func (k *azureNativeProvider) readAuthConfig() (*authConfiguration, error) { + auxTenantsString := k.getConfig("auxiliaryTenantIds", "ARM_AUXILIARY_TENANT_IDS") + var auxTenants []string + if auxTenantsString != "" { + err := json.Unmarshal([]byte(auxTenantsString), &auxTenants) + if err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal '%s' as Auxiliary Tenants", auxTenantsString) + } + } + + cloudName := k.getConfig("environment", "ARM_ENVIRONMENT") + if cloudName == "" { + cloudName = "public" + } + cloud := azure.GetCloudByName(cloudName) + + return &authConfiguration{ + clientId: k.getConfig("clientId", "ARM_CLIENT_ID"), + tenantId: k.getConfig("tenantId", "ARM_TENANT_ID"), + auxTenants: auxTenants, + cloud: cloud, + + clientSecret: k.getConfig("clientSecret", "ARM_CLIENT_SECRET"), + clientCertPath: k.getConfig("clientCertificatePath", "ARM_CLIENT_CERTIFICATE_PATH"), + clientCertPassword: k.getConfig("clientCertificatePassword", "ARM_CLIENT_CERTIFICATE_PASSWORD"), + + useMsi: k.getConfig("useMsi", "ARM_USE_MSI") == "true", + + useOidc: k.getConfig("useOidc", "ARM_USE_OIDC") == "true", + oidcToken: k.getConfig("oidcToken", "ARM_OIDC_TOKEN"), + oidcTokenFilePath: k.getConfig("oidcTokenFilePath", "ARM_OIDC_TOKEN_FILE_PATH"), + oidcTokenRequestToken: k.getConfig("oidcRequestToken", "ACTIONS_ID_TOKEN_REQUEST_TOKEN"), + oidcTokenRequestUrl: k.getConfig("oidcRequestUrl", "ACTIONS_ID_TOKEN_REQUEST_URL"), + }, nil +} diff --git a/provider/pkg/provider/auth_azidentity_test.go b/provider/pkg/provider/auth_azidentity_test.go new file mode 100644 index 000000000000..2ed94ea3a468 --- /dev/null +++ b/provider/pkg/provider/auth_azidentity_test.go @@ -0,0 +1,322 @@ +// Copyright 2016-2024, Pulumi Corporation. + +package provider + +import ( + "context" + _ "embed" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed test.pfx +var testPfxCert []byte + +func TestGetAuthConfig(t *testing.T) { + setAuthEnvVariables := func(value, boolValue, cloudValue string) { + if value != "" { + t.Setenv("ARM_AUXILIARY_TENANT_IDS", `["`+value+`"]`) + } + t.Setenv("ARM_CLIENT_CERTIFICATE_PASSWORD", value) + t.Setenv("ARM_CLIENT_CERTIFICATE_PATH", value) + t.Setenv("ARM_CLIENT_ID", value) + t.Setenv("ARM_CLIENT_SECRET", value) + t.Setenv("ARM_ENVIRONMENT", cloudValue) + t.Setenv("ARM_OIDC_TOKEN", value) + t.Setenv("ARM_OIDC_TOKEN_FILE_PATH", value) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", value) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", value) + t.Setenv("ARM_TENANT_ID", value) + t.Setenv("ARM_USE_MSI", boolValue) + t.Setenv("ARM_USE_OIDC", boolValue) + } + + t.Run("empty", func(t *testing.T) { + setAuthEnvVariables("", "", "") + p := azureNativeProvider{} + c, err := p.readAuthConfig() + require.NoError(t, err) + require.NotNil(t, c) + require.Empty(t, c.auxTenants) + require.Empty(t, c.clientCertPassword) + require.Empty(t, c.clientCertPath) + require.Empty(t, c.clientId) + require.Empty(t, c.clientSecret) + require.Equal(t, cloud.AzurePublic, c.cloud) + require.Empty(t, c.oidcToken) + require.Empty(t, c.oidcTokenFilePath) + require.Empty(t, c.oidcTokenRequestToken) + require.Empty(t, c.oidcTokenRequestUrl) + require.Empty(t, c.tenantId) + require.False(t, c.useOidc) + require.False(t, c.useMsi) + }) + + t.Run("values from config take precedence", func(t *testing.T) { + setAuthEnvVariables("env", "false", "china") + + p := azureNativeProvider{ + config: map[string]string{ + "auxiliaryTenantIds": `["conf"]`, + "clientCertificatePassword": "conf", + "clientCertificatePath": "conf", + "clientId": "conf", + "clientSecret": "conf", + "environment": "usgov", + "oidcToken": "conf", + "oidcTokenFilePath": "conf", + "oidcRequestToken": "conf", + "oidcRequestUrl": "conf", + "tenantId": "conf", + "useMsi": "true", + "useOidc": "true", + }, + } + + c, err := p.readAuthConfig() + require.NoError(t, err) + require.NotNil(t, c) + require.Equal(t, []string{"conf"}, c.auxTenants) + require.Equal(t, "conf", c.clientCertPassword) + require.Equal(t, "conf", c.clientCertPath) + require.Equal(t, "conf", c.clientId) + require.Equal(t, "conf", c.clientSecret) + require.Equal(t, cloud.AzureGovernment, c.cloud) + require.Equal(t, "conf", c.oidcToken) + require.Equal(t, "conf", c.oidcTokenFilePath) + require.Equal(t, "conf", c.oidcTokenRequestToken) + require.Equal(t, "conf", c.oidcTokenRequestUrl) + require.Equal(t, "conf", c.tenantId) + require.True(t, c.useOidc) + require.True(t, c.useMsi) + }) + + t.Run("values from env", func(t *testing.T) { + p := azureNativeProvider{} + setAuthEnvVariables("env", "true", "china") + + c, err := p.readAuthConfig() + require.NoError(t, err) + require.NotNil(t, c) + require.Equal(t, []string{"env"}, c.auxTenants) + require.Equal(t, "env", c.clientCertPassword) + require.Equal(t, "env", c.clientCertPath) + require.Equal(t, "env", c.clientId) + require.Equal(t, "env", c.clientSecret) + require.Equal(t, cloud.AzureChina, c.cloud) + require.Equal(t, "env", c.oidcToken) + require.Equal(t, "env", c.oidcTokenFilePath) + require.Equal(t, "env", c.oidcTokenRequestToken) + require.Equal(t, "env", c.oidcTokenRequestUrl) + require.Equal(t, "env", c.tenantId) + require.True(t, c.useOidc) + require.True(t, c.useMsi) + }) +} + +func TestNewCredential(t *testing.T) { + t.Run("SP with client secret", func(t *testing.T) { + conf := &authConfiguration{ + clientId: "client-id", + clientSecret: "client-secret", + tenantId: "tenant-id", + } + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.ClientSecretCredential{}, cred) + }) + + t.Run("Incomplete SP with client secret conf missing tenant id", func(t *testing.T) { + conf := &authConfiguration{ + clientId: "client-id", + clientSecret: "client-secret", + } + _, err := newSingleMethodAuthCredential(conf) + require.Error(t, err) + require.Contains(t, err.Error(), "tenant") + }) + + t.Run("SP with client cert", func(t *testing.T) { + certPath := filepath.Join(t.TempDir(), "cert.pfx") + require.NoError(t, os.WriteFile(certPath, testPfxCert, 0644)) + + conf := &authConfiguration{ + clientId: "client-id", + clientCertPath: certPath, + clientCertPassword: "pulumi", + tenantId: "tenant-id", + } + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.ClientCertificateCredential{}, cred) + }) + + t.Run("SP with invalid client cert", func(t *testing.T) { + certPath := filepath.Join(t.TempDir(), "cert.pem") + require.NoError(t, os.WriteFile(certPath, []byte("cert"), 0644)) + + conf := &authConfiguration{ + clientId: "client-id", + clientCertPath: certPath, + tenantId: "tenant-id", + } + _, err := newSingleMethodAuthCredential(conf) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse certificate") + }) + + t.Run("SP with client cert and wrong password", func(t *testing.T) { + certPath := filepath.Join(t.TempDir(), "cert.pfx") + require.NoError(t, os.WriteFile(certPath, testPfxCert, 0644)) + + conf := &authConfiguration{ + clientId: "client-id", + clientCertPath: certPath, + clientCertPassword: "wrong", + tenantId: "tenant-id", + } + _, err := newSingleMethodAuthCredential(conf) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse certificate") + require.Contains(t, err.Error(), "password incorrect") + }) + + t.Run("OIDC with token", func(t *testing.T) { + conf := &authConfiguration{ + useOidc: true, + oidcToken: "oidc-token", + clientId: "client-id", + tenantId: "tenant-id", + } + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.ClientAssertionCredential{}, cred) + }) + + t.Run("OIDC with token file", func(t *testing.T) { + tokenPath := filepath.Join(t.TempDir(), "my.token") + require.NoError(t, os.WriteFile(tokenPath, []byte("token"), 0644)) + + conf := &authConfiguration{ + useOidc: true, + oidcTokenFilePath: tokenPath, + clientId: "client-id", + tenantId: "tenant-id", + } + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.ClientAssertionCredential{}, cred) + }) + + t.Run("OIDC with wrong token file", func(t *testing.T) { + conf := &authConfiguration{ + useOidc: true, + oidcTokenFilePath: filepath.Join(t.TempDir(), "foo"), + clientId: "client-id", + tenantId: "tenant-id", + } + _, err := newSingleMethodAuthCredential(conf) + require.Error(t, err) + }) + + t.Run("OIDC with token exchange URL", func(t *testing.T) { + conf := &authConfiguration{ + useOidc: true, + oidcTokenRequestToken: "oidc-token", + oidcTokenRequestUrl: "oidc-token-url", + clientId: "client-id", + tenantId: "tenant-id", + } + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.ClientAssertionCredential{}, cred) + }) + + t.Run("Incomplete OIDC conf", func(t *testing.T) { + for _, conf := range []*authConfiguration{ + { + useOidc: true, + oidcToken: "oidc-token", + clientId: "client-id", + }, + { + useOidc: true, + oidcTokenRequestUrl: "oidc-token-url", + clientId: "client-id", + tenantId: "tenant-id", + }, + } { + _, err := newSingleMethodAuthCredential(conf) + require.Error(t, err) + } + }) + + t.Run("MSI", func(t *testing.T) { + conf := &authConfiguration{ + useMsi: true, + } + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.ManagedIdentityCredential{}, cred) + }) + + // Used for user-assigned managed identity + t.Run("MSI with client id", func(t *testing.T) { + conf := &authConfiguration{ + clientId: "123", + useMsi: true, + } + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.ManagedIdentityCredential{}, cred) + }) + + t.Run("CLI", func(t *testing.T) { + conf := &authConfiguration{} + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.AzureCLICredential{}, cred) + }) + + t.Run("CLI with tenant ids", func(t *testing.T) { + conf := &authConfiguration{ + tenantId: "tenant-id", + auxTenants: []string{"123", "456"}, + } + cred, err := newSingleMethodAuthCredential(conf) + require.NoError(t, err) + require.IsType(t, &azidentity.AzureCLICredential{}, cred) + }) +} + +func TestOidcTokenExchangeAssertion(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + assert.Equal(t, "application/json", r.Header.Get("Accept")) + assert.Equal(t, "api://AzureADTokenExchange", r.URL.Query().Get("audience")) + assert.Equal(t, "Bearer oidc-token", r.Header.Get("Authorization")) + + w.Write([]byte(`{"Value": "new-oidc-token"}`)) + })) + defer ts.Close() + + conf := &authConfiguration{ + oidcTokenRequestToken: "oidc-token", + oidcTokenRequestUrl: ts.URL, + } + + assertion := getOidcTokenExchangeAssertion(conf) + + oidcToken, err := assertion(context.Background()) + require.NoError(t, err) + require.Equal(t, "new-oidc-token", oidcToken) +} diff --git a/provider/pkg/provider/provider.go b/provider/pkg/provider/provider.go index e12aada7594d..f0b7a676badf 100644 --- a/provider/pkg/provider/provider.go +++ b/provider/pkg/provider/provider.go @@ -21,7 +21,7 @@ import ( "google.golang.org/protobuf/types/known/emptypb" "github.com/Azure/azure-sdk-for-go/sdk/azcore" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/go-autorest/autorest" azureEnv "github.com/Azure/go-autorest/autorest/azure" pbempty "github.com/golang/protobuf/ptypes/empty" @@ -216,15 +216,24 @@ func (k *azureNativeProvider) Configure(ctx context.Context, userAgent := k.getUserAgent() - azCoreTokenCredential := azCoreTokenCredential{p: k} + var credential azcore.TokenCredential + if enableAzcoreBackend() { + credential, err = k.newTokenCredential() + if err != nil { + return nil, fmt.Errorf("creating Pulumi auth credential: %w", err) + } + } else { + logging.V(9).Infof("Using legacy authentication") + credential = azCoreTokenCredential{p: k} + } - k.azureClient, err = k.newAzureClient(resourceManagerAuth, azCoreTokenCredential, userAgent) + k.azureClient, err = k.newAzureClient(resourceManagerAuth, credential, userAgent) if err != nil { return nil, fmt.Errorf("creating Azure client: %w", err) } k.customResources, err = customresources.BuildCustomResources(&env, k.azureClient, k.LookupResource, k.newCrudClient, k.subscriptionID, - resourceManagerBearerAuth, resourceManagerAuth, keyVaultBearerAuth, userAgent, azCoreTokenCredential) + resourceManagerBearerAuth, resourceManagerAuth, keyVaultBearerAuth, userAgent, credential) if err != nil { return nil, fmt.Errorf("initializing custom resources: %w", err) } @@ -237,25 +246,14 @@ func (k *azureNativeProvider) Configure(ctx context.Context, } func (k *azureNativeProvider) newAzureClient(armAuth autorest.Authorizer, tokenCred azcore.TokenCredential, userAgent string) (azure.AzureClient, error) { - if os.Getenv("PULUMI_USE_AUTOREST") == "false" { - logging.V(9).Infof("AzureClient: using azCore") - return azure.NewAzCoreClient(tokenCred, userAgent, k.getAzureCloud(), nil) + if enableAzcoreBackend() { + logging.V(9).Infof("AzureClient: using azcore and azidentity") + return azure.NewAzCoreClient(tokenCred, userAgent, azure.GetCloudByName(k.environment.Name), nil) } logging.V(9).Infof("AzureClient: using autorest") return azure.NewAzureClient(k.environment, armAuth, userAgent), nil } -func (k *azureNativeProvider) getAzureCloud() cloud.Configuration { - switch k.environment.Name { - case azureEnv.ChinaCloud.Name: - return cloud.AzureChina - case azureEnv.USGovernmentCloud.Name: - return cloud.AzureGovernment - default: - return cloud.AzurePublic - } -} - // Invoke dynamically executes a built-in function in the provider. func (k *azureNativeProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest) (*rpc.InvokeResponse, error) { label := fmt.Sprintf("%s.Invoke(%s)", k.name, req.Tok) @@ -297,11 +295,7 @@ func (k *azureNativeProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest if err != nil { return nil, fmt.Errorf("getting auth config: %w", err) } - endpoint := k.environment.ResourceManagerEndpoint - if endpointArg := args["endpoint"]; endpointArg.HasValue() && endpointArg.IsString() { - endpoint = endpointArg.StringValue() - } - token, err := k.getOAuthToken(ctx, auth, endpoint) + token, err := k.getClientToken(ctx, auth, args["endpoint"]) if err != nil { return nil, err } @@ -369,6 +363,43 @@ func (k *azureNativeProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest return &rpc.InvokeResponse{Return: result}, nil } +func (k *azureNativeProvider) getClientToken(ctx context.Context, authConfig *authConfig, endpointArg resource.PropertyValue) (string, error) { + endpoint := k.tokenEndpoint(endpointArg) + + if enableAzcoreBackend() { + cred, err := k.newTokenCredential() + if err != nil { + return "", err + } + t, err := cred.GetToken(ctx, tokenRequestOpts(endpoint)) + if err != nil { + return "", err + } + return t.Token, nil + } + + // legacy autorest/go-azure-helpers auth + return k.getOAuthToken(ctx, authConfig, endpoint) +} + +// Returns the Azure endpoint where tokens can be requested. If the argument is not null or empty, +// it will be used verbatim. +func (k *azureNativeProvider) tokenEndpoint(endpointArg resource.PropertyValue) string { + if endpointArg.HasValue() && endpointArg.IsString() && endpointArg.StringValue() != "" { + return endpointArg.StringValue() + } + return k.environment.ResourceManagerEndpoint +} + +func tokenRequestOpts(endpoint string) policy.TokenRequestOptions { + return policy.TokenRequestOptions{ + // "".default" is the well-defined scope for all resources accessible to the user or + // application. Despite the URL, it doesn't apply only to OIDC. + // https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc#the-default-scope + Scopes: []string{endpoint + "/.default"}, + } +} + func (k *azureNativeProvider) invokeResponseToOutputs(response any, res resources.AzureAPIInvoke) map[string]any { if responseMap, ok := response.(map[string]any); ok { // Map the raw response to the shape of outputs that the SDKs expect. @@ -1580,3 +1611,14 @@ func (k *azureNativeProvider) autorestEnvToHamiltonEnv() environments.Environmen return environments.Global } } + +// enableAzcoreBackend is a feature toggle that returns true if the newer backend using azcore and +// azidentity for REST and authentication should be used. Otherwise, the previous autorest backend +// is used. +// Tracked in epic #3576, the new backend was added to upgrade from unmaintained libraries that +// don't receive security and other updates. It uses the latest official Azure packages. +// The new backend is gated behind this feature toggle to allow enabling it selectively, +// limiting the blast radius of regressions. It's enabled in the daily CI workflow azcore-scheduled. +func enableAzcoreBackend() bool { + return os.Getenv("PULUMI_ENABLE_AZCORE_BACKEND") == "true" +} diff --git a/provider/pkg/provider/provider_test.go b/provider/pkg/provider/provider_test.go index b3d008c169f0..8c785d9a0a50 100644 --- a/provider/pkg/provider/provider_test.go +++ b/provider/pkg/provider/provider_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" + "github.com/Azure/go-autorest/autorest/azure" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/convert" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/provider/crud" "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/resources" @@ -400,21 +401,21 @@ func TestUsesCorrectAzureClient(t *testing.T) { p := azureNativeProvider{} t.Run("default", func(t *testing.T) { - t.Setenv("PULUMI_USE_AUTOREST", "") + t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "") client, err := p.newAzureClient(nil, &fake.TokenCredential{}, "pulumi") require.NoError(t, err) assert.Equal(t, "azureClientImpl", reflect.TypeOf(client).Elem().Name()) }) - t.Run("Autorest enabled", func(t *testing.T) { - t.Setenv("PULUMI_USE_AUTOREST", "true") + t.Run("Autorest and legacy auth disabled explicitly", func(t *testing.T) { + t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "false") client, err := p.newAzureClient(nil, &fake.TokenCredential{}, "pulumi") require.NoError(t, err) assert.Equal(t, "azureClientImpl", reflect.TypeOf(client).Elem().Name()) }) - t.Run("Autorest disabled", func(t *testing.T) { - t.Setenv("PULUMI_USE_AUTOREST", "false") + t.Run("Azcore enabled", func(t *testing.T) { + t.Setenv("PULUMI_ENABLE_AZCORE_BACKEND", "true") client, err := p.newAzureClient(nil, &fake.TokenCredential{}, "pulumi") require.NoError(t, err) assert.Equal(t, "azCoreClient", reflect.TypeOf(client).Elem().Name()) @@ -450,3 +451,50 @@ func (m *mockAzureClient) Put(ctx context.Context, id string, bodyProps map[stri func (m *mockAzureClient) IsNotFound(err error) bool { return false } + +func TestGetTokenEndpoint(t *testing.T) { + t.Parallel() + + t.Run("explicit", func(t *testing.T) { + t.Parallel() + p := azureNativeProvider{} + endpoint := p.tokenEndpoint(resource.NewStringProperty("https://management.azure.com/")) + assert.Equal(t, "https://management.azure.com/", endpoint) + }) + + t.Run("implicit public", func(t *testing.T) { + t.Parallel() + p := azureNativeProvider{ + environment: azure.PublicCloud, + } + endpoint := p.tokenEndpoint(resource.NewNullProperty()) + assert.Equal(t, "https://management.azure.com/", endpoint) + }) + + t.Run("implicit usgov", func(t *testing.T) { + t.Parallel() + p := azureNativeProvider{ + environment: azure.USGovernmentCloud, + } + endpoint := p.tokenEndpoint(resource.NewNullProperty()) + assert.Equal(t, "https://management.usgovcloudapi.net/", endpoint) + }) + + t.Run("implicit with empty string, public", func(t *testing.T) { + t.Parallel() + p := azureNativeProvider{ + environment: azure.PublicCloud, + } + endpoint := p.tokenEndpoint(resource.NewStringProperty("")) + assert.Equal(t, "https://management.azure.com/", endpoint) + }) +} + +func TestGetTokenRequestOpts(t *testing.T) { + t.Parallel() + + opts := tokenRequestOpts("http://endpoint") + assert.Empty(t, opts.Claims) + assert.Empty(t, opts.TenantID) + assert.Equal(t, []string{"http://endpoint/.default"}, opts.Scopes) +} diff --git a/provider/pkg/provider/test.pfx b/provider/pkg/provider/test.pfx new file mode 100644 index 000000000000..4a0d7881852a Binary files /dev/null and b/provider/pkg/provider/test.pfx differ diff --git a/provider/pkg/resources/customresources/customresources.go b/provider/pkg/resources/customresources/customresources.go index 8a2cc53d52fc..cf419f13e6d7 100644 --- a/provider/pkg/resources/customresources/customresources.go +++ b/provider/pkg/resources/customresources/customresources.go @@ -159,13 +159,13 @@ func BuildCustomResources(env *azureEnv.Environment, tokenAuth autorest.Authorizer, kvBearerAuth autorest.Authorizer, userAgent string, - tokenFactory azcore.TokenCredential) (map[string]*CustomResource, error) { + tokenCred azcore.TokenCredential) (map[string]*CustomResource, error) { kvClient := keyvault.New() kvClient.Authorizer = kvBearerAuth kvClient.UserAgent = userAgent - armKVClient, err := armkeyvault.NewVaultsClient(subscriptionID, tokenFactory, &arm.ClientOptions{}) + armKVClient, err := armkeyvault.NewVaultsClient(subscriptionID, tokenCred, &arm.ClientOptions{}) if err != nil { return nil, err }