Plugins: Handle app plugin proxy routes per request (#51835)

Fixes #47530
This commit is contained in:
Marcus Efraimsson
2022-08-23 13:05:31 +02:00
committed by GitHub
parent 5f80bf5297
commit e6857bf17d
7 changed files with 443 additions and 236 deletions

View File

@ -365,6 +365,8 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Any("/plugins/:pluginId/resources", authorize(reqSignedIn, ac.EvalPermission(plugins.ActionAppAccess, pluginIDScope)), hs.CallResource) apiRoute.Any("/plugins/:pluginId/resources", authorize(reqSignedIn, ac.EvalPermission(plugins.ActionAppAccess, pluginIDScope)), hs.CallResource)
apiRoute.Any("/plugins/:pluginId/resources/*", authorize(reqSignedIn, ac.EvalPermission(plugins.ActionAppAccess, pluginIDScope)), hs.CallResource) apiRoute.Any("/plugins/:pluginId/resources/*", authorize(reqSignedIn, ac.EvalPermission(plugins.ActionAppAccess, pluginIDScope)), hs.CallResource)
apiRoute.Get("/plugins/errors", routing.Wrap(hs.GetPluginErrorsList)) apiRoute.Get("/plugins/errors", routing.Wrap(hs.GetPluginErrorsList))
apiRoute.Any("/plugin-proxy/:pluginId/*", authorize(reqSignedIn, ac.EvalPermission(plugins.ActionAppAccess, pluginIDScope)), hs.ProxyPluginRequest)
apiRoute.Any("/plugin-proxy/:pluginId", authorize(reqSignedIn, ac.EvalPermission(plugins.ActionAppAccess, pluginIDScope)), hs.ProxyPluginRequest)
if hs.Cfg.PluginAdminEnabled && !hs.Cfg.PluginAdminExternalManageEnabled { if hs.Cfg.PluginAdminEnabled && !hs.Cfg.PluginAdminExternalManageEnabled {
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {

View File

@ -1,79 +0,0 @@
package api
import (
"context"
"crypto/tls"
"net"
"net/http"
"strings"
"time"
"github.com/grafana/grafana/pkg/api/pluginproxy"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
var pluginProxyTransport *http.Transport
var applog = log.New("app.routes")
func (hs *HTTPServer) initAppPluginRoutes(r *web.Mux) {
pluginProxyTransport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: hs.Cfg.PluginsAppsSkipVerifyTLS,
Renegotiation: tls.RenegotiateFreelyAsClient,
},
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
}
for _, plugin := range hs.pluginStore.Plugins(context.Background(), plugins.App) {
for _, route := range plugin.Routes {
url := util.JoinURLFragments("/api/plugin-proxy/"+plugin.ID, route.Path)
handlers := make([]web.Handler, 0)
handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{
ReqSignedIn: true,
}))
// Preventing access to plugin routes if the user has no right to access the plugin
authorize := ac.Middleware(hs.AccessControl)
handlers = append(handlers, authorize(middleware.ReqSignedIn,
ac.EvalPermission(plugins.ActionAppAccess, plugins.ScopeProvider.GetResourceScope(plugin.ID))))
if route.ReqRole != "" {
if route.ReqRole == org.RoleAdmin {
handlers = append(handlers, middleware.RoleAuth(org.RoleAdmin))
} else if route.ReqRole == org.RoleEditor {
handlers = append(handlers, middleware.RoleAuth(org.RoleEditor, org.RoleAdmin))
}
}
handlers = append(handlers, AppPluginRoute(route, plugin.ID, hs))
for _, method := range strings.Split(route.Method, ",") {
r.Handle(strings.TrimSpace(method), url, handlers)
}
applog.Debug("Plugins: Adding proxy route", "url", url)
}
}
}
func AppPluginRoute(route *plugins.Route, appID string, hs *HTTPServer) web.Handler {
return func(c *models.ReqContext) {
path := web.Params(c.Req)["*"]
proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg, hs.PluginSettings, hs.SecretsService)
proxy.Transport = pluginProxyTransport
proxy.ServeHTTP(c.Resp, c.Req)
}
}

View File

@ -513,8 +513,6 @@ func (hs *HTTPServer) applyRoutes() {
hs.addMiddlewaresAndStaticRoutes() hs.addMiddlewaresAndStaticRoutes()
// then add view routes & api routes // then add view routes & api routes
hs.RouteRegister.Register(hs.web, hs.namedMiddlewares...) hs.RouteRegister.Register(hs.web, hs.namedMiddlewares...)
// then custom app proxy routes
hs.initAppPluginRoutes(hs.web)
// lastly not found route // lastly not found route
hs.web.NotFound(middleware.ReqSignedIn, hs.NotFoundHandler) hs.web.NotFound(middleware.ReqSignedIn, hs.NotFoundHandler)
} }

68
pkg/api/plugin_proxy.go Normal file
View File

@ -0,0 +1,68 @@
package api
import (
"crypto/tls"
"net"
"net/http"
"regexp"
"sync"
"time"
"github.com/grafana/grafana/pkg/api/pluginproxy"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/web"
)
func (hs *HTTPServer) ProxyPluginRequest(c *models.ReqContext) {
var once sync.Once
var pluginProxyTransport *http.Transport
once.Do(func() {
pluginProxyTransport = &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: hs.Cfg.PluginsAppsSkipVerifyTLS,
Renegotiation: tls.RenegotiateFreelyAsClient,
},
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
}
})
pluginID := web.Params(c.Req)[":pluginId"]
plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID)
if !exists {
c.JsonApiErr(http.StatusNotFound, "Plugin not found, no installed plugin with that id", nil)
return
}
query := pluginsettings.GetByPluginIDArgs{OrgID: c.OrgID, PluginID: plugin.ID}
ps, err := hs.PluginSettings.GetPluginSettingByPluginID(c.Req.Context(), &query)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to fetch plugin settings", err)
return
}
proxyPath := getProxyPath(c)
p, err := pluginproxy.NewPluginProxy(ps, plugin.Routes, c, proxyPath, hs.Cfg, hs.SecretsService, hs.tracer, pluginProxyTransport)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create plugin proxy", err)
return
}
p.HandleRequest()
}
var pluginProxyPathRegexp = regexp.MustCompile(`^\/api\/plugin-proxy\/([\w\-]+)\/?`)
func extractProxyPath(originalRawPath string) string {
return pluginProxyPathRegexp.ReplaceAllString(originalRawPath, "")
}
func getProxyPath(c *models.ReqContext) string {
return extractProxyPath(c.Req.URL.EscapedPath())
}

View File

@ -0,0 +1,36 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestExtractPluginProxyPath(t *testing.T) {
testCases := []struct {
originalRawPath string
exp string
}{
{
"/api/plugin-proxy/test",
"",
},
{
"/api/plugin-proxy/test/some/thing",
"some/thing",
},
{
"/api/plugin-proxy/test2/api/services/afsd%2Fafsd/operations",
"api/services/afsd%2Fafsd/operations",
},
{
"/api/plugin-proxy/cloudflare-app/with-token/api/v4/accounts",
"with-token/api/v4/accounts",
},
}
for _, tc := range testCases {
t.Run("Given raw path, should extract expected proxy path", func(t *testing.T) {
assert.Equal(t, tc.exp, extractProxyPath(tc.originalRawPath))
})
}
}

View File

@ -5,9 +5,9 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httputil"
"net/url" "net/url"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsettings"
@ -15,111 +15,184 @@ import (
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/proxyutil" "github.com/grafana/grafana/pkg/util/proxyutil"
"github.com/grafana/grafana/pkg/web"
"go.opentelemetry.io/otel/attribute"
) )
type templateData struct { type PluginProxy struct {
JsonData map[string]interface{} ps *pluginsettings.DTO
SecureJsonData map[string]string pluginRoutes []*plugins.Route
ctx *models.ReqContext
proxyPath string
matchedRoute *plugins.Route
cfg *setting.Cfg
secretsService secrets.Service
tracer tracing.Tracer
transport *http.Transport
} }
// NewApiPluginProxy create a plugin proxy // NewPluginProxy creates a plugin proxy.
func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins.Route, func NewPluginProxy(ps *pluginsettings.DTO, routes []*plugins.Route, ctx *models.ReqContext,
appID string, cfg *setting.Cfg, pluginSettingsService pluginsettings.Service, proxyPath string, cfg *setting.Cfg, secretsService secrets.Service, tracer tracing.Tracer,
secretsService secrets.Service) *httputil.ReverseProxy { transport *http.Transport) (*PluginProxy, error) {
appProxyLogger := logger.New( return &PluginProxy{
"userId", ctx.UserID, ps: ps,
"orgId", ctx.OrgID, pluginRoutes: routes,
"uname", ctx.Login, ctx: ctx,
"app", appID, proxyPath: proxyPath,
"path", ctx.Req.URL.Path, cfg: cfg,
"remote_addr", ctx.RemoteAddr(), secretsService: secretsService,
"referer", ctx.Req.Referer(), tracer: tracer,
) transport: transport,
}, nil
}
director := func(req *http.Request) { func (proxy *PluginProxy) HandleRequest() {
query := pluginsettings.GetByPluginIDArgs{OrgID: ctx.OrgID, PluginID: appID} // found route if there are any
ps, err := pluginSettingsService.GetPluginSettingByPluginID(ctx.Req.Context(), &query) for _, route := range proxy.pluginRoutes {
if err != nil { // method match
ctx.JsonApiErr(500, "Failed to fetch plugin settings", err) if route.Method != "" && route.Method != "*" && route.Method != proxy.ctx.Req.Method {
return continue
} }
secureJsonData, err := secretsService.DecryptJsonData(ctx.Req.Context(), ps.SecureJSONData) t := web.NewTree()
if err != nil { t.Add(route.Path, nil)
ctx.JsonApiErr(500, "Failed to decrypt plugin settings", err) _, params, isMatch := t.Match(proxy.proxyPath)
return
if !isMatch {
continue
} }
data := templateData{ if route.ReqRole.IsValid() {
JsonData: ps.JSONData, if !proxy.ctx.HasUserRole(route.ReqRole) {
SecureJsonData: secureJsonData, proxy.ctx.JsonApiErr(http.StatusForbidden, "plugin proxy route access denied", nil)
return
}
} }
interpolatedURL, err := interpolateString(route.URL, data) if path, exists := params["*"]; exists {
if err != nil { proxy.proxyPath = path
ctx.JsonApiErr(500, "Could not interpolate plugin route url", err) } else {
return proxy.proxyPath = ""
}
targetURL, err := url.Parse(interpolatedURL)
if err != nil {
ctx.JsonApiErr(500, "Could not parse url", err)
return
}
req.URL.Scheme = targetURL.Scheme
req.URL.Host = targetURL.Host
req.Host = targetURL.Host
req.URL.Path = util.JoinURLFragments(targetURL.Path, proxyPath)
// clear cookie headers
req.Header.Del("Cookie")
req.Header.Del("Set-Cookie")
// Create a HTTP header with the context in it.
ctxJSON, err := json.Marshal(ctx.SignedInUser)
if err != nil {
ctx.JsonApiErr(500, "failed to marshal context to json.", err)
return
} }
req.Header.Set("X-Grafana-Context", string(ctxJSON)) proxy.matchedRoute = route
break
applyUserHeader(cfg.SendUserHeader, req, ctx.SignedInUser)
if err := addHeaders(&req.Header, route, data); err != nil {
ctx.JsonApiErr(500, "Failed to render plugin headers", err)
return
}
if err := setBodyContent(req, route, data); err != nil {
appProxyLogger.Error("Failed to set plugin route body content", "error", err)
}
} }
logAppPluginProxyRequest(appID, cfg, ctx) if proxy.matchedRoute == nil {
proxy.ctx.JsonApiErr(http.StatusNotFound, "plugin route match not found", nil)
return
}
return proxyutil.NewReverseProxy(appProxyLogger, director) traceID := tracing.TraceIDFromContext(proxy.ctx.Req.Context(), false)
proxyErrorLogger := logger.New(
"userId", proxy.ctx.UserID,
"orgId", proxy.ctx.OrgID,
"uname", proxy.ctx.Login,
"path", proxy.ctx.Req.URL.Path,
"remote_addr", proxy.ctx.RemoteAddr(),
"referer", proxy.ctx.Req.Referer(),
"traceID", traceID,
)
reverseProxy := proxyutil.NewReverseProxy(
proxyErrorLogger,
proxy.director,
proxyutil.WithTransport(proxy.transport),
)
proxy.logRequest()
ctx, span := proxy.tracer.Start(proxy.ctx.Req.Context(), "plugin reverse proxy")
defer span.End()
proxy.ctx.Req = proxy.ctx.Req.WithContext(ctx)
span.SetAttributes("user", proxy.ctx.SignedInUser.Login, attribute.Key("user").String(proxy.ctx.SignedInUser.Login))
span.SetAttributes("org_id", proxy.ctx.SignedInUser.OrgID, attribute.Key("org_id").Int64(proxy.ctx.SignedInUser.OrgID))
proxy.tracer.Inject(ctx, proxy.ctx.Req.Header, span)
reverseProxy.ServeHTTP(proxy.ctx.Resp, proxy.ctx.Req)
} }
func logAppPluginProxyRequest(appID string, cfg *setting.Cfg, c *models.ReqContext) { func (proxy PluginProxy) director(req *http.Request) {
if !cfg.DataProxyLogging { secureJsonData, err := proxy.secretsService.DecryptJsonData(proxy.ctx.Req.Context(), proxy.ps.SecureJSONData)
if err != nil {
proxy.ctx.JsonApiErr(500, "Failed to decrypt plugin settings", err)
return
}
data := templateData{
JsonData: proxy.ps.JSONData,
SecureJsonData: secureJsonData,
}
interpolatedURL, err := interpolateString(proxy.matchedRoute.URL, data)
if err != nil {
proxy.ctx.JsonApiErr(500, "Could not interpolate plugin route url", err)
return
}
targetURL, err := url.Parse(interpolatedURL)
if err != nil {
proxy.ctx.JsonApiErr(500, "Could not parse url", err)
return
}
req.URL.Scheme = targetURL.Scheme
req.URL.Host = targetURL.Host
req.Host = targetURL.Host
req.URL.Path = util.JoinURLFragments(targetURL.Path, proxy.proxyPath)
// clear cookie headers
req.Header.Del("Cookie")
req.Header.Del("Set-Cookie")
// Create a HTTP header with the context in it.
ctxJSON, err := json.Marshal(proxy.ctx.SignedInUser)
if err != nil {
proxy.ctx.JsonApiErr(500, "failed to marshal context to json.", err)
return
}
req.Header.Set("X-Grafana-Context", string(ctxJSON))
applyUserHeader(proxy.cfg.SendUserHeader, req, proxy.ctx.SignedInUser)
if err := addHeaders(&req.Header, proxy.matchedRoute, data); err != nil {
proxy.ctx.JsonApiErr(500, "Failed to render plugin headers", err)
return
}
if err := setBodyContent(req, proxy.matchedRoute, data); err != nil {
logger.Error("Failed to set plugin route body content", "error", err)
}
}
func (proxy PluginProxy) logRequest() {
if !proxy.cfg.DataProxyLogging {
return return
} }
var body string var body string
if c.Req.Body != nil { if proxy.ctx.Req.Body != nil {
buffer, err := io.ReadAll(c.Req.Body) buffer, err := io.ReadAll(proxy.ctx.Req.Body)
if err == nil { if err == nil {
c.Req.Body = io.NopCloser(bytes.NewBuffer(buffer)) proxy.ctx.Req.Body = io.NopCloser(bytes.NewBuffer(buffer))
body = string(buffer) body = string(buffer)
} }
} }
logger.Info("Proxying incoming request", logger.Info("Proxying incoming request",
"userid", c.UserID, "userid", proxy.ctx.UserID,
"orgid", c.OrgID, "orgid", proxy.ctx.OrgID,
"username", c.Login, "username", proxy.ctx.Login,
"app", appID, "app", proxy.ps.PluginID,
"uri", c.Req.RequestURI, "uri", proxy.ctx.Req.RequestURI,
"method", c.Req.Method, "method", proxy.ctx.Req.Method,
"body", body) "body", body)
} }
type templateData struct {
JsonData map[string]interface{}
SecureJsonData map[string]string
}

View File

@ -2,11 +2,14 @@ package pluginproxy
import ( import (
"context" "context"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
@ -32,19 +35,19 @@ func TestPluginProxy(t *testing.T) {
}, },
} }
store := &mockPluginsSettingsService{} key, err := secretsService.Encrypt(context.Background(), []byte("123"), secrets.WithoutScope())
key, _ := secretsService.Encrypt(context.Background(), []byte("123"), secrets.WithoutScope()) require.NoError(t, err)
store.pluginSetting = &pluginsettings.DTO{
SecureJSONData: map[string][]byte{
"key": key,
},
}
httpReq, err := http.NewRequest(http.MethodGet, "", nil) httpReq, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err) require.NoError(t, err)
req := getPluginProxiedRequest( req := getPluginProxiedRequest(
t, t,
&pluginsettings.DTO{
SecureJSONData: map[string][]byte{
"key": key,
},
},
secretsService, secretsService,
&models.ReqContext{ &models.ReqContext{
SignedInUser: &user.SignedInUser{ SignedInUser: &user.SignedInUser{
@ -56,7 +59,6 @@ func TestPluginProxy(t *testing.T) {
}, },
&setting.Cfg{SendUserHeader: true}, &setting.Cfg{SendUserHeader: true},
route, route,
store,
) )
assert.Equal(t, "my secret 123", req.Header.Get("x-header")) assert.Equal(t, "my secret 123", req.Header.Get("x-header"))
@ -66,11 +68,9 @@ func TestPluginProxy(t *testing.T) {
httpReq, err := http.NewRequest(http.MethodGet, "", nil) httpReq, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err) require.NoError(t, err)
store := &mockPluginsSettingsService{}
store.pluginSetting = &pluginsettings.DTO{}
req := getPluginProxiedRequest( req := getPluginProxiedRequest(
t, t,
&pluginsettings.DTO{},
secretsService, secretsService,
&models.ReqContext{ &models.ReqContext{
SignedInUser: &user.SignedInUser{ SignedInUser: &user.SignedInUser{
@ -82,7 +82,6 @@ func TestPluginProxy(t *testing.T) {
}, },
&setting.Cfg{SendUserHeader: true}, &setting.Cfg{SendUserHeader: true},
nil, nil,
store,
) )
// Get will return empty string even if header is not set // Get will return empty string even if header is not set
@ -93,11 +92,9 @@ func TestPluginProxy(t *testing.T) {
httpReq, err := http.NewRequest(http.MethodGet, "", nil) httpReq, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err) require.NoError(t, err)
store := &mockPluginsSettingsService{}
store.pluginSetting = &pluginsettings.DTO{}
req := getPluginProxiedRequest( req := getPluginProxiedRequest(
t, t,
&pluginsettings.DTO{},
secretsService, secretsService,
&models.ReqContext{ &models.ReqContext{
SignedInUser: &user.SignedInUser{ SignedInUser: &user.SignedInUser{
@ -109,7 +106,6 @@ func TestPluginProxy(t *testing.T) {
}, },
&setting.Cfg{SendUserHeader: false}, &setting.Cfg{SendUserHeader: false},
nil, nil,
store,
) )
// Get will return empty string even if header is not set // Get will return empty string even if header is not set
assert.Equal(t, "", req.Header.Get("X-Grafana-User")) assert.Equal(t, "", req.Header.Get("X-Grafana-User"))
@ -119,11 +115,9 @@ func TestPluginProxy(t *testing.T) {
httpReq, err := http.NewRequest(http.MethodGet, "", nil) httpReq, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err) require.NoError(t, err)
store := &mockPluginsSettingsService{}
store.pluginSetting = &pluginsettings.DTO{}
req := getPluginProxiedRequest( req := getPluginProxiedRequest(
t, t,
&pluginsettings.DTO{},
secretsService, secretsService,
&models.ReqContext{ &models.ReqContext{
SignedInUser: &user.SignedInUser{IsAnonymous: true}, SignedInUser: &user.SignedInUser{IsAnonymous: true},
@ -133,7 +127,6 @@ func TestPluginProxy(t *testing.T) {
}, },
&setting.Cfg{SendUserHeader: true}, &setting.Cfg{SendUserHeader: true},
nil, nil,
store,
) )
// Get will return empty string even if header is not set // Get will return empty string even if header is not set
@ -146,18 +139,16 @@ func TestPluginProxy(t *testing.T) {
Method: "GET", Method: "GET",
} }
store := &mockPluginsSettingsService{}
store.pluginSetting = &pluginsettings.DTO{
JSONData: map[string]interface{}{
"dynamicUrl": "https://dynamic.grafana.com",
},
}
httpReq, err := http.NewRequest(http.MethodGet, "", nil) httpReq, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err) require.NoError(t, err)
req := getPluginProxiedRequest( req := getPluginProxiedRequest(
t, t,
&pluginsettings.DTO{
JSONData: map[string]interface{}{
"dynamicUrl": "https://dynamic.grafana.com",
},
},
secretsService, secretsService,
&models.ReqContext{ &models.ReqContext{
SignedInUser: &user.SignedInUser{ SignedInUser: &user.SignedInUser{
@ -169,7 +160,6 @@ func TestPluginProxy(t *testing.T) {
}, },
&setting.Cfg{SendUserHeader: true}, &setting.Cfg{SendUserHeader: true},
route, route,
store,
) )
assert.Equal(t, "https://dynamic.grafana.com", req.URL.String()) assert.Equal(t, "https://dynamic.grafana.com", req.URL.String())
assert.Equal(t, "{{.JsonData.dynamicUrl}}", route.URL) assert.Equal(t, "{{.JsonData.dynamicUrl}}", route.URL)
@ -181,14 +171,12 @@ func TestPluginProxy(t *testing.T) {
Method: "GET", Method: "GET",
} }
store := &mockPluginsSettingsService{}
store.pluginSetting = &pluginsettings.DTO{}
httpReq, err := http.NewRequest(http.MethodGet, "", nil) httpReq, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err) require.NoError(t, err)
req := getPluginProxiedRequest( req := getPluginProxiedRequest(
t, t,
&pluginsettings.DTO{},
secretsService, secretsService,
&models.ReqContext{ &models.ReqContext{
SignedInUser: &user.SignedInUser{ SignedInUser: &user.SignedInUser{
@ -200,7 +188,6 @@ func TestPluginProxy(t *testing.T) {
}, },
&setting.Cfg{SendUserHeader: true}, &setting.Cfg{SendUserHeader: true},
route, route,
store,
) )
assert.Equal(t, "https://example.com", req.URL.String()) assert.Equal(t, "https://example.com", req.URL.String())
}) })
@ -212,22 +199,22 @@ func TestPluginProxy(t *testing.T) {
Body: []byte(`{ "url": "{{.JsonData.dynamicUrl}}", "secret": "{{.SecureJsonData.key}}" }`), Body: []byte(`{ "url": "{{.JsonData.dynamicUrl}}", "secret": "{{.SecureJsonData.key}}" }`),
} }
store := &mockPluginsSettingsService{} encryptedJsonData, err := secretsService.EncryptJsonData(
encryptedJsonData, _ := secretsService.EncryptJsonData(
context.Background(), context.Background(),
map[string]string{"key": "123"}, map[string]string{"key": "123"},
secrets.WithoutScope(), secrets.WithoutScope(),
) )
store.pluginSetting = &pluginsettings.DTO{ require.NoError(t, err)
JSONData: map[string]interface{}{"dynamicUrl": "https://dynamic.grafana.com"},
SecureJSONData: encryptedJsonData,
}
httpReq, err := http.NewRequest(http.MethodGet, "", nil) httpReq, err := http.NewRequest(http.MethodGet, "", nil)
require.NoError(t, err) require.NoError(t, err)
req := getPluginProxiedRequest( req := getPluginProxiedRequest(
t, t,
&pluginsettings.DTO{
JSONData: map[string]interface{}{"dynamicUrl": "https://dynamic.grafana.com"},
SecureJSONData: encryptedJsonData,
},
secretsService, secretsService,
&models.ReqContext{ &models.ReqContext{
SignedInUser: &user.SignedInUser{ SignedInUser: &user.SignedInUser{
@ -239,7 +226,6 @@ func TestPluginProxy(t *testing.T) {
}, },
&setting.Cfg{SendUserHeader: true}, &setting.Cfg{SendUserHeader: true},
route, route,
store,
) )
content, err := io.ReadAll(req.Body) content, err := io.ReadAll(req.Body)
require.NoError(t, err) require.NoError(t, err)
@ -257,9 +243,11 @@ func TestPluginProxy(t *testing.T) {
responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder()) responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder())
route := &plugins.Route{ routes := []*plugins.Route{
Path: "/", {
URL: backendServer.URL, Path: "/",
URL: backendServer.URL,
},
} }
ctx := &models.ReqContext{ ctx := &models.ReqContext{
@ -269,13 +257,12 @@ func TestPluginProxy(t *testing.T) {
Resp: responseWriter, Resp: responseWriter,
}, },
} }
pluginSettingsService := &mockPluginsSettingsService{ ps := &pluginsettings.DTO{
pluginSetting: &pluginsettings.DTO{ SecureJSONData: map[string][]byte{},
SecureJSONData: map[string][]byte{},
},
} }
proxy := NewApiPluginProxy(ctx, "", route, "", &setting.Cfg{}, pluginSettingsService, secretsService) proxy, err := NewPluginProxy(ps, routes, ctx, "", &setting.Cfg{}, secretsService, tracing.InitializeTracerForTest(), &http.Transport{})
proxy.ServeHTTP(ctx.Resp, ctx.Req) require.NoError(t, err)
proxy.HandleRequest()
for { for {
if requestHandled { if requestHandled {
@ -287,8 +274,153 @@ func TestPluginProxy(t *testing.T) {
}) })
} }
func TestPluginProxyRoutes(t *testing.T) {
routes := []*plugins.Route{
{
Path: "",
Method: "GET",
URL: "http://localhost",
},
{
Path: "some-api",
Method: "GET",
URL: "http://localhost/api",
},
{
Path: "some-api/instances",
Method: "GET",
URL: "http://localhost/api/instances/",
},
{
Path: "some-api/*",
Method: "GET",
URL: "http://localhost/api",
},
{
Path: "some-api/instances/*",
Method: "GET",
URL: "http://localhost/api/instances",
},
{
Path: "some-other-api/*",
Method: "GET",
URL: "http://localhost/api/v2",
},
{
Path: "some-other-api/instances/*",
Method: "GET",
URL: "http://localhost/api/v2/instances",
},
}
tcs := []struct {
proxyPath string
expectedURLPath string
expectedStatus int
}{
{
proxyPath: "/notexists",
expectedStatus: http.StatusNotFound,
},
{
proxyPath: "/",
expectedURLPath: "/",
expectedStatus: http.StatusOK,
},
{
proxyPath: "/some-api",
expectedURLPath: "/api",
expectedStatus: http.StatusOK,
},
{
proxyPath: "/some-api/instances",
expectedURLPath: "/api/instances/",
expectedStatus: http.StatusOK,
},
{
proxyPath: "/some-api/some/thing",
expectedURLPath: "/api/some/thing",
expectedStatus: http.StatusOK,
},
{
proxyPath: "/some-api/instances/instance-one",
expectedURLPath: "/api/instances/instance-one",
expectedStatus: http.StatusOK,
},
{
proxyPath: "/some-other-api/some/thing",
expectedURLPath: "/api/v2/some/thing",
expectedStatus: http.StatusOK,
},
{
proxyPath: "/some-other-api/instances/instance-one",
expectedURLPath: "/api/v2/instances/instance-one",
expectedStatus: http.StatusOK,
},
}
for _, tc := range tcs {
t.Run(fmt.Sprintf("When proxying path %q should call expected URL", tc.proxyPath), func(t *testing.T) {
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
requestHandled := false
requestURL := ""
backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestURL = r.URL.RequestURI()
w.WriteHeader(200)
_, _ = w.Write([]byte("I am the backend"))
requestHandled = true
}))
t.Cleanup(backendServer.Close)
backendURL, err := url.Parse(backendServer.URL)
require.NoError(t, err)
testRoutes := make([]*plugins.Route, len(routes))
for i, r := range routes {
u, err := url.Parse(r.URL)
require.NoError(t, err)
u.Scheme = backendURL.Scheme
u.Host = backendURL.Host
testRoute := *r
testRoute.URL = u.String()
testRoutes[i] = &testRoute
}
responseWriter := web.NewResponseWriter("GET", httptest.NewRecorder())
ctx := &models.ReqContext{
SignedInUser: &user.SignedInUser{},
Context: &web.Context{
Req: httptest.NewRequest("GET", tc.proxyPath, nil),
Resp: responseWriter,
},
}
ps := &pluginsettings.DTO{
SecureJSONData: map[string][]byte{},
}
proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, &setting.Cfg{}, secretsService, tracing.InitializeTracerForTest(), &http.Transport{})
require.NoError(t, err)
proxy.HandleRequest()
for {
if requestHandled || ctx.Resp.Written() {
break
}
}
require.Equal(t, tc.expectedStatus, ctx.Resp.Status())
if tc.expectedStatus == http.StatusNotFound {
return
}
require.Equal(t, tc.expectedURLPath, requestURL)
})
}
}
// 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(t *testing.T, secretsService secrets.Service, ctx *models.ReqContext, cfg *setting.Cfg, route *plugins.Route, pluginSettingsService pluginsettings.Service) *http.Request { func getPluginProxiedRequest(t *testing.T, ps *pluginsettings.DTO, secretsService secrets.Service, ctx *models.ReqContext, cfg *setting.Cfg, route *plugins.Route) *http.Request {
// insert dummy route if none is specified // insert dummy route if none is specified
if route == nil { if route == nil {
route = &plugins.Route{ route = &plugins.Route{
@ -297,35 +429,12 @@ func getPluginProxiedRequest(t *testing.T, secretsService secrets.Service, ctx *
ReqRole: org.RoleEditor, ReqRole: org.RoleEditor,
} }
} }
proxy := NewApiPluginProxy(ctx, "", route, "", cfg, pluginSettingsService, secretsService) proxy, err := NewPluginProxy(ps, []*plugins.Route{}, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{})
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "/api/plugin-proxy/grafana-simple-app/api/v4/alerts", nil) req, err := http.NewRequest(http.MethodGet, "/api/plugin-proxy/grafana-simple-app/api/v4/alerts", nil)
require.NoError(t, err) require.NoError(t, err)
proxy.Director(req) proxy.matchedRoute = route
proxy.director(req)
return req return req
} }
type mockPluginsSettingsService struct {
pluginSetting *pluginsettings.DTO
err error
}
func (s *mockPluginsSettingsService) GetPluginSettings(_ context.Context, _ *pluginsettings.GetArgs) ([]*pluginsettings.DTO, error) {
return nil, s.err
}
func (s *mockPluginsSettingsService) GetPluginSettingByPluginID(_ context.Context, _ *pluginsettings.GetByPluginIDArgs) (*pluginsettings.DTO, error) {
return s.pluginSetting, s.err
}
func (s *mockPluginsSettingsService) UpdatePluginSettingPluginVersion(_ context.Context, _ *pluginsettings.UpdatePluginVersionArgs) error {
return s.err
}
func (s *mockPluginsSettingsService) UpdatePluginSetting(_ context.Context, _ *pluginsettings.UpdateArgs) error {
return s.err
}
func (s *mockPluginsSettingsService) DecryptedValues(_ *pluginsettings.DTO) map[string]string {
return nil
}