diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73cda5b..0bf2b60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,10 @@ on: branches: [ "main" ] workflow_dispatch: +permissions: + id-token: write + contents: read + jobs: ci: strategy: @@ -22,6 +26,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 + with: + submodules: true - name: Set up Go uses: actions/setup-go@v3 @@ -34,20 +40,7 @@ jobs: - name: Install gotestsum run: go install gotest.tools/gotestsum@latest - - name: Test (PR) - if: ${{ github.event_name == 'pull_request' }} - run: > - gotestsum - --format testname - -- - -race - -coverprofile='coverage.txt' - -covermode=atomic - -coverpkg='./...' - ./... - - - name: Test (Full) - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' }} + - name: Test run: > gotestsum --format testname diff --git a/cmd/config.go b/cmd/config.go index e35448c..8782e03 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -6,25 +6,36 @@ import ( "github.com/weikanglim/ksd/internal/ksd" ) +var clientId string +var clientSecret string +var credentialProvider string +var tenantId string +var endpoint string + func GetCredentialOptionsFromFlags() (ksd.CredentialOptions, error) { opts := ksd.CredentialOptions{} if clientId != "" { - if clientSecret == "" { - return opts, errors.New("`--client-secret` must be set when `--client-id` is provided") - } - if tenantId == "" { return opts, errors.New("`--tenant-id` must be set when `--client-id` is provided") } + if clientSecret == "" && credentialProvider == "" { + return opts, errors.New("`--client-secret` or `--credential-provider` must be set when `--client-id` is provided") + } + opts.ClientId = clientId opts.ClientSecret = clientSecret opts.TenantId = tenantId + opts.CredentialProvider = credentialProvider } else { if clientSecret != "" { return opts, errors.New("`--client-id` must be set when `--client-secret` is provided") } + if credentialProvider != "" { + return opts, errors.New("`--client-id` must be set when `--credential-provider` is provided") + } + if tenantId != "" { return opts, errors.New("`--client-id` must be set when `--tenant-id` is provided") } diff --git a/cmd/run.go b/cmd/run.go index 6c30ade..d4fc1c7 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -23,7 +23,7 @@ func NewRunCmd() *cobra.Command { `), Example: heredoc.Doc(` # Run a script file - $ ksd run ./script.ksl + $ ksd run ./script.ksl --endpoint https://.kusto.windows.net/ `), RunE: func(cmd *cobra.Command, args []string) error { if endpoint == "" { @@ -55,6 +55,7 @@ func NewRunCmd() *cobra.Command { runCmd.Flags().StringVar(&clientId, "client-id", "", "The ID of the application to authenticate with") runCmd.Flags().StringVar(&clientSecret, "client-secret", "", "The secret of the application to authenticate with") runCmd.Flags().StringVar(&tenantId, "tenant-id", "", "The tenant ID of the application to authenticate with") + runCmd.Flags().StringVar(&credentialProvider, "credential-provider", "", "The credential provider to use instead of client-secret. Allowed values: github") return runCmd } diff --git a/cmd/sync.go b/cmd/sync.go index 416243b..58e579d 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -13,11 +13,6 @@ import ( "github.com/weikanglim/ksd/internal/ksd" ) -var clientId string -var clientSecret string -var tenantId string -var endpoint string - func NewSyncCommand() *cobra.Command { var fromOut string var syncCmd = &cobra.Command{ @@ -35,7 +30,10 @@ func NewSyncCommand() *cobra.Command { $ ksd sync --endpoint https://.kusto.windows.net/ # Sync using aad app credentials. Recommended for CI workflows. - $ ksd sync --endpoint https://.kusto.windows.net/--client-id --client-secret --tenantId + $ ksd sync --endpoint https://.kusto.windows.net/ --client-id --client-secret --tenantId + + # Sync using GitHub OIDC credentials. Recommended for CI workflows. + $ ksd sync --endpoint https://.kusto.windows.net/ --client-id --credential-provider github --tenantId `), RunE: func(cmd *cobra.Command, args []string) error { root, err := os.Getwd() @@ -116,6 +114,7 @@ func NewSyncCommand() *cobra.Command { syncCmd.Flags().StringVar(&clientId, "client-id", "", "The ID of the application to authenticate with") syncCmd.Flags().StringVar(&clientSecret, "client-secret", "", "The secret of the application to authenticate with") syncCmd.Flags().StringVar(&tenantId, "tenant-id", "", "The tenant ID of the application to authenticate with") + syncCmd.Flags().StringVar(&credentialProvider, "credential-provider", "", "The credential provider to use instead of client-secret. Allowed values: github") return syncCmd } diff --git a/go.mod b/go.mod index 1e713b9..bfad6df 100644 --- a/go.mod +++ b/go.mod @@ -3,30 +3,31 @@ module github.com/weikanglim/ksd go 1.20 require ( - github.com/Azure/azure-kusto-go v0.11.4-0.20230330134018-302957036361 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2 + github.com/Azure/azure-kusto-go v0.14.2 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.2 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 + github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.6.1 - github.com/stretchr/testify v1.8.2 + github.com/stretchr/testify v1.8.4 ) require ( - github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v0.9.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect + github.com/google/uuid v1.3.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/samber/lo v1.37.0 // indirect + github.com/samber/lo v1.38.1 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/crypto v0.14.0 // indirect - golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect diff --git a/go.sum b/go.sum index a64f305..f76371d 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -github.com/Azure/azure-kusto-go v0.11.4-0.20230330134018-302957036361 h1:UJVZ9i0S8rIdLicjCQgshhhPUMIW1TJNLZ89XHbN5vI= -github.com/Azure/azure-kusto-go v0.11.4-0.20230330134018-302957036361/go.mod h1:AyWTO0r50y7rAkxkTveLtn80njNyXpJP5WADvoSZ/P4= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2 h1:uqM+VoHjVH6zdlkLF2b6O0ZANcHoj3rO0PoQ3jglUJA= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2/go.mod h1:twTKAa1E6hLmSDjLhaCkbTMQKc7p/rNLU40rLxGEOCI= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0 h1:leh5DwKv6Ihwi+h60uHtn6UWAxBbZ0q8DwQVMzf61zw= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= -github.com/AzureAD/microsoft-authentication-library-for-go v0.9.0 h1:UE9n9rkJF62ArLb1F3DEjRt8O3jLwMWdSoypKV4f3MU= -github.com/AzureAD/microsoft-authentication-library-for-go v0.9.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= +github.com/Azure/azure-kusto-go v0.14.2 h1:dkdHggCp14TCPLHPkWOiWZWWdnNXlaqNfpW6YC/Xnxo= +github.com/Azure/azure-kusto-go v0.14.2/go.mod h1:twZbo+gYmZPDzzMOqExT7rEZ6kyKFvZxqUl3DoTwaIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.2 h1:t5+QXLCK9SVi0PPdaY0PrFvYUo24KwA0QwxnaHRSVd4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.2/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1 h1:LNHhpdK7hzUcx/k1LIcuh5k7k1LGIWLQfCjaneSj7Fc= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 h1:hVeq+yCyUi+MsoO/CU95yqCIcdzra5ovzk8Q2BBpV2M= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= @@ -16,14 +16,16 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= @@ -31,8 +33,8 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzL github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/samber/lo v1.37.0 h1:XjVcB8g6tgUp8rsPsJ2CvhClfImrpL04YpQHXeHPhRw= -github.com/samber/lo v1.37.0/go.mod h1:9vaz2O4o8oOnK23pd2TrXufcbdbJIa3b6cstBWKpopA= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= @@ -41,18 +43,14 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= -golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/ksd/client.go b/internal/ksd/client.go new file mode 100644 index 0000000..e70e2ae --- /dev/null +++ b/internal/ksd/client.go @@ -0,0 +1,205 @@ +package ksd + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + "github.com/Azure/azure-kusto-go/kusto" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +const ( + CredProviderGithub = "github" +) + +// Credential to use to connect to a Kusto database. +// When set to the default empty struct, +// DefaultAzureCredential will be used, which typically +// relies on authentication from CLIs like `az`. +type CredentialOptions struct { + // Authenticate using client ID. + ClientId string + // Tenant ID for authentication. + TenantId string + // Client secret. + ClientSecret string + // Credential provider + CredentialProvider string +} + +type kustoClient interface { + Mgmt(ctx context.Context, db string, query kusto.Statement, options ...kusto.MgmtOption) (*kusto.RowIterator, error) + Close() error +} + +type connection struct { + endpoint string + db string +} + +func parseEndpoint(endpoint string) (connection, error) { + endpointUrl, err := url.Parse(endpoint) + if err != nil { + return connection{}, fmt.Errorf("invalid endpoint: %w", err) + } + + if endpointUrl.Path == "" { + return connection{}, fmt.Errorf( + "endpoint must target a database, and not a cluster. Does the endpoint end with the database name?") + } + + db := strings.TrimPrefix(endpointUrl.Path, "/") + endpointUrl.Path = "" + + return connection{ + db: db, + endpoint: endpointUrl.String(), + }, nil +} + +// newKustoClient creates a kusto client using the specified +// endpoint and credentials. +func newKustoClient( + endpoint string, + cred CredentialOptions, + transport *http.Client) (kustoClient, error) { + connection := kusto.NewConnectionStringBuilder(endpoint) + if cred.ClientId != "" { + if cred.TenantId == "" { + return nil, errors.New("tenant id must be provided") + } + + if cred.ClientSecret == "" && cred.CredentialProvider == "" { + return nil, errors.New("client secret must be provided") + } + + if cred.CredentialProvider != "" { + cred, err := newFederatedCredential(cred.TenantId, cred.ClientId, cred.CredentialProvider) + if err != nil { + return nil, fmt.Errorf("creating federated credential: %w", err) + } + connection = connection.WithTokenCredential(cred) + } else { + connection = connection.WithAadAppKey(cred.ClientId, cred.ClientSecret, cred.TenantId) + } + } else { + forceInteractive := false + forceAuthEnv, has := os.LookupEnv("KSD_FORCE_INTERACTIVE_AUTH") + if has { + parsed, err := strconv.ParseBool(forceAuthEnv) + if err != nil { + return nil, fmt.Errorf( + "invalid value for KSD_FORCE_INTERACTIVE_AUTH: '%s'. expected truthy value: 1, true, TRUE, 0, false, FALSE", forceAuthEnv) + } + forceInteractive = parsed + } + // first, verify if azure default credential is available + credAvailable, err := verifyDefaultAzureCredential(cred) + if err != nil { + log.Printf("auth: enabling interactive logon, default credential not available with error: %v", err) + } + + if forceInteractive || !credAvailable { + log.Println("auth: using interactive logon") + connection = connection.WithInteractiveLogin(cred.TenantId) + } else { + log.Println("auth: using default credential") + connection.AuthorityId = cred.TenantId + connection = connection.WithDefaultAzureCredential() + } + } + + client, err := kusto.New(connection, kusto.WithHttpClient(transport)) + if err != nil { + return nil, fmt.Errorf("creating kusto client: %w", err) + } + return client, nil +} + +func newFederatedCredential( + tenantID string, + clientID string, + provider string, +) (azcore.TokenCredential, error) { + if provider != CredProviderGithub { + return nil, fmt.Errorf("unsupported credential provider: '%s'", string(provider)) + } + + options := &azidentity.ClientAssertionCredentialOptions{} + cred, err := azidentity.NewClientAssertionCredential( + tenantID, + clientID, + func(ctx context.Context) (string, error) { + federatedToken, err := githubToken(ctx, "api://AzureADTokenExchange") + if err != nil { + return "", fmt.Errorf("fetching federated token: %w", err) + } + + return federatedToken, nil + }, + options) + if err != nil { + return nil, fmt.Errorf("creating credential: %w", err) + } + + return cred, nil +} + +// githubToken gets the credential token from GitHub Actions +func githubToken(ctx context.Context, audience string) (string, error) { + idTokenUrl, has := os.LookupEnv("ACTIONS_ID_TOKEN_REQUEST_URL") + if !has { + return "", errors.New("ACTIONS_ID_TOKEN_REQUEST_URL is unset") + } + + if audience != "" { + idTokenUrl = fmt.Sprintf("%s&audience=%s", idTokenUrl, url.QueryEscape(audience)) + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, idTokenUrl) + if err != nil { + return "", fmt.Errorf("building request: %w", err) + } + + token, has := os.LookupEnv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if !has { + return "", errors.New("ACTIONS_ID_TOKEN_REQUEST_TOKEN is unset") + } + req.Raw().Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + res, err := runtime.NewPipeline("", "", runtime.PipelineOptions{}, nil).Do(req) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer res.Body.Close() + + if !runtime.HasStatusCode(res, http.StatusOK) { + return "", fmt.Errorf("expected 200 response, got: %d", res.StatusCode) + } + + type tokenResponse struct { + Value string `json:"value"` + } + + tokenResp := tokenResponse{} + err = runtime.UnmarshalAsJSON(res, &tokenResp) + if err != nil { + return "", fmt.Errorf("reading body: %w", err) + } + + if tokenResp.Value == "" { + return "", errors.New("no token in response") + } + + return tokenResp.Value, nil +} diff --git a/internal/ksd/sync.go b/internal/ksd/sync.go index 9b2668d..c16949d 100644 --- a/internal/ksd/sync.go +++ b/internal/ksd/sync.go @@ -2,117 +2,19 @@ package ksd import ( "context" - "errors" "fmt" "io/fs" "log" "net/http" - "net/url" "os" "path/filepath" - "strconv" - "strings" - "github.com/Azure/azure-kusto-go/kusto" "github.com/Azure/azure-kusto-go/kusto/kql" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" ) -// Credential to use to connect to a Kusto database. -// When set to the default empty struct, -// DefaultAzureCredential will be used, which typically -// relies on authentication from CLIs like `az`. -type CredentialOptions struct { - // Authenticate using client ID. - ClientId string - // Tenant ID for authentication. - TenantId string - // Client secret. - ClientSecret string -} - -type kustoClient interface { - Mgmt(ctx context.Context, db string, query kusto.Statement, options ...kusto.MgmtOption) (*kusto.RowIterator, error) - Close() error -} - -type connection struct { - endpoint string - db string -} - -func parseEndpoint(endpoint string) (connection, error) { - endpointUrl, err := url.Parse(endpoint) - if err != nil { - return connection{}, fmt.Errorf("invalid --endpoint: %w", err) - } - - if endpointUrl.Path == "" { - return connection{}, fmt.Errorf( - "endpoint must target a database, and not a cluster. Does the endpoint end with the database name?") - } - - db := strings.TrimPrefix(endpointUrl.Path, "/") - return connection{ - db: db, - endpoint: endpoint, - }, nil -} - -// newKustoClient creates a kusto client using the specified -// endpoint and credentials. -func newKustoClient( - endpoint string, - cred CredentialOptions, - transport *http.Client) (kustoClient, error) { - connection := kusto.NewConnectionStringBuilder(endpoint) - if cred.ClientId != "" { - if cred.ClientSecret == "" { - return nil, errors.New("client secret must be provided") - } - - if cred.TenantId == "" { - return nil, errors.New("tenant id must be provided") - } - - connection = connection.WithAadAppKey( - cred.ClientId, cred.ClientSecret, cred.TenantId) - } else { - forceInteractive := false - forceAuthEnv, has := os.LookupEnv("KSD_FORCE_INTERACTIVE_AUTH") - if has { - parsed, err := strconv.ParseBool(forceAuthEnv) - if err != nil { - return nil, fmt.Errorf( - "invalid value for KSD_FORCE_INTERACTIVE_AUTH: '%s'. expected truthy value: 1, true, TRUE, 0, false, FALSE", forceAuthEnv) - } - forceInteractive = parsed - } - // first, verify if azure default credential is available - credAvailable, err := verifyDefaultAzureCredential(cred) - if err != nil { - log.Printf("auth: enabling interactive logon, default credential not available with error: %v", err) - } - - if forceInteractive || !credAvailable { - log.Println("auth: using interactive logon") - connection = connection.WithInteractiveLogin(cred.TenantId) - } else { - log.Println("auth: using default credential") - connection.AuthorityId = cred.TenantId - connection = connection.WithDefaultAzureCredential() - } - } - - client, err := kusto.New(connection, kusto.WithHttpClient(transport)) - if err != nil { - return nil, fmt.Errorf("creating kusto client: %w", err) - } - return client, nil -} - func Sync( root string, endpoint string, diff --git a/test/run_test.go b/test/run_test.go index b61eea2..0f74f42 100644 --- a/test/run_test.go +++ b/test/run_test.go @@ -14,7 +14,7 @@ func TestRun_Live(t *testing.T) { } runArgs := []string{"run"} - runArgs = append(runArgs, argsFromConfig(cfg)...) + runArgs = append(runArgs, argsFromConfig(cfg, true)...) tests := []struct { name string diff --git a/test/sync_test.go b/test/sync_test.go index 5c6f965..5e1359f 100644 --- a/test/sync_test.go +++ b/test/sync_test.go @@ -5,6 +5,7 @@ import ( "os" "testing" + "github.com/joho/godotenv" "github.com/stretchr/testify/require" ) @@ -34,12 +35,12 @@ func TestSync_Errors(t *testing.T) { { "ClientAuth_MissingSecretAndTenant", []string{"sync", "--client-id", "some-id", "--endpoint", anyEndpoint}, - "`--client-secret` must be set", + "`--tenant-id` must be set when `--client-id` is provided", }, { "ClientAuth_MissingSecret", []string{"sync", "--client-id", "some-id", "--tenant-id", "some-tenant", "--endpoint", anyEndpoint}, - "`--client-secret` must be set", + "`--client-secret` or `--credential-provider` must be set when `--client-id` is provided", }, { "ClientAuth_MissingTenantId", @@ -79,7 +80,7 @@ func TestSync_Live(t *testing.T) { } syncArgs := []string{"sync"} - syncArgs = append(syncArgs, argsFromConfig(cfg)...) + syncArgs = append(syncArgs, argsFromConfig(cfg, false)...) tests := []struct { name string @@ -121,23 +122,32 @@ func TestSync_Live(t *testing.T) { } } -func argsFromConfig(cfg clientConfig) []string { +func argsFromConfig(cfg clientConfig, useSecret bool) []string { if cfg.defaultAuth { return []string{ "--endpoint", cfg.endpoint, } } else { - return []string{ + res := []string{ "--client-id", cfg.clientId, - "--client-secret", - cfg.clientSecret, "--tenant-id", cfg.tenantId, "--endpoint", cfg.endpoint, } + if useSecret { + res = append(res, + "--client-secret", + cfg.clientSecret) + } else { + res = append(res, + "--credential-provider", + "github") + } + + return res } } @@ -151,6 +161,10 @@ type clientConfig struct { endpoint string } +func init() { + _ = godotenv.Load(".env") +} + func getLiveConfig() (clientConfig, error) { endpoint := os.Getenv("KSD_TEST_ENDPOINT")