diff --git a/backend/config.go b/backend/config.go index da55e8f04..0fcf1b1c7 100644 --- a/backend/config.go +++ b/backend/config.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "strconv" "strings" @@ -21,6 +22,7 @@ const ( SQLMaxIdleConnsDefault = "GF_SQL_MAX_IDLE_CONNS_DEFAULT" SQLMaxConnLifetimeSecondsDefault = "GF_SQL_MAX_CONN_LIFETIME_SECONDS_DEFAULT" ResponseLimit = "GF_RESPONSE_LIMIT" + AppClientSecret = "GF_PLUGIN_APP_CLIENT_SECRET" // nolint:gosec ) type configKey struct{} @@ -149,7 +151,11 @@ func (c *GrafanaCfg) proxy() (Proxy, error) { func (c *GrafanaCfg) AppURL() (string, error) { url, ok := c.config[AppURL] if !ok { - return "", fmt.Errorf("app URL not set in config. A more recent version of Grafana may be required") + // Fallback to environment variable for backwards compatibility + url = os.Getenv(AppURL) + if url == "" { + return "", errors.New("app URL not set in config. A more recent version of Grafana may be required") + } } return url, nil } @@ -246,6 +252,19 @@ func (c *GrafanaCfg) ResponseLimit() int64 { return i } +func (c *GrafanaCfg) PluginAppClientSecret() (string, error) { + value, ok := c.config[AppClientSecret] + if !ok { + // Fallback to environment variable for backwards compatibility + value = os.Getenv(AppClientSecret) + if value == "" { + return "", errors.New("PluginAppClientSecret not set in config") + } + } + + return value, nil +} + type userAgentKey struct{} // UserAgentFromContext returns user agent from context. diff --git a/backend/config_test.go b/backend/config_test.go index c34676360..ce99f301a 100644 --- a/backend/config_test.go +++ b/backend/config_test.go @@ -2,6 +2,7 @@ package backend import ( "context" + "os" "testing" "github.com/stretchr/testify/assert" @@ -194,6 +195,15 @@ func TestAppURL(t *testing.T) { _, err := cfg.AppURL() require.Error(t, err) }) + + t.Run("it should return the configured app URL from env", func(t *testing.T) { + os.Setenv(AppURL, "http://localhost-env:3000") + defer os.Unsetenv(AppURL) + cfg := NewGrafanaCfg(map[string]string{}) + v, err := cfg.AppURL() + require.NoError(t, err) + require.Equal(t, "http://localhost-env:3000", v) + }) } func TestUserAgentFromContext(t *testing.T) { @@ -328,3 +338,23 @@ func TestSql(t *testing.T) { require.ErrorContains(t, err, "not a valid integer") }) } + +func TestPluginAppClientSecret(t *testing.T) { + t.Run("it should return the configured PluginAppClientSecret", func(t *testing.T) { + cfg := NewGrafanaCfg(map[string]string{ + AppClientSecret: "client-secret", + }) + v, err := cfg.PluginAppClientSecret() + require.NoError(t, err) + require.Equal(t, "client-secret", v) + }) + + t.Run("it should return the configured PluginAppClientSecret from env", func(t *testing.T) { + os.Setenv(AppClientSecret, "client-secret") + defer os.Unsetenv(AppClientSecret) + cfg := NewGrafanaCfg(map[string]string{}) + v, err := cfg.PluginAppClientSecret() + require.NoError(t, err) + require.Equal(t, "client-secret", v) + }) +} diff --git a/experimental/oauthtokenretriever/sign.go b/experimental/oauthtokenretriever/sign.go deleted file mode 100644 index 5db854798..000000000 --- a/experimental/oauthtokenretriever/sign.go +++ /dev/null @@ -1,57 +0,0 @@ -package oauthtokenretriever - -import ( - "crypto/x509" - "encoding/pem" - "errors" - "fmt" - - "github.com/go-jose/go-jose/v3" - "github.com/go-jose/go-jose/v3/jwt" -) - -type signer interface { - sign(payload interface{}) (string, error) -} - -type jwtSigner struct { - signer jose.Signer -} - -// parsePrivateKey parses a PEM encoded private key. -func parsePrivateKey(pemBytes []byte) (signer, error) { - block, _ := pem.Decode(pemBytes) - if block == nil { - return nil, errors.New("crypto: no key found") - } - - var rawkey interface{} - var alg jose.SignatureAlgorithm - switch block.Type { - case "RSA PRIVATE KEY": - alg = jose.RS256 - rsa, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return nil, err - } - rawkey = rsa - case "PRIVATE KEY": - alg = jose.ES256 - ecdsa, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return nil, err - } - rawkey = ecdsa - default: - return nil, fmt.Errorf("crypto: unsupported private key type %q", block.Type) - } - s, err := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: rawkey}, &jose.SignerOptions{}) - if err != nil { - return nil, err - } - return &jwtSigner{signer: s}, nil -} - -func (s *jwtSigner) sign(payload interface{}) (string, error) { - return jwt.Signed(s.signer).Claims(payload).CompactSerialize() -} diff --git a/experimental/oauthtokenretriever/sign_test.go b/experimental/oauthtokenretriever/sign_test.go deleted file mode 100644 index 8b212e9cb..000000000 --- a/experimental/oauthtokenretriever/sign_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package oauthtokenretriever - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -const ( - //nolint: gosec - // gosec warning G101 can be ignored because this is just test data - testRSAKey = `-----BEGIN RSA PRIVATE KEY----- -MIICWwIBAAKBgQC35vznv35Kaby20gu+RQBDj/kHhPd64b6p9TKKxqiAs8kukNFj -Q8keR6MOO41Md0Jh4b/ZSo1O3C3K3K587NORJDWz0H2wVyTWDvSMI36nI/EnGDhh -4fImv5E/9jIvhOxCJ3Dej57//tMt8TEG1ZETrAKzUvB7EfCfsnazGraMQwIDAQAB -AoGAfbFh4B+w+LlGY4oyvow4vvTTV4FZCOLsRwuwzMs09iprcelHQ9pbxtddqeeo -DsBgXbhHQQPEi0bQAZxNolLX0m4nQ8n9H6by42qOJlwywYZIl7Di3aWYiOiT56v7 -PfqCsShSqsvWH8Ok4Jy6/Vcc4QcO4mGi8y8EZdSqfytGvkkCQQDhO+1Y4x36ETAh -NOQx1E/psPuSH8H6YeDoWYeap5z1KXzN4eTo01p8ckPSD93uXIig7LmfIWPMqlGV -yOBSyqD/AkEA0QXBLeDksi8hX8B2XOMfY9hWOBwBRXrlKX6TVF/9Kw+ulJpe3sU5 -lc53oytpk1VwXAfJrjNRqyIIIRnFyTJQvQJAMBgFxFcqzXziFBUhLOqy7amW7krN -ttMznSmQ5RspTsg/GA9GO9j1l2EmzjIJJ56mpgYmVK5iiw9LQHqWO9d8rQJASUDz -CtkeTTQnRh91W+hdP+i5jsCB0Y/YcEpj59YcK9M7I+lWBkyoec/6Lb0xKuluj1JL -ZDmoDYnHv5IAtxpjIQJASxC/V51AHfuQ+rWvbZ6jzoHW6owbFpC2RbZPtFanOlda -ozjy/YI5hvWLr/bre/wZ3N81pLA9lPgEpJiOPYem3Q== ------END RSA PRIVATE KEY----- -` - - //nolint: gosec - // gosec warning G101 can be ignored because this is just test data - testECDSAKey = `-----BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYH3q1su2TRDIr4RB -2okegCNvfhn/Q9CycAXtPnfYsZehRANCAARSs6LcDI314KqKqGHbv2FLGoMXjm6B -p6/mP7VLRqyPpiGmhCEKXD5R/695X5JYQRBF34hn2XZpMCW2z2Lr+d6s ------END PRIVATE KEY----- -` -) - -func Test_Sign(t *testing.T) { - for _, test := range []struct { - name string - key string - length int - }{ - {"RSA", testRSAKey, 196}, - {"ECDSA", testECDSAKey, 111}, - } { - t.Run(test.name, func(t *testing.T) { - signer, err := parsePrivateKey([]byte(test.key)) - assert.NoError(t, err) - signed, err := signer.sign(map[string]interface{}{}) - assert.NoError(t, err) - assert.Equal(t, test.length, len(signed)) - }) - } -} diff --git a/experimental/oauthtokenretriever/token.go b/experimental/oauthtokenretriever/token.go deleted file mode 100644 index c5ef0e3c7..000000000 --- a/experimental/oauthtokenretriever/token.go +++ /dev/null @@ -1,116 +0,0 @@ -package oauthtokenretriever - -import ( - "context" - "fmt" - "net/url" - "os" - "strings" - "time" - - "github.com/google/uuid" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "golang.org/x/oauth2" - "golang.org/x/oauth2/clientcredentials" -) - -const ( - AppClientID = "GF_PLUGIN_APP_CLIENT_ID" - AppPrivateKey = "GF_PLUGIN_APP_PRIVATE_KEY" - // nolint:gosec - // AppClientSecret constant represents a string index value for the secret, not the secret itself. - AppClientSecret = "GF_PLUGIN_APP_CLIENT_SECRET" -) - -type TokenRetriever interface { - OnBehalfOfUser(ctx context.Context, userID string) (string, error) - Self(ctx context.Context) (string, error) -} - -type tokenRetriever struct { - signer signer - conf *clientcredentials.Config -} - -// tokenPayload returns a JWT payload for the given user ID, client ID, and host. -func (t *tokenRetriever) tokenPayload(userID string) map[string]interface{} { - iat := time.Now().Unix() - exp := iat + 1800 - u := uuid.New() - payload := map[string]interface{}{ - "iss": t.conf.ClientID, - "sub": fmt.Sprintf("user:id:%s", userID), - "aud": t.conf.TokenURL, - "exp": exp, - "iat": iat, - "jti": u.String(), - } - return payload -} - -func (t *tokenRetriever) Self(ctx context.Context) (string, error) { - t.conf.EndpointParams = url.Values{} - tok, err := t.conf.TokenSource(ctx).Token() - if err != nil { - return "", err - } - return tok.AccessToken, nil -} - -func (t *tokenRetriever) OnBehalfOfUser(ctx context.Context, userID string) (string, error) { - signed, err := t.signer.sign(t.tokenPayload(userID)) - if err != nil { - return "", err - } - - t.conf.EndpointParams = url.Values{ - "grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, - "assertion": {signed}, - } - tok, err := t.conf.TokenSource(ctx).Token() - if err != nil { - return "", err - } - - return tok.AccessToken, nil -} - -func New() (TokenRetriever, error) { - // The Grafana URL is required to obtain tokens later on - grafanaAppURL := strings.TrimRight(os.Getenv(backend.AppURL), "/") - if grafanaAppURL == "" { - // For debugging purposes only - grafanaAppURL = "http://localhost:3000" - } - - clientID := os.Getenv(AppClientID) - if clientID == "" { - return nil, fmt.Errorf("GF_PLUGIN_APP_CLIENT_ID is required") - } - - clientSecret := os.Getenv(AppClientSecret) - if clientSecret == "" { - return nil, fmt.Errorf("GF_PLUGIN_APP_CLIENT_SECRET is required") - } - - privateKey := os.Getenv(AppPrivateKey) - if privateKey == "" { - return nil, fmt.Errorf("GF_PLUGIN_APP_PRIVATE_KEY is required") - } - - signer, err := parsePrivateKey([]byte(privateKey)) - if err != nil { - return nil, err - } - - return &tokenRetriever{ - signer: signer, - conf: &clientcredentials.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - TokenURL: grafanaAppURL + "/oauth2/token", - AuthStyle: oauth2.AuthStyleInParams, - Scopes: []string{"profile", "email", "entitlements"}, - }, - }, nil -} diff --git a/experimental/oauthtokenretriever/token_test.go b/experimental/oauthtokenretriever/token_test.go deleted file mode 100644 index 7cbd66dd5..000000000 --- a/experimental/oauthtokenretriever/token_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package oauthtokenretriever - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test_GetExternalServiceToken(t *testing.T) { - for _, test := range []struct { - name string - userID string - }{ - {"On Behalf Of", "1"}, - {"Service account", ""}, - } { - t.Run(test.name, func(t *testing.T) { - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, err := io.ReadAll(r.Body) - assert.NoError(t, err) - if test.userID != "" { - assert.Contains(t, string(b), "assertion=") - assert.Contains(t, string(b), "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer") - } else { - assert.NotContains(t, string(b), "assertion=") - assert.Contains(t, string(b), "grant_type=client_credentials") - } - assert.Contains(t, string(b), "client_id=test_client_id") - assert.Contains(t, string(b), "client_secret=test_client_secret") - - w.Header().Set("Content-Type", "application/json") - _, err = w.Write([]byte(`{"access_token":"test_token"}`)) - assert.NoError(t, err) - })) - defer s.Close() - - os.Setenv("GF_APP_URL", s.URL) - defer os.Unsetenv("GF_APP_URL") - os.Setenv("GF_PLUGIN_APP_CLIENT_ID", "test_client_id") - defer os.Unsetenv("GF_PLUGIN_APP_CLIENT_ID") - os.Setenv("GF_PLUGIN_APP_CLIENT_SECRET", "test_client_secret") - defer os.Unsetenv("GF_PLUGIN_APP_CLIENT_SECRET") - os.Setenv("GF_PLUGIN_APP_PRIVATE_KEY", testECDSAKey) - defer os.Unsetenv("GF_PLUGIN_APP_PRIVATE_KEY") - - ss, err := New() - assert.NoError(t, err) - - var token string - if test.userID != "" { - token, err = ss.OnBehalfOfUser(context.Background(), test.userID) - } else { - token, err = ss.Self(context.Background()) - } - assert.NoError(t, err) - assert.Equal(t, "test_token", token) - }) - } -} diff --git a/experimental/testdata/folder.golden.txt b/experimental/testdata/folder.golden.txt index aaca2da54..9f364e870 100644 --- a/experimental/testdata/folder.golden.txt +++ b/experimental/testdata/folder.golden.txt @@ -9,7 +9,7 @@ Frame[0] { "pathSeparator": "/" } Name: -Dimensions: 2 Fields by 21 Rows +Dimensions: 2 Fields by 20 Rows +----------------+------------------+ | Name: name | Name: media-type | | Labels: | Labels: | @@ -29,4 +29,4 @@ Dimensions: 2 Fields by 21 Rows ====== TEST DATA RESPONSE (arrow base64) ====== -FRAME=QVJST1cxAAD/////yAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEEAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAATAAAACgAAAAEAAAAwP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADg/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAD///8IAAAAUAAAAEQAAAB7InR5cGUiOiJkaXJlY3RvcnktbGlzdGluZyIsInR5cGVWZXJzaW9uIjpbMCwwXSwicGF0aFNlcGFyYXRvciI6Ii8ifQAAAAAEAAAAbWV0YQAAAAACAAAAeAAAAAQAAACi////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAAJD///8IAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAACI////CgAAAG1lZGlhLXR5cGUAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAASAAAAAAAAAVEAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAAAEAAQABAAAAAQAAABuYW1lAAAAAP/////YAAAAFAAAAAAAAAAMABYAFAATAAwABAAMAAAAOAIAAAAAAAAUAAAAAAAAAwQACgAYAAwACAAEAAoAAAAUAAAAeAAAABUAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABYAAAAAAAAAFgAAAAAAAAACgEAAAAAAABoAQAAAAAAAAAAAAAAAAAAaAEAAAAAAABYAAAAAAAAAMABAAAAAAAAdQAAAAAAAAAAAAAAAgAAABUAAAAAAAAAAAAAAAAAAAAVAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAAQAAAAFAAAAB4AAAAsAAAALwAAADoAAABIAAAAUwAAAGMAAAByAAAAhgAAAKAAAAC/AAAAygAAANAAAADUAAAA5wAAAPUAAAACAQAACgEAAFJFQURNRS5tZGFjdGlvbnNhcGlzYXV0aGNsaWVudGRhdGFzb3VyY2V0ZXN0ZTJlZXJyb3Jzb3VyY2VmZWF0dXJldG9nZ2xlc2ZpbGVpbmZvLmdvZmlsZWluZm9fdGVzdC5nb2ZyYW1lX3NvcnRlci5nb2ZyYW1lX3NvcnRlcl90ZXN0LmdvZ29sZGVuX3Jlc3BvbnNlX2NoZWNrZXIuZ29nb2xkZW5fcmVzcG9uc2VfY2hlY2tlcl90ZXN0LmdvaHR0cF9sb2dnZXJtYWNyb3Ntb2Nrb2F1dGh0b2tlbnJldHJpZXZlcnJlc3RfY2xpZW50Lmdvc2NoZW1hYnVpbGRlcnRlc3RkYXRhAAAAAAAAAAAAAAAAAAAJAAAAEgAAABsAAAAkAAAALQAAADYAAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAASAAAAFEAAABaAAAAYwAAAGMAAABsAAAAdQAAAGRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeWRpcmVjdG9yeQAAABAAAAAMABQAEgAMAAgABAAMAAAAEAAAACwAAAA8AAAAAAAEAAEAAADYAQAAAAAAAOAAAAAAAAAAOAIAAAAAAAAAAAAAAAAAAAAAAAAAAAoADAAAAAgABAAKAAAACAAAALgAAAADAAAATAAAACgAAAAEAAAAwP7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADg/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAAAD///8IAAAAUAAAAEQAAAB7InR5cGUiOiJkaXJlY3RvcnktbGlzdGluZyIsInR5cGVWZXJzaW9uIjpbMCwwXSwicGF0aFNlcGFyYXRvciI6Ii8ifQAAAAAEAAAAbWV0YQAAAAACAAAAeAAAAAQAAACi////FAAAADwAAAA8AAAAAAAABTgAAAABAAAABAAAAJD///8IAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAACI////CgAAAG1lZGlhLXR5cGUAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAASAAAAAAAAAVEAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAYAAABzdHJpbmcAAAYAAAB0c3R5cGUAAAAAAAAEAAQABAAAAAQAAABuYW1lAAAAAPgBAABBUlJPVzE= +FRAME=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