mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugins: Support templated urls in routes (#16599)
This adds support for using templated/dynamic urls in routes. * refactor interpolateString into utils and add interpolation support for app plugin routes. * cleanup and add error check for url parse failure * add docs for interpolated route urls Closes #16835
This commit is contained in:
parent
336655a46a
commit
b07d0b1026
@ -51,6 +51,36 @@ then the Grafana proxy will transform it into "https://management.azure.com/foo/
|
|||||||
|
|
||||||
The `method` parameter is optional. It can be set to any HTTP verb to provide more fine-grained control.
|
The `method` parameter is optional. It can be set to any HTTP verb to provide more fine-grained control.
|
||||||
|
|
||||||
|
### Dynamic Routes
|
||||||
|
|
||||||
|
When using routes, you can also reference a variable stored in JsonData or SecureJsonData which will be interpolated when connecting to the datasource.
|
||||||
|
|
||||||
|
With JsonData:
|
||||||
|
```json
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"path": "custom/api/v5/*",
|
||||||
|
"method": "*",
|
||||||
|
"url": "{{.JsonData.dynamicUrl}}",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
With SecureJsonData:
|
||||||
|
```json
|
||||||
|
"routes": [{
|
||||||
|
"path": "custom/api/v5/*",
|
||||||
|
"method": "*",
|
||||||
|
"url": "{{.SecureJsonData.dynamicUrl}}",
|
||||||
|
...
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
In the above example, the app is able to set the value for `dynamicUrl` in JsonData or SecureJsonData and it will be replaced on-demand.
|
||||||
|
|
||||||
|
An app using this feature can be found [here](https://github.com/grafana/kentik-app).
|
||||||
|
|
||||||
## Encrypting Sensitive Data
|
## Encrypting Sensitive Data
|
||||||
|
|
||||||
When a user saves a password or secret with your datasource plugin's Config page, then you can save data to a column in the datasource table called `secureJsonData` that is an encrypted blob. Any data saved in the blob is encrypted by Grafana and can only be decrypted by the Grafana server on the backend. This means once a password is saved, no sensitive data is sent to the browser. If the password is saved in the `jsonData` blob or the `password` field then it is unencrypted and anyone with Admin access (with the help of Chrome Developer Tools) can read it.
|
When a user saves a password or secret with your datasource plugin's Config page, then you can save data to a column in the datasource table called `secureJsonData` that is an encrypted blob. Any data saved in the blob is encrypted by Grafana and can only be decrypted by the Grafana server on the backend. This means once a password is saved, no sensitive data is sent to the browser. If the password is saved in the `jsonData` blob or the `password` field then it is unencrypted and anyone with Admin access (with the help of Chrome Developer Tools) can read it.
|
||||||
|
@ -67,14 +67,14 @@ func (provider *accessTokenProvider) getAccessToken(data templateData) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
urlInterpolated, err := interpolateString(provider.route.TokenAuth.Url, data)
|
urlInterpolated, err := InterpolateString(provider.route.TokenAuth.Url, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := make(url.Values)
|
params := make(url.Values)
|
||||||
for key, value := range provider.route.TokenAuth.Params {
|
for key, value := range provider.route.TokenAuth.Params {
|
||||||
interpolatedParam, err := interpolateString(value, data)
|
interpolatedParam, err := InterpolateString(value, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -119,7 +119,7 @@ func (provider *accessTokenProvider) getJwtAccessToken(ctx context.Context, data
|
|||||||
conf := &jwt.Config{}
|
conf := &jwt.Config{}
|
||||||
|
|
||||||
if val, ok := provider.route.JwtTokenAuth.Params["client_email"]; ok {
|
if val, ok := provider.route.JwtTokenAuth.Params["client_email"]; ok {
|
||||||
interpolatedVal, err := interpolateString(val, data)
|
interpolatedVal, err := InterpolateString(val, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -127,7 +127,7 @@ func (provider *accessTokenProvider) getJwtAccessToken(ctx context.Context, data
|
|||||||
}
|
}
|
||||||
|
|
||||||
if val, ok := provider.route.JwtTokenAuth.Params["private_key"]; ok {
|
if val, ok := provider.route.JwtTokenAuth.Params["private_key"]; ok {
|
||||||
interpolatedVal, err := interpolateString(val, data)
|
interpolatedVal, err := InterpolateString(val, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -135,7 +135,7 @@ func (provider *accessTokenProvider) getJwtAccessToken(ctx context.Context, data
|
|||||||
}
|
}
|
||||||
|
|
||||||
if val, ok := provider.route.JwtTokenAuth.Params["token_uri"]; ok {
|
if val, ok := provider.route.JwtTokenAuth.Params["token_uri"]; ok {
|
||||||
interpolatedVal, err := interpolateString(val, data)
|
interpolatedVal, err := InterpolateString(val, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
package pluginproxy
|
package pluginproxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
|
||||||
|
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
@ -24,7 +22,7 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route
|
|||||||
SecureJsonData: ds.SecureJsonData.Decrypt(),
|
SecureJsonData: ds.SecureJsonData.Decrypt(),
|
||||||
}
|
}
|
||||||
|
|
||||||
interpolatedURL, err := interpolateString(route.Url, data)
|
interpolatedURL, err := InterpolateString(route.Url, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Error interpolating proxy url", "error", err)
|
logger.Error("Error interpolating proxy url", "error", err)
|
||||||
return
|
return
|
||||||
@ -81,24 +79,9 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route
|
|||||||
logger.Info("Requesting", "url", req.URL.String())
|
logger.Info("Requesting", "url", req.URL.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func interpolateString(text string, data templateData) (string, error) {
|
|
||||||
t, err := template.New("content").Parse(text)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("could not parse template %s", text)
|
|
||||||
}
|
|
||||||
|
|
||||||
var contentBuf bytes.Buffer
|
|
||||||
err = t.Execute(&contentBuf, data)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to execute template %s", text)
|
|
||||||
}
|
|
||||||
|
|
||||||
return contentBuf.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data templateData) error {
|
func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data templateData) error {
|
||||||
for _, header := range route.Headers {
|
for _, header := range route.Headers {
|
||||||
interpolated, err := interpolateString(header.Content, data)
|
interpolated, err := InterpolateString(header.Content, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ func TestDsAuthProvider(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
interpolated, err := interpolateString("{{.SecureJsonData.Test}}", data)
|
interpolated, err := InterpolateString("{{.SecureJsonData.Test}}", data)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(interpolated, ShouldEqual, "0asd+asd")
|
So(interpolated, ShouldEqual, "0asd+asd")
|
||||||
})
|
})
|
||||||
|
@ -2,12 +2,13 @@ package pluginproxy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/log"
|
"github.com/grafana/grafana/pkg/log"
|
||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
@ -38,6 +39,24 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appID string) (http.
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateURL(route *plugins.AppPluginRoute, orgId int64, appID string) (string, error) {
|
||||||
|
query := m.GetPluginSettingByIdQuery{OrgId: orgId, PluginId: appID}
|
||||||
|
if err := bus.Dispatch(&query); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := templateData{
|
||||||
|
JsonData: query.Result.JsonData,
|
||||||
|
SecureJsonData: query.Result.SecureJsonData.Decrypt(),
|
||||||
|
}
|
||||||
|
interpolated, err := InterpolateString(route.Url, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return interpolated, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApiPluginProxy create a plugin proxy
|
||||||
func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string, cfg *setting.Cfg) *httputil.ReverseProxy {
|
func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string, cfg *setting.Cfg) *httputil.ReverseProxy {
|
||||||
targetURL, _ := url.Parse(route.Url)
|
targetURL, _ := url.Parse(route.Url)
|
||||||
|
|
||||||
@ -48,7 +67,6 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
|
|||||||
req.Host = targetURL.Host
|
req.Host = targetURL.Host
|
||||||
|
|
||||||
req.URL.Path = util.JoinURLFragments(targetURL.Path, proxyPath)
|
req.URL.Path = util.JoinURLFragments(targetURL.Path, proxyPath)
|
||||||
|
|
||||||
// clear cookie headers
|
// clear cookie headers
|
||||||
req.Header.Del("Cookie")
|
req.Header.Del("Cookie")
|
||||||
req.Header.Del("Set-Cookie")
|
req.Header.Del("Set-Cookie")
|
||||||
@ -72,13 +90,13 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a HTTP header with the context in it.
|
// Create a HTTP header with the context in it.
|
||||||
ctxJson, err := json.Marshal(ctx.SignedInUser)
|
ctxJSON, err := json.Marshal(ctx.SignedInUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JsonApiErr(500, "failed to marshal context to json.", err)
|
ctx.JsonApiErr(500, "failed to marshal context to json.", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Add("X-Grafana-Context", string(ctxJson))
|
req.Header.Add("X-Grafana-Context", string(ctxJSON))
|
||||||
|
|
||||||
if cfg.SendUserHeader && !ctx.SignedInUser.IsAnonymous {
|
if cfg.SendUserHeader && !ctx.SignedInUser.IsAnonymous {
|
||||||
req.Header.Add("X-Grafana-User", ctx.SignedInUser.Login)
|
req.Header.Add("X-Grafana-User", ctx.SignedInUser.Login)
|
||||||
@ -97,6 +115,27 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(route.Url) > 0 {
|
||||||
|
interpolatedURL, err := updateURL(route, ctx.OrgId, appID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JsonApiErr(500, "Could not interpolate plugin route url", err)
|
||||||
|
}
|
||||||
|
targetURL, err := url.Parse(interpolatedURL)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JsonApiErr(500, "Could not parse custom url: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.URL.Scheme = targetURL.Scheme
|
||||||
|
req.URL.Host = targetURL.Host
|
||||||
|
req.Host = targetURL.Host
|
||||||
|
req.URL.Path = util.JoinURLFragments(targetURL.Path, proxyPath)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ctx.JsonApiErr(500, "Could not interpolate plugin route url", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// reqBytes, _ := httputil.DumpRequestOut(req, true);
|
// reqBytes, _ := httputil.DumpRequestOut(req, true);
|
||||||
// log.Trace("Proxying plugin request: %s", string(reqBytes))
|
// log.Trace("Proxying plugin request: %s", string(reqBytes))
|
||||||
}
|
}
|
||||||
|
@ -53,6 +53,7 @@ func TestPluginProxy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
&setting.Cfg{SendUserHeader: true},
|
&setting.Cfg{SendUserHeader: true},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
Convey("Should add header with username", func() {
|
Convey("Should add header with username", func() {
|
||||||
@ -69,6 +70,7 @@ func TestPluginProxy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
&setting.Cfg{SendUserHeader: false},
|
&setting.Cfg{SendUserHeader: false},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
Convey("Should not add header with username", func() {
|
Convey("Should not add header with username", func() {
|
||||||
// Get will return empty string even if header is not set
|
// Get will return empty string even if header is not set
|
||||||
@ -82,6 +84,7 @@ func TestPluginProxy(t *testing.T) {
|
|||||||
SignedInUser: &m.SignedInUser{IsAnonymous: true},
|
SignedInUser: &m.SignedInUser{IsAnonymous: true},
|
||||||
},
|
},
|
||||||
&setting.Cfg{SendUserHeader: true},
|
&setting.Cfg{SendUserHeader: true},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
Convey("Should not add header with username", func() {
|
Convey("Should not add header with username", func() {
|
||||||
@ -89,14 +92,59 @@ func TestPluginProxy(t *testing.T) {
|
|||||||
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
|
So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Convey("When getting templated url", t, func() {
|
||||||
|
route := &plugins.AppPluginRoute{
|
||||||
|
Url: "{{.JsonData.dynamicUrl}}",
|
||||||
|
Method: "GET",
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(query *m.GetPluginSettingByIdQuery) error {
|
||||||
|
query.Result = &m.PluginSetting{
|
||||||
|
JsonData: map[string]interface{}{
|
||||||
|
"dynamicUrl": "https://dynamic.grafana.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
req := getPluginProxiedRequest(
|
||||||
|
&m.ReqContext{
|
||||||
|
SignedInUser: &m.SignedInUser{
|
||||||
|
Login: "test_user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&setting.Cfg{SendUserHeader: true},
|
||||||
|
route,
|
||||||
|
)
|
||||||
|
Convey("Headers should be updated", func() {
|
||||||
|
header, err := getHeaders(route, 1, "my-app")
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(header.Get("X-Grafana-User"), ShouldEqual, "")
|
||||||
|
})
|
||||||
|
Convey("Should set req.URL to be interpolated value from jsonData", func() {
|
||||||
|
So(req.URL.String(), ShouldEqual, "https://dynamic.grafana.com")
|
||||||
|
})
|
||||||
|
Convey("Route url should not be modified", func() {
|
||||||
|
So(route.Url, ShouldEqual, "{{.JsonData.dynamicUrl}}")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext.
|
// getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext.
|
||||||
func getPluginProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request {
|
func getPluginProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg, route *plugins.AppPluginRoute) *http.Request {
|
||||||
route := &plugins.AppPluginRoute{}
|
// insert dummy route if none is specified
|
||||||
|
if route == nil {
|
||||||
|
route = &plugins.AppPluginRoute{
|
||||||
|
Path: "api/v4/",
|
||||||
|
Url: "https://www.google.com",
|
||||||
|
ReqRole: m.ROLE_EDITOR,
|
||||||
|
}
|
||||||
|
}
|
||||||
proxy := NewApiPluginProxy(ctx, "", route, "", cfg)
|
proxy := NewApiPluginProxy(ctx, "", route, "", cfg)
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
|
req, err := http.NewRequest(http.MethodGet, route.Url, nil)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
proxy.Director(req)
|
proxy.Director(req)
|
||||||
return req
|
return req
|
||||||
|
49
pkg/api/pluginproxy/utils.go
Normal file
49
pkg/api/pluginproxy/utils.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package pluginproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InterpolateString accepts template data and return a string with substitutions
|
||||||
|
func InterpolateString(text string, data templateData) (string, error) {
|
||||||
|
t, err := template.New("content").Parse(text)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not parse template %s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentBuf bytes.Buffer
|
||||||
|
err = t.Execute(&contentBuf, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to execute template %s", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentBuf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterpolateURL accepts template data and return a string with substitutions
|
||||||
|
func InterpolateURL(anURL *url.URL, route *plugins.AppPluginRoute, orgID int64, appID string) (*url.URL, error) {
|
||||||
|
query := m.GetPluginSettingByIdQuery{OrgId: orgID, PluginId: appID}
|
||||||
|
result, err := url.Parse(anURL.String())
|
||||||
|
if query.Result != nil {
|
||||||
|
if len(query.Result.JsonData) > 0 {
|
||||||
|
data := templateData{
|
||||||
|
JsonData: query.Result.JsonData,
|
||||||
|
}
|
||||||
|
interpolatedResult, err := InterpolateString(anURL.String(), data)
|
||||||
|
if err == nil {
|
||||||
|
result, err = url.Parse(interpolatedResult)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Error parsing plugin route url %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
21
pkg/api/pluginproxy/utils_test.go
Normal file
21
pkg/api/pluginproxy/utils_test.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package pluginproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInterpolateString(t *testing.T) {
|
||||||
|
Convey("When interpolating string", t, func() {
|
||||||
|
data := templateData{
|
||||||
|
SecureJsonData: map[string]string{
|
||||||
|
"Test": "0asd+asd",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interpolated, err := InterpolateString("{{.SecureJsonData.Test}}", data)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
So(interpolated, ShouldEqual, "0asd+asd")
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user