diff --git a/.circleci/config.yml b/.circleci/config.yml
index 69cea87dccd..da0e0665285 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -56,6 +56,20 @@ jobs:
name: postgres integration tests
command: './scripts/circle-test-postgres.sh'
+ cache-server-test:
+ docker:
+ - image: circleci/golang:1.11.5
+ - image: circleci/redis:4-alpine
+ - image: memcached
+ working_directory: /go/src/github.com/grafana/grafana
+ steps:
+ - checkout
+ - run: dockerize -wait tcp://127.0.0.1:11211 -timeout 120s
+ - run: dockerize -wait tcp://127.0.0.1:6379 -timeout 120s
+ - run:
+ name: cache server tests
+ command: './scripts/circle-test-cache-servers.sh'
+
codespell:
docker:
- image: circleci/python
@@ -545,6 +559,8 @@ workflows:
filters: *filter-not-release-or-master
- postgres-integration-test:
filters: *filter-not-release-or-master
+ - cache-server-test:
+ filters: *filter-not-release-or-master
- grafana-docker-pr:
requires:
- build
@@ -554,4 +570,5 @@ workflows:
- gometalinter
- mysql-integration-test
- postgres-integration-test
+ - cache-server-test
filters: *filter-not-release-or-master
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b648603579a..1f09ead1fe3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@
* **Heatmap**: `Middle` bucket bound option [#15683](https://github.com/grafana/grafana/issues/15683)
* **Heatmap**: `Reverse order` option for changing order of buckets [#15683](https://github.com/grafana/grafana/issues/15683)
* **VictorOps**: Adds more information to the victor ops notifiers [#15744](https://github.com/grafana/grafana/issues/15744), thx [@zhulongcheng](https://github.com/zhulongcheng)
+* **Cache**: Adds support for using out of proc caching in the backend [#10816](https://github.com/grafana/grafana/issues/10816)
+* **Dataproxy**: Make it possible to add user details to requests sent to the dataproxy [#6359](https://github.com/grafana/grafana/issues/6359) and [#15931](https://github.com/grafana/grafana/issues/15931)
+* **Auth**: Support listing and revoking auth tokens via API [#15836](https://github.com/grafana/grafana/issues/15836)
+* **Datasource**: Only log connection string in dev environment [#16001](https://github.com/grafana/grafana/issues/16001)
### Bug Fixes
* **Api**: Invalid org invite code [#10506](https://github.com/grafana/grafana/issues/10506)
diff --git a/conf/defaults.ini b/conf/defaults.ini
index 044d8e59a7a..492525e6b5f 100644
--- a/conf/defaults.ini
+++ b/conf/defaults.ini
@@ -106,6 +106,17 @@ path = grafana.db
# For "sqlite3" only. cache mode setting used for connecting to the database
cache_mode = private
+#################################### Cache server #############################
+[remote_cache]
+# Either "redis", "memcached" or "database" default is "database"
+type = database
+
+# cache connectionstring options
+# database: will use Grafana primary database.
+# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana`
+# memcache: 127.0.0.1:11211
+connstr =
+
#################################### Session #############################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", "memcache", default is "file"
@@ -146,6 +157,9 @@ logging = false
# How long the data proxy should wait before timing out default is 30 (seconds)
timeout = 30
+# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
+send_user_header = false
+
#################################### Analytics ###########################
[analytics]
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
diff --git a/conf/sample.ini b/conf/sample.ini
index dc1e4fbde8e..fd414c2af47 100644
--- a/conf/sample.ini
+++ b/conf/sample.ini
@@ -102,6 +102,17 @@ log_queries =
# For "sqlite3" only. cache mode setting used for connecting to the database. (private, shared)
;cache_mode = private
+#################################### Cache server #############################
+[remote_cache]
+# Either "redis", "memcached" or "database" default is "database"
+;type = database
+
+# cache connectionstring options
+# database: will use Grafana primary database.
+# redis: config like redis server e.g. `addr=127.0.0.1:6379,pool_size=100,db=grafana`
+# memcache: 127.0.0.1:11211
+;connstr =
+
#################################### Session ####################################
[session]
# Either "memory", "file", "redis", "mysql", "postgres", default is "file"
@@ -133,6 +144,9 @@ log_queries =
# How long the data proxy should wait before timing out default is 30 (seconds)
;timeout = 30
+# If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
+;send_user_header = false
+
#################################### Analytics ####################################
[analytics]
# Server reporting, sends usage counters to stats.grafana.org every 24 hours.
diff --git a/devenv/docker/blocks/redis/docker-compose.yaml b/devenv/docker/blocks/redis/docker-compose.yaml
index 65071d4966b..fb56afaac1c 100644
--- a/devenv/docker/blocks/redis/docker-compose.yaml
+++ b/devenv/docker/blocks/redis/docker-compose.yaml
@@ -1,4 +1,4 @@
- memcached:
+ redis:
image: redis:latest
ports:
- "6379:6379"
diff --git a/docs/sources/enterprise/index.md b/docs/sources/enterprise/index.md
index 5d524dcbee2..421e94cbf9f 100644
--- a/docs/sources/enterprise/index.md
+++ b/docs/sources/enterprise/index.md
@@ -38,6 +38,8 @@ With a Grafana Enterprise license you will get access to premium plugins, includ
* [DataDog](https://grafana.com/plugins/grafana-datadog-datasource)
* [Dynatrace](https://grafana.com/plugins/grafana-dynatrace-datasource)
* [New Relic](https://grafana.com/plugins/grafana-newrelic-datasource)
+* [Amazon Timestream](https://grafana.com/plugins/grafana-timestream-datasource)
+* [Oracle Database](https://grafana.com/plugins/grafana-oracle-datasource)
## Try Grafana Enterprise
diff --git a/docs/sources/http_api/admin.md b/docs/sources/http_api/admin.md
index a27fd2aac14..c2d540c452b 100644
--- a/docs/sources/http_api/admin.md
+++ b/docs/sources/http_api/admin.md
@@ -341,3 +341,105 @@ Content-Type: application/json
{"state": "new state", "message": "alerts pause/un paused", "alertsAffected": 100}
```
+
+## Auth tokens for User
+
+`GET /api/admin/users/:id/auth-tokens`
+
+Return a list of all auth tokens (devices) that the user currently have logged in from.
+
+Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
+
+**Example Request**:
+
+```http
+GET /api/admin/users/1/auth-tokens HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+[
+ {
+ "id": 361,
+ "isActive": false,
+ "clientIp": "127.0.0.1",
+ "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
+ "createdAt": "2019-03-05T21:22:54+01:00",
+ "seenAt": "2019-03-06T19:41:06+01:00"
+ },
+ {
+ "id": 364,
+ "isActive": false,
+ "clientIp": "127.0.0.1",
+ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
+ "createdAt": "2019-03-06T19:41:19+01:00",
+ "seenAt": "2019-03-06T19:41:21+01:00"
+ }
+]
+```
+
+## Revoke auth token for User
+
+`POST /api/admin/users/:id/revoke-auth-token`
+
+Revokes the given auth token (device) for the user. User of issued auth token (device) will no longer be logged in
+and will be required to authenticate again upon next activity.
+
+Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
+
+**Example Request**:
+
+```http
+POST /api/admin/users/1/revoke-auth-token HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+
+{
+ "authTokenId": 364
+}
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "message": "User auth token revoked"
+}
+```
+
+## Logout User
+
+`POST /api/admin/users/:id/logout`
+
+Logout user revokes all auth tokens (devices) for the user. User of issued auth tokens (devices) will no longer be logged in
+and will be required to authenticate again upon next activity.
+
+Only works with Basic Authentication (username and password). See [introduction](http://docs.grafana.org/http_api/admin/#admin-api) for an explanation.
+
+**Example Request**:
+
+```http
+POST /api/admin/users/1/logout HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "message": "User auth token revoked"
+}
+```
diff --git a/docs/sources/http_api/user.md b/docs/sources/http_api/user.md
index 669e8003247..a81f608c2f5 100644
--- a/docs/sources/http_api/user.md
+++ b/docs/sources/http_api/user.md
@@ -478,3 +478,75 @@ Content-Type: application/json
{"message":"Dashboard unstarred"}
```
+
+## Auth tokens of the actual User
+
+`GET /api/user/auth-tokens`
+
+Return a list of all auth tokens (devices) that the actual user currently have logged in from.
+
+**Example Request**:
+
+```http
+GET /api/user/auth-tokens HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+[
+ {
+ "id": 361,
+ "isActive": true,
+ "clientIp": "127.0.0.1",
+ "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
+ "createdAt": "2019-03-05T21:22:54+01:00",
+ "seenAt": "2019-03-06T19:41:06+01:00"
+ },
+ {
+ "id": 364,
+ "isActive": false,
+ "clientIp": "127.0.0.1",
+ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
+ "createdAt": "2019-03-06T19:41:19+01:00",
+ "seenAt": "2019-03-06T19:41:21+01:00"
+ }
+]
+```
+
+## Revoke an auth token of the actual User
+
+`POST /api/user/revoke-auth-token`
+
+Revokes the given auth token (device) for the actual user. User of issued auth token (device) will no longer be logged in
+and will be required to authenticate again upon next activity.
+
+**Example Request**:
+
+```http
+POST /api/user/revoke-auth-token HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{
+ "authTokenId": 364
+}
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "message": "User auth token revoked"
+}
+```
diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md
index 3d1b25979c3..d94bacc5779 100644
--- a/docs/sources/installation/configuration.md
+++ b/docs/sources/installation/configuration.md
@@ -179,7 +179,6 @@ Path to the certificate key file (if `protocol` is set to `https`).
Set to true for Grafana to log all HTTP requests (not just errors). These are logged as Info level events
to grafana log.
-
@@ -262,6 +261,19 @@ Set to `true` to log the sql calls and execution times.
For "sqlite3" only. [Shared cache](https://www.sqlite.org/sharedcache.html) setting used for connecting to the database. (private, shared)
Defaults to private.
+
+
+## [remote_cache]
+
+### type
+
+Either `redis`, `memcached` or `database` default is `database`
+
+### connstr
+
+The remote cache connection string. Leave empty when using `database` since it will use the primary database.
+Redis example config: `addr=127.0.0.1:6379,pool_size=100,db=grafana`
+Memcache example: `127.0.0.1:11211`
@@ -399,6 +411,22 @@ How long sessions lasts in seconds. Defaults to `86400` (24 hours).
+## [dataproxy]
+
+### logging
+
+This enables data proxy logging, default is false.
+
+### timeout
+
+How long the data proxy should wait before timing out default is 30 (seconds)
+
+### send_user_header
+
+If enabled and user is not anonymous, data proxy will add X-Grafana-User header with username into the request, default is false.
+
+
+
## [analytics]
### reporting_enabled
diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go
index c16c2f126f8..4ad8a2b84ab 100644
--- a/pkg/api/admin_users.go
+++ b/pkg/api/admin_users.go
@@ -110,3 +110,26 @@ func AdminDeleteUser(c *m.ReqContext) {
c.JsonOK("User deleted")
}
+
+// POST /api/admin/users/:id/logout
+func (server *HTTPServer) AdminLogoutUser(c *m.ReqContext) Response {
+ userID := c.ParamsInt64(":id")
+
+ if c.UserId == userID {
+ return Error(400, "You cannot logout yourself", nil)
+ }
+
+ return server.logoutUserFromAllDevicesInternal(userID)
+}
+
+// GET /api/admin/users/:id/auth-tokens
+func (server *HTTPServer) AdminGetUserAuthTokens(c *m.ReqContext) Response {
+ userID := c.ParamsInt64(":id")
+ return server.getUserAuthTokensInternal(c, userID)
+}
+
+// POST /api/admin/users/:id/revoke-auth-token
+func (server *HTTPServer) AdminRevokeUserAuthToken(c *m.ReqContext, cmd m.RevokeAuthTokenCmd) Response {
+ userID := c.ParamsInt64(":id")
+ return server.revokeUserAuthTokenInternal(c, userID, cmd)
+}
diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go
index 0b94a64b3fb..b94f09b0b75 100644
--- a/pkg/api/admin_users_test.go
+++ b/pkg/api/admin_users_test.go
@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/auth"
. "github.com/smartystreets/goconvey/convey"
)
@@ -27,6 +28,62 @@ func TestAdminApiEndpoint(t *testing.T) {
So(sc.resp.Code, ShouldEqual, 400)
})
})
+
+ Convey("When a server admin attempts to logout himself from all devices", t, func() {
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ cmd.Result = &m.User{Id: TestUserID}
+ return nil
+ })
+
+ adminLogoutUserScenario("Should not be allowed when calling POST on", "/api/admin/users/1/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) {
+ sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 400)
+ })
+ })
+
+ Convey("When a server admin attempts to logout a non-existing user from all devices", t, func() {
+ userId := int64(0)
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ userId = cmd.Id
+ return m.ErrUserNotFound
+ })
+
+ adminLogoutUserScenario("Should return not found when calling POST on", "/api/admin/users/200/logout", "/api/admin/users/:id/logout", func(sc *scenarioContext) {
+ sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 404)
+ So(userId, ShouldEqual, 200)
+ })
+ })
+
+ Convey("When a server admin attempts to revoke an auth token for a non-existing user", t, func() {
+ userId := int64(0)
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ userId = cmd.Id
+ return m.ErrUserNotFound
+ })
+
+ cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
+
+ adminRevokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/admin/users/200/revoke-auth-token", "/api/admin/users/:id/revoke-auth-token", cmd, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 404)
+ So(userId, ShouldEqual, 200)
+ })
+ })
+
+ Convey("When a server admin gets auth tokens for a non-existing user", t, func() {
+ userId := int64(0)
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ userId = cmd.Id
+ return m.ErrUserNotFound
+ })
+
+ adminGetUserAuthTokensScenario("Should return not found when calling GET on", "/api/admin/users/200/auth-tokens", "/api/admin/users/:id/auth-tokens", func(sc *scenarioContext) {
+ sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 404)
+ So(userId, ShouldEqual, 200)
+ })
+ })
}
func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) {
@@ -48,3 +105,84 @@ func putAdminScenario(desc string, url string, routePattern string, role m.RoleT
fn(sc)
})
}
+
+func adminLogoutUserScenario(desc string, url string, routePattern string, fn scenarioFunc) {
+ Convey(desc+" "+url, func() {
+ defer bus.ClearBusHandlers()
+
+ hs := HTTPServer{
+ Bus: bus.GetBus(),
+ AuthTokenService: auth.NewFakeUserAuthTokenService(),
+ }
+
+ sc := setupScenarioContext(url)
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = TestUserID
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = m.ROLE_ADMIN
+
+ return hs.AdminLogoutUser(c)
+ })
+
+ sc.m.Post(routePattern, sc.defaultHandler)
+
+ fn(sc)
+ })
+}
+
+func adminRevokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, fn scenarioFunc) {
+ Convey(desc+" "+url, func() {
+ defer bus.ClearBusHandlers()
+
+ fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+ hs := HTTPServer{
+ Bus: bus.GetBus(),
+ AuthTokenService: fakeAuthTokenService,
+ }
+
+ sc := setupScenarioContext(url)
+ sc.userAuthTokenService = fakeAuthTokenService
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = TestUserID
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = m.ROLE_ADMIN
+
+ return hs.AdminRevokeUserAuthToken(c, cmd)
+ })
+
+ sc.m.Post(routePattern, sc.defaultHandler)
+
+ fn(sc)
+ })
+}
+
+func adminGetUserAuthTokensScenario(desc string, url string, routePattern string, fn scenarioFunc) {
+ Convey(desc+" "+url, func() {
+ defer bus.ClearBusHandlers()
+
+ fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+ hs := HTTPServer{
+ Bus: bus.GetBus(),
+ AuthTokenService: fakeAuthTokenService,
+ }
+
+ sc := setupScenarioContext(url)
+ sc.userAuthTokenService = fakeAuthTokenService
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = TestUserID
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = m.ROLE_ADMIN
+
+ return hs.AdminGetUserAuthTokens(c)
+ })
+
+ sc.m.Get(routePattern, sc.defaultHandler)
+
+ fn(sc)
+ })
+}
diff --git a/pkg/api/api.go b/pkg/api/api.go
index 81ea83eae61..f3dc35b6b06 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -133,6 +133,9 @@ func (hs *HTTPServer) registerRoutes() {
userRoute.Get("/preferences", Wrap(GetUserPreferences))
userRoute.Put("/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateUserPreferences))
+
+ userRoute.Get("/auth-tokens", Wrap(hs.GetUserAuthTokens))
+ userRoute.Post("/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.RevokeUserAuthToken))
})
// users (admin permission required)
@@ -375,6 +378,10 @@ func (hs *HTTPServer) registerRoutes() {
adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), Wrap(UpdateUserQuota))
adminRoute.Get("/stats", AdminGetStats)
adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), Wrap(PauseAllAlerts))
+
+ adminRoute.Post("/users/:id/logout", Wrap(hs.AdminLogoutUser))
+ adminRoute.Get("/users/:id/auth-tokens", Wrap(hs.AdminGetUserAuthTokens))
+ adminRoute.Post("/users/:id/revoke-auth-token", bind(m.RevokeAuthTokenCmd{}), Wrap(hs.AdminRevokeUserAuthToken))
}, reqGrafanaAdmin)
// rendering
diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go
index d77d9d87b4a..abee55711b0 100644
--- a/pkg/api/app_routes.go
+++ b/pkg/api/app_routes.go
@@ -48,18 +48,18 @@ func (hs *HTTPServer) initAppPluginRoutes(r *macaron.Macaron) {
handlers = append(handlers, middleware.RoleAuth(m.ROLE_EDITOR, m.ROLE_ADMIN))
}
}
- handlers = append(handlers, AppPluginRoute(route, plugin.Id))
+ handlers = append(handlers, AppPluginRoute(route, plugin.Id, hs))
r.Route(url, route.Method, handlers...)
log.Debug("Plugins: Adding proxy route %s", url)
}
}
}
-func AppPluginRoute(route *plugins.AppPluginRoute, appID string) macaron.Handler {
+func AppPluginRoute(route *plugins.AppPluginRoute, appID string, hs *HTTPServer) macaron.Handler {
return func(c *m.ReqContext) {
path := c.Params("*")
- proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID)
+ proxy := pluginproxy.NewApiPluginProxy(c, path, route, appID, hs.Cfg)
proxy.Transport = pluginProxyTransport
proxy.ServeHTTP(c.Resp, c.Req.Request)
}
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index 3f3a50aae69..4e0b0dcd998 100644
--- a/pkg/api/common_test.go
+++ b/pkg/api/common_test.go
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/auth"
"gopkg.in/macaron.v1"
. "github.com/smartystreets/goconvey/convey"
@@ -94,13 +95,14 @@ func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map
}
type scenarioContext struct {
- m *macaron.Macaron
- context *m.ReqContext
- resp *httptest.ResponseRecorder
- handlerFunc handlerFunc
- defaultHandler macaron.Handler
- req *http.Request
- url string
+ m *macaron.Macaron
+ context *m.ReqContext
+ resp *httptest.ResponseRecorder
+ handlerFunc handlerFunc
+ defaultHandler macaron.Handler
+ req *http.Request
+ url string
+ userAuthTokenService *auth.FakeUserAuthTokenService
}
func (sc *scenarioContext) exec() {
diff --git a/pkg/api/dataproxy.go b/pkg/api/dataproxy.go
index 54a744fccdc..48f9c73c934 100644
--- a/pkg/api/dataproxy.go
+++ b/pkg/api/dataproxy.go
@@ -31,7 +31,7 @@ func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
// macaron does not include trailing slashes when resolving a wildcard path
proxyPath := ensureProxyPathTrailingSlash(c.Req.URL.Path, c.Params("*"))
- proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath)
+ proxy := pluginproxy.NewDataSourceProxy(ds, plugin, c, proxyPath, hs.Cfg)
proxy.HandleRequest()
}
diff --git a/pkg/api/dtos/user_token.go b/pkg/api/dtos/user_token.go
new file mode 100644
index 00000000000..1542421e2f6
--- /dev/null
+++ b/pkg/api/dtos/user_token.go
@@ -0,0 +1,12 @@
+package dtos
+
+import "time"
+
+type UserToken struct {
+ Id int64 `json:"id"`
+ IsActive bool `json:"isActive"`
+ ClientIp string `json:"clientIp"`
+ UserAgent string `json:"userAgent"`
+ CreatedAt time.Time `json:"createdAt"`
+ SeenAt time.Time `json:"seenAt"`
+}
diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go
index b1950998297..3aec988f9e3 100644
--- a/pkg/api/pluginproxy/ds_proxy.go
+++ b/pkg/api/pluginproxy/ds_proxy.go
@@ -34,13 +34,14 @@ type DataSourceProxy struct {
proxyPath string
route *plugins.AppPluginRoute
plugin *plugins.DataSourcePlugin
+ cfg *setting.Cfg
}
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
-func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string) *DataSourceProxy {
+func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx *m.ReqContext, proxyPath string, cfg *setting.Cfg) *DataSourceProxy {
targetURL, _ := url.Parse(ds.Url)
return &DataSourceProxy{
@@ -49,6 +50,7 @@ func NewDataSourceProxy(ds *m.DataSource, plugin *plugins.DataSourcePlugin, ctx
ctx: ctx,
proxyPath: proxyPath,
targetUrl: targetURL,
+ cfg: cfg,
}
}
@@ -170,6 +172,10 @@ func (proxy *DataSourceProxy) getDirector() func(req *http.Request) {
req.Header.Add("Authorization", dsAuth)
}
+ if proxy.cfg.SendUserHeader && !proxy.ctx.SignedInUser.IsAnonymous {
+ req.Header.Add("X-Grafana-User", proxy.ctx.SignedInUser.Login)
+ }
+
// clear cookie header, except for whitelisted cookies
var keptCookies []*http.Cookie
if proxy.ds.JsonData != nil {
diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go
index c9be169565f..bfad7d5670d 100644
--- a/pkg/api/pluginproxy/ds_proxy_test.go
+++ b/pkg/api/pluginproxy/ds_proxy_test.go
@@ -81,7 +81,7 @@ func TestDSRouteRule(t *testing.T) {
}
Convey("When matching route path", func() {
- proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{})
proxy.route = plugin.Routes[0]
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
@@ -92,7 +92,7 @@ func TestDSRouteRule(t *testing.T) {
})
Convey("When matching route path and has dynamic url", func() {
- proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method", &setting.Cfg{})
proxy.route = plugin.Routes[3]
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, proxy.ds)
@@ -104,20 +104,20 @@ func TestDSRouteRule(t *testing.T) {
Convey("Validating request", func() {
Convey("plugin route with valid role", func() {
- proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", &setting.Cfg{})
err := proxy.validateRequest()
So(err, ShouldBeNil)
})
Convey("plugin route with admin role and user is editor", func() {
- proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{})
err := proxy.validateRequest()
So(err, ShouldNotBeNil)
})
Convey("plugin route with admin role and user is admin", func() {
ctx.SignedInUser.OrgRole = m.ROLE_ADMIN
- proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "api/admin", &setting.Cfg{})
err := proxy.validateRequest()
So(err, ShouldBeNil)
})
@@ -186,7 +186,7 @@ func TestDSRouteRule(t *testing.T) {
So(err, ShouldBeNil)
client = newFakeHTTPClient(json)
- proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
+ proxy1 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{})
proxy1.route = plugin.Routes[0]
ApplyRoute(proxy1.ctx.Req.Context(), req, proxy1.proxyPath, proxy1.route, proxy1.ds)
@@ -200,7 +200,7 @@ func TestDSRouteRule(t *testing.T) {
req, _ := http.NewRequest("GET", "http://localhost/asd", nil)
client = newFakeHTTPClient(json2)
- proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2")
+ proxy2 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2", &setting.Cfg{})
proxy2.route = plugin.Routes[1]
ApplyRoute(proxy2.ctx.Req.Context(), req, proxy2.proxyPath, proxy2.route, proxy2.ds)
@@ -215,7 +215,7 @@ func TestDSRouteRule(t *testing.T) {
req, _ := http.NewRequest("GET", "http://localhost/asd", nil)
client = newFakeHTTPClient([]byte{})
- proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1")
+ proxy3 := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", &setting.Cfg{})
proxy3.route = plugin.Routes[0]
ApplyRoute(proxy3.ctx.Req.Context(), req, proxy3.proxyPath, proxy3.route, proxy3.ds)
@@ -236,7 +236,7 @@ func TestDSRouteRule(t *testing.T) {
ds := &m.DataSource{Url: "htttp://graphite:8080", Type: m.DS_GRAPHITE}
ctx := &m.ReqContext{}
- proxy := NewDataSourceProxy(ds, plugin, ctx, "/render")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{})
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
So(err, ShouldBeNil)
@@ -261,7 +261,7 @@ func TestDSRouteRule(t *testing.T) {
}
ctx := &m.ReqContext{}
- proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
So(err, ShouldBeNil)
@@ -291,7 +291,7 @@ func TestDSRouteRule(t *testing.T) {
}
ctx := &m.ReqContext{}
- proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
requestURL, _ := url.Parse("http://grafana.com/sub")
req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -317,7 +317,7 @@ func TestDSRouteRule(t *testing.T) {
}
ctx := &m.ReqContext{}
- proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
requestURL, _ := url.Parse("http://grafana.com/sub")
req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -347,7 +347,7 @@ func TestDSRouteRule(t *testing.T) {
}
ctx := &m.ReqContext{}
- proxy := NewDataSourceProxy(ds, plugin, ctx, "")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{})
requestURL, _ := url.Parse("http://grafana.com/sub")
req := http.Request{URL: requestURL, Header: make(http.Header)}
@@ -369,7 +369,7 @@ func TestDSRouteRule(t *testing.T) {
Url: "http://host/root/",
}
ctx := &m.ReqContext{}
- proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/")
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/", &setting.Cfg{})
req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
req.Header.Add("Origin", "grafana.com")
req.Header.Add("Referer", "grafana.com")
@@ -388,9 +388,68 @@ func TestDSRouteRule(t *testing.T) {
So(req.Header.Get("X-Canary"), ShouldEqual, "stillthere")
})
})
+
+ Convey("When SendUserHeader config is enabled", func() {
+ req := getDatasourceProxiedRequest(
+ &m.ReqContext{
+ SignedInUser: &m.SignedInUser{
+ Login: "test_user",
+ },
+ },
+ &setting.Cfg{SendUserHeader: true},
+ )
+ Convey("Should add header with username", func() {
+ So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user")
+ })
+ })
+
+ Convey("When SendUserHeader config is disabled", func() {
+ req := getDatasourceProxiedRequest(
+ &m.ReqContext{
+ SignedInUser: &m.SignedInUser{
+ Login: "test_user",
+ },
+ },
+ &setting.Cfg{SendUserHeader: false},
+ )
+ Convey("Should not add header with username", func() {
+ // Get will return empty string even if header is not set
+ So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
+ })
+ })
+
+ Convey("When SendUserHeader config is enabled but user is anonymous", func() {
+ req := getDatasourceProxiedRequest(
+ &m.ReqContext{
+ SignedInUser: &m.SignedInUser{IsAnonymous: true},
+ },
+ &setting.Cfg{SendUserHeader: true},
+ )
+ Convey("Should not add header with username", func() {
+ // Get will return empty string even if header is not set
+ So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
+ })
+ })
})
}
+// getDatasourceProxiedRequest is a helper for easier setup of tests based on global config and ReqContext.
+func getDatasourceProxiedRequest(ctx *m.ReqContext, cfg *setting.Cfg) *http.Request {
+ plugin := &plugins.DataSourcePlugin{}
+
+ ds := &m.DataSource{
+ Type: "custom",
+ Url: "http://host/root/",
+ }
+
+ proxy := NewDataSourceProxy(ds, plugin, ctx, "", cfg)
+ req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
+ So(err, ShouldBeNil)
+
+ proxy.getDirector()(req)
+ return req
+}
+
type httpClientStub struct {
fakeBody []byte
}
diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go
index 4cf1dbd7cde..5ee59017a82 100644
--- a/pkg/api/pluginproxy/pluginproxy.go
+++ b/pkg/api/pluginproxy/pluginproxy.go
@@ -2,6 +2,7 @@ package pluginproxy
import (
"encoding/json"
+ "github.com/grafana/grafana/pkg/setting"
"net"
"net/http"
"net/http/httputil"
@@ -37,7 +38,7 @@ func getHeaders(route *plugins.AppPluginRoute, orgId int64, appID string) (http.
return result, err
}
-func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string) *httputil.ReverseProxy {
+func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPluginRoute, appID string, cfg *setting.Cfg) *httputil.ReverseProxy {
targetURL, _ := url.Parse(route.Url)
director := func(req *http.Request) {
@@ -79,6 +80,10 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl
req.Header.Add("X-Grafana-Context", string(ctxJson))
+ if cfg.SendUserHeader && !ctx.SignedInUser.IsAnonymous {
+ req.Header.Add("X-Grafana-User", ctx.SignedInUser.Login)
+ }
+
if len(route.Headers) > 0 {
headers, err := getHeaders(route, ctx.OrgId, appID)
if err != nil {
diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go
index 424c3fd670c..e4a4fdb25ba 100644
--- a/pkg/api/pluginproxy/pluginproxy_test.go
+++ b/pkg/api/pluginproxy/pluginproxy_test.go
@@ -1,6 +1,7 @@
package pluginproxy
import (
+ "net/http"
"testing"
"github.com/grafana/grafana/pkg/bus"
@@ -44,4 +45,59 @@ func TestPluginProxy(t *testing.T) {
})
})
+ Convey("When SendUserHeader config is enabled", t, func() {
+ req := getPluginProxiedRequest(
+ &m.ReqContext{
+ SignedInUser: &m.SignedInUser{
+ Login: "test_user",
+ },
+ },
+ &setting.Cfg{SendUserHeader: true},
+ )
+
+ Convey("Should add header with username", func() {
+ // Get will return empty string even if header is not set
+ So(req.Header.Get("X-Grafana-User"), ShouldEqual, "test_user")
+ })
+ })
+
+ Convey("When SendUserHeader config is disabled", t, func() {
+ req := getPluginProxiedRequest(
+ &m.ReqContext{
+ SignedInUser: &m.SignedInUser{
+ Login: "test_user",
+ },
+ },
+ &setting.Cfg{SendUserHeader: false},
+ )
+ Convey("Should not add header with username", func() {
+ // Get will return empty string even if header is not set
+ So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
+ })
+ })
+
+ Convey("When SendUserHeader config is enabled but user is anonymous", t, func() {
+ req := getPluginProxiedRequest(
+ &m.ReqContext{
+ SignedInUser: &m.SignedInUser{IsAnonymous: true},
+ },
+ &setting.Cfg{SendUserHeader: true},
+ )
+
+ Convey("Should not add header with username", func() {
+ // Get will return empty string even if header is not set
+ So(req.Header.Get("X-Grafana-User"), ShouldEqual, "")
+ })
+ })
+}
+
+// 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 {
+ route := &plugins.AppPluginRoute{}
+ proxy := NewApiPluginProxy(ctx, "", route, "", cfg)
+
+ req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
+ So(err, ShouldBeNil)
+ proxy.Director(req)
+ return req
}
diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go
new file mode 100644
index 00000000000..2f74eedea5d
--- /dev/null
+++ b/pkg/api/user_token.go
@@ -0,0 +1,110 @@
+package api
+
+import (
+ "time"
+
+ "github.com/grafana/grafana/pkg/api/dtos"
+ "github.com/grafana/grafana/pkg/bus"
+ "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/util"
+)
+
+// GET /api/user/auth-tokens
+func (server *HTTPServer) GetUserAuthTokens(c *models.ReqContext) Response {
+ return server.getUserAuthTokensInternal(c, c.UserId)
+}
+
+// POST /api/user/revoke-auth-token
+func (server *HTTPServer) RevokeUserAuthToken(c *models.ReqContext, cmd models.RevokeAuthTokenCmd) Response {
+ return server.revokeUserAuthTokenInternal(c, c.UserId, cmd)
+}
+
+func (server *HTTPServer) logoutUserFromAllDevicesInternal(userID int64) Response {
+ userQuery := models.GetUserByIdQuery{Id: userID}
+
+ if err := bus.Dispatch(&userQuery); err != nil {
+ if err == models.ErrUserNotFound {
+ return Error(404, "User not found", err)
+ }
+ return Error(500, "Could not read user from database", err)
+ }
+
+ err := server.AuthTokenService.RevokeAllUserTokens(userID)
+ if err != nil {
+ return Error(500, "Failed to logout user", err)
+ }
+
+ return JSON(200, util.DynMap{
+ "message": "User logged out",
+ })
+}
+
+func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID int64) Response {
+ userQuery := models.GetUserByIdQuery{Id: userID}
+
+ if err := bus.Dispatch(&userQuery); err != nil {
+ if err == models.ErrUserNotFound {
+ return Error(404, "User not found", err)
+ }
+ return Error(500, "Failed to get user", err)
+ }
+
+ tokens, err := server.AuthTokenService.GetUserTokens(userID)
+ if err != nil {
+ return Error(500, "Failed to get user auth tokens", err)
+ }
+
+ result := []*dtos.UserToken{}
+ for _, token := range tokens {
+ isActive := false
+ if c.UserToken != nil && c.UserToken.Id == token.Id {
+ isActive = true
+ }
+
+ result = append(result, &dtos.UserToken{
+ Id: token.Id,
+ IsActive: isActive,
+ ClientIp: token.ClientIp,
+ UserAgent: token.UserAgent,
+ CreatedAt: time.Unix(token.CreatedAt, 0),
+ SeenAt: time.Unix(token.SeenAt, 0),
+ })
+ }
+
+ return JSON(200, result)
+}
+
+func (server *HTTPServer) revokeUserAuthTokenInternal(c *models.ReqContext, userID int64, cmd models.RevokeAuthTokenCmd) Response {
+ userQuery := models.GetUserByIdQuery{Id: userID}
+
+ if err := bus.Dispatch(&userQuery); err != nil {
+ if err == models.ErrUserNotFound {
+ return Error(404, "User not found", err)
+ }
+ return Error(500, "Failed to get user", err)
+ }
+
+ token, err := server.AuthTokenService.GetUserToken(userID, cmd.AuthTokenId)
+ if err != nil {
+ if err == models.ErrUserTokenNotFound {
+ return Error(404, "User auth token not found", err)
+ }
+ return Error(500, "Failed to get user auth token", err)
+ }
+
+ if c.UserToken != nil && c.UserToken.Id == token.Id {
+ return Error(400, "Cannot revoke active user auth token", nil)
+ }
+
+ err = server.AuthTokenService.RevokeToken(token)
+ if err != nil {
+ if err == models.ErrUserTokenNotFound {
+ return Error(404, "User auth token not found", err)
+ }
+ return Error(500, "Failed to revoke user auth token", err)
+ }
+
+ return JSON(200, util.DynMap{
+ "message": "User auth token revoked",
+ })
+}
diff --git a/pkg/api/user_token_test.go b/pkg/api/user_token_test.go
new file mode 100644
index 00000000000..111070dca92
--- /dev/null
+++ b/pkg/api/user_token_test.go
@@ -0,0 +1,294 @@
+package api
+
+import (
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana/pkg/bus"
+ m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/auth"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestUserTokenApiEndpoint(t *testing.T) {
+ Convey("When current user attempts to revoke an auth token for a non-existing user", t, func() {
+ userId := int64(0)
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ userId = cmd.Id
+ return m.ErrUserNotFound
+ })
+
+ cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
+
+ revokeUserAuthTokenScenario("Should return not found when calling POST on", "/api/user/revoke-auth-token", "/api/user/revoke-auth-token", cmd, 200, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 404)
+ So(userId, ShouldEqual, 200)
+ })
+ })
+
+ Convey("When current user gets auth tokens for a non-existing user", t, func() {
+ userId := int64(0)
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ userId = cmd.Id
+ return m.ErrUserNotFound
+ })
+
+ getUserAuthTokensScenario("Should return not found when calling GET on", "/api/user/auth-tokens", "/api/user/auth-tokens", 200, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 404)
+ So(userId, ShouldEqual, 200)
+ })
+ })
+
+ Convey("When logout an existing user from all devices", t, func() {
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ cmd.Result = &m.User{Id: 200}
+ return nil
+ })
+
+ logoutUserFromAllDevicesInternalScenario("Should be successful", 1, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 200)
+ })
+ })
+
+ Convey("When logout a non-existing user from all devices", t, func() {
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ return m.ErrUserNotFound
+ })
+
+ logoutUserFromAllDevicesInternalScenario("Should return not found", TestUserID, func(sc *scenarioContext) {
+ sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 404)
+ })
+ })
+
+ Convey("When revoke an auth token for a user", t, func() {
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ cmd.Result = &m.User{Id: 200}
+ return nil
+ })
+
+ cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
+ token := &m.UserToken{Id: 1}
+
+ revokeUserAuthTokenInternalScenario("Should be successful", cmd, 200, token, func(sc *scenarioContext) {
+ sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) {
+ return &m.UserToken{Id: 2}, nil
+ }
+ sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 200)
+ })
+ })
+
+ Convey("When revoke the active auth token used by himself", t, func() {
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ cmd.Result = &m.User{Id: TestUserID}
+ return nil
+ })
+
+ cmd := m.RevokeAuthTokenCmd{AuthTokenId: 2}
+ token := &m.UserToken{Id: 2}
+
+ revokeUserAuthTokenInternalScenario("Should not be successful", cmd, TestUserID, token, func(sc *scenarioContext) {
+ sc.userAuthTokenService.GetUserTokenProvider = func(userId, userTokenId int64) (*m.UserToken, error) {
+ return token, nil
+ }
+ sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
+ So(sc.resp.Code, ShouldEqual, 400)
+ })
+ })
+
+ Convey("When gets auth tokens for a user", t, func() {
+ bus.AddHandler("test", func(cmd *m.GetUserByIdQuery) error {
+ cmd.Result = &m.User{Id: TestUserID}
+ return nil
+ })
+
+ currentToken := &m.UserToken{Id: 1}
+
+ getUserAuthTokensInternalScenario("Should be successful", currentToken, func(sc *scenarioContext) {
+ tokens := []*m.UserToken{
+ {
+ Id: 1,
+ ClientIp: "127.0.0.1",
+ UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
+ CreatedAt: time.Now().Unix(),
+ SeenAt: time.Now().Unix(),
+ },
+ {
+ Id: 2,
+ ClientIp: "127.0.0.2",
+ UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
+ CreatedAt: time.Now().Unix(),
+ SeenAt: time.Now().Unix(),
+ },
+ }
+ sc.userAuthTokenService.GetUserTokensProvider = func(userId int64) ([]*m.UserToken, error) {
+ return tokens, nil
+ }
+ sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+
+ So(sc.resp.Code, ShouldEqual, 200)
+ result := sc.ToJSON()
+ So(result.MustArray(), ShouldHaveLength, 2)
+
+ resultOne := result.GetIndex(0)
+ So(resultOne.Get("id").MustInt64(), ShouldEqual, tokens[0].Id)
+ So(resultOne.Get("isActive").MustBool(), ShouldBeTrue)
+ So(resultOne.Get("clientIp").MustString(), ShouldEqual, "127.0.0.1")
+ So(resultOne.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36")
+ So(resultOne.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[0].CreatedAt, 0).Format(time.RFC3339))
+ So(resultOne.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[0].SeenAt, 0).Format(time.RFC3339))
+
+ resultTwo := result.GetIndex(1)
+ So(resultTwo.Get("id").MustInt64(), ShouldEqual, tokens[1].Id)
+ So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse)
+ So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2")
+ So(resultTwo.Get("userAgent").MustString(), ShouldEqual, "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1")
+ So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339))
+ So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339))
+ })
+ })
+}
+
+func revokeUserAuthTokenScenario(desc string, url string, routePattern string, cmd m.RevokeAuthTokenCmd, userId int64, fn scenarioFunc) {
+ Convey(desc+" "+url, func() {
+ defer bus.ClearBusHandlers()
+
+ fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+ hs := HTTPServer{
+ Bus: bus.GetBus(),
+ AuthTokenService: fakeAuthTokenService,
+ }
+
+ sc := setupScenarioContext(url)
+ sc.userAuthTokenService = fakeAuthTokenService
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = userId
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = m.ROLE_ADMIN
+
+ return hs.RevokeUserAuthToken(c, cmd)
+ })
+
+ sc.m.Post(routePattern, sc.defaultHandler)
+
+ fn(sc)
+ })
+}
+
+func getUserAuthTokensScenario(desc string, url string, routePattern string, userId int64, fn scenarioFunc) {
+ Convey(desc+" "+url, func() {
+ defer bus.ClearBusHandlers()
+
+ fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+ hs := HTTPServer{
+ Bus: bus.GetBus(),
+ AuthTokenService: fakeAuthTokenService,
+ }
+
+ sc := setupScenarioContext(url)
+ sc.userAuthTokenService = fakeAuthTokenService
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = userId
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = m.ROLE_ADMIN
+
+ return hs.GetUserAuthTokens(c)
+ })
+
+ sc.m.Get(routePattern, sc.defaultHandler)
+
+ fn(sc)
+ })
+}
+
+func logoutUserFromAllDevicesInternalScenario(desc string, userId int64, fn scenarioFunc) {
+ Convey(desc, func() {
+ defer bus.ClearBusHandlers()
+
+ hs := HTTPServer{
+ Bus: bus.GetBus(),
+ AuthTokenService: auth.NewFakeUserAuthTokenService(),
+ }
+
+ sc := setupScenarioContext("/")
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = TestUserID
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = m.ROLE_ADMIN
+
+ return hs.logoutUserFromAllDevicesInternal(userId)
+ })
+
+ sc.m.Post("/", sc.defaultHandler)
+
+ fn(sc)
+ })
+}
+
+func revokeUserAuthTokenInternalScenario(desc string, cmd m.RevokeAuthTokenCmd, userId int64, token *m.UserToken, fn scenarioFunc) {
+ Convey(desc, func() {
+ defer bus.ClearBusHandlers()
+
+ fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+ hs := HTTPServer{
+ Bus: bus.GetBus(),
+ AuthTokenService: fakeAuthTokenService,
+ }
+
+ sc := setupScenarioContext("/")
+ sc.userAuthTokenService = fakeAuthTokenService
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = TestUserID
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = m.ROLE_ADMIN
+ sc.context.UserToken = token
+
+ return hs.revokeUserAuthTokenInternal(c, userId, cmd)
+ })
+
+ sc.m.Post("/", sc.defaultHandler)
+
+ fn(sc)
+ })
+}
+
+func getUserAuthTokensInternalScenario(desc string, token *m.UserToken, fn scenarioFunc) {
+ Convey(desc, func() {
+ defer bus.ClearBusHandlers()
+
+ fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
+
+ hs := HTTPServer{
+ Bus: bus.GetBus(),
+ AuthTokenService: fakeAuthTokenService,
+ }
+
+ sc := setupScenarioContext("/")
+ sc.userAuthTokenService = fakeAuthTokenService
+ sc.defaultHandler = Wrap(func(c *m.ReqContext) Response {
+ sc.context = c
+ sc.context.UserId = TestUserID
+ sc.context.OrgId = TestOrgID
+ sc.context.OrgRole = m.ROLE_ADMIN
+ sc.context.UserToken = token
+
+ return hs.getUserAuthTokensInternal(c, TestUserID)
+ })
+
+ sc.m.Get("/", sc.defaultHandler)
+
+ fn(sc)
+ })
+}
diff --git a/pkg/cmd/grafana-server/server.go b/pkg/cmd/grafana-server/server.go
index 53218147ae0..c10212329cf 100644
--- a/pkg/cmd/grafana-server/server.go
+++ b/pkg/cmd/grafana-server/server.go
@@ -29,6 +29,7 @@ import (
// self registering services
_ "github.com/grafana/grafana/pkg/extensions"
_ "github.com/grafana/grafana/pkg/infra/metrics"
+ _ "github.com/grafana/grafana/pkg/infra/remotecache"
_ "github.com/grafana/grafana/pkg/infra/serverlock"
_ "github.com/grafana/grafana/pkg/infra/tracing"
_ "github.com/grafana/grafana/pkg/infra/usagestats"
diff --git a/pkg/infra/remotecache/database_storage.go b/pkg/infra/remotecache/database_storage.go
new file mode 100644
index 00000000000..1c39d74d800
--- /dev/null
+++ b/pkg/infra/remotecache/database_storage.go
@@ -0,0 +1,126 @@
+package remotecache
+
+import (
+ "context"
+ "time"
+
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+)
+
+var getTime = time.Now
+
+const databaseCacheType = "database"
+
+type databaseCache struct {
+ SQLStore *sqlstore.SqlStore
+ log log.Logger
+}
+
+func newDatabaseCache(sqlstore *sqlstore.SqlStore) *databaseCache {
+ dc := &databaseCache{
+ SQLStore: sqlstore,
+ log: log.New("remotecache.database"),
+ }
+
+ return dc
+}
+
+func (dc *databaseCache) Run(ctx context.Context) error {
+ ticker := time.NewTicker(time.Minute * 10)
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ dc.internalRunGC()
+ }
+ }
+}
+
+func (dc *databaseCache) internalRunGC() {
+ now := getTime().Unix()
+ sql := `DELETE FROM cache_data WHERE (? - created_at) >= expires AND expires <> 0`
+
+ _, err := dc.SQLStore.NewSession().Exec(sql, now)
+ if err != nil {
+ dc.log.Error("failed to run garbage collect", "error", err)
+ }
+}
+
+func (dc *databaseCache) Get(key string) (interface{}, error) {
+ cacheHit := CacheData{}
+ session := dc.SQLStore.NewSession()
+ defer session.Close()
+
+ exist, err := session.Where("cache_key= ?", key).Get(&cacheHit)
+
+ if err != nil {
+ return nil, err
+ }
+
+ if !exist {
+ return nil, ErrCacheItemNotFound
+ }
+
+ if cacheHit.Expires > 0 {
+ existedButExpired := getTime().Unix()-cacheHit.CreatedAt >= cacheHit.Expires
+ if existedButExpired {
+ _ = dc.Delete(key) //ignore this error since we will return `ErrCacheItemNotFound` anyway
+ return nil, ErrCacheItemNotFound
+ }
+ }
+
+ item := &cachedItem{}
+ if err = decodeGob(cacheHit.Data, item); err != nil {
+ return nil, err
+ }
+
+ return item.Val, nil
+}
+
+func (dc *databaseCache) Set(key string, value interface{}, expire time.Duration) error {
+ item := &cachedItem{Val: value}
+ data, err := encodeGob(item)
+ if err != nil {
+ return err
+ }
+
+ session := dc.SQLStore.NewSession()
+
+ var cacheHit CacheData
+ has, err := session.Where("cache_key = ?", key).Get(&cacheHit)
+ if err != nil {
+ return err
+ }
+
+ var expiresInSeconds int64
+ if expire != 0 {
+ expiresInSeconds = int64(expire) / int64(time.Second)
+ }
+
+ // insert or update depending on if item already exist
+ if has {
+ sql := `UPDATE cache_data SET data=?, created=?, expire=? WHERE cache_key='?'`
+ _, err = session.Exec(sql, data, getTime().Unix(), expiresInSeconds, key)
+ } else {
+ sql := `INSERT INTO cache_data (cache_key,data,created_at,expires) VALUES(?,?,?,?)`
+ _, err = session.Exec(sql, key, data, getTime().Unix(), expiresInSeconds)
+ }
+
+ return err
+}
+
+func (dc *databaseCache) Delete(key string) error {
+ sql := "DELETE FROM cache_data WHERE cache_key=?"
+ _, err := dc.SQLStore.NewSession().Exec(sql, key)
+
+ return err
+}
+
+type CacheData struct {
+ CacheKey string
+ Data []byte
+ Expires int64
+ CreatedAt int64
+}
diff --git a/pkg/infra/remotecache/database_storage_test.go b/pkg/infra/remotecache/database_storage_test.go
new file mode 100644
index 00000000000..d15e26fd07f
--- /dev/null
+++ b/pkg/infra/remotecache/database_storage_test.go
@@ -0,0 +1,56 @@
+package remotecache
+
+import (
+ "testing"
+ "time"
+
+ "github.com/bmizerany/assert"
+
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+)
+
+func TestDatabaseStorageGarbageCollection(t *testing.T) {
+ sqlstore := sqlstore.InitTestDB(t)
+
+ db := &databaseCache{
+ SQLStore: sqlstore,
+ log: log.New("remotecache.database"),
+ }
+
+ obj := &CacheableStruct{String: "foolbar"}
+
+ //set time.now to 2 weeks ago
+ var err error
+ getTime = func() time.Time { return time.Now().AddDate(0, 0, -2) }
+ err = db.Set("key1", obj, 1000*time.Second)
+ assert.Equal(t, err, nil)
+
+ err = db.Set("key2", obj, 1000*time.Second)
+ assert.Equal(t, err, nil)
+
+ err = db.Set("key3", obj, 1000*time.Second)
+ assert.Equal(t, err, nil)
+
+ // insert object that should never expire
+ db.Set("key4", obj, 0)
+
+ getTime = time.Now
+ db.Set("key5", obj, 1000*time.Second)
+
+ //run GC
+ db.internalRunGC()
+
+ //try to read values
+ _, err = db.Get("key1")
+ assert.Equal(t, err, ErrCacheItemNotFound, "expected cache item not found. got: ", err)
+ _, err = db.Get("key2")
+ assert.Equal(t, err, ErrCacheItemNotFound)
+ _, err = db.Get("key3")
+ assert.Equal(t, err, ErrCacheItemNotFound)
+
+ _, err = db.Get("key4")
+ assert.Equal(t, err, nil)
+ _, err = db.Get("key5")
+ assert.Equal(t, err, nil)
+}
diff --git a/pkg/infra/remotecache/memcached_storage.go b/pkg/infra/remotecache/memcached_storage.go
new file mode 100644
index 00000000000..5424a05ad02
--- /dev/null
+++ b/pkg/infra/remotecache/memcached_storage.go
@@ -0,0 +1,71 @@
+package remotecache
+
+import (
+ "time"
+
+ "github.com/bradfitz/gomemcache/memcache"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+const memcachedCacheType = "memcached"
+
+type memcachedStorage struct {
+ c *memcache.Client
+}
+
+func newMemcachedStorage(opts *setting.RemoteCacheOptions) *memcachedStorage {
+ return &memcachedStorage{
+ c: memcache.New(opts.ConnStr),
+ }
+}
+
+func newItem(sid string, data []byte, expire int32) *memcache.Item {
+ return &memcache.Item{
+ Key: sid,
+ Value: data,
+ Expiration: expire,
+ }
+}
+
+// Set sets value to given key in the cache.
+func (s *memcachedStorage) Set(key string, val interface{}, expires time.Duration) error {
+ item := &cachedItem{Val: val}
+ bytes, err := encodeGob(item)
+ if err != nil {
+ return err
+ }
+
+ var expiresInSeconds int64
+ if expires != 0 {
+ expiresInSeconds = int64(expires) / int64(time.Second)
+ }
+
+ memcachedItem := newItem(key, bytes, int32(expiresInSeconds))
+ return s.c.Set(memcachedItem)
+}
+
+// Get gets value by given key in the cache.
+func (s *memcachedStorage) Get(key string) (interface{}, error) {
+ memcachedItem, err := s.c.Get(key)
+ if err != nil && err.Error() == "memcache: cache miss" {
+ return nil, ErrCacheItemNotFound
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ item := &cachedItem{}
+
+ err = decodeGob(memcachedItem.Value, item)
+ if err != nil {
+ return nil, err
+ }
+
+ return item.Val, nil
+}
+
+// Delete delete a key from the cache
+func (s *memcachedStorage) Delete(key string) error {
+ return s.c.Delete(key)
+}
diff --git a/pkg/infra/remotecache/memcached_storage_integration_test.go b/pkg/infra/remotecache/memcached_storage_integration_test.go
new file mode 100644
index 00000000000..d1d82468644
--- /dev/null
+++ b/pkg/infra/remotecache/memcached_storage_integration_test.go
@@ -0,0 +1,15 @@
+// +build memcached
+
+package remotecache
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+func TestMemcachedCacheStorage(t *testing.T) {
+ opts := &setting.RemoteCacheOptions{Name: memcachedCacheType, ConnStr: "localhost:11211"}
+ client := createTestClient(t, opts, nil)
+ runTestsForClient(t, client)
+}
diff --git a/pkg/infra/remotecache/redis_storage.go b/pkg/infra/remotecache/redis_storage.go
new file mode 100644
index 00000000000..bd54b843119
--- /dev/null
+++ b/pkg/infra/remotecache/redis_storage.go
@@ -0,0 +1,62 @@
+package remotecache
+
+import (
+ "time"
+
+ "github.com/grafana/grafana/pkg/setting"
+ redis "gopkg.in/redis.v2"
+)
+
+const redisCacheType = "redis"
+
+type redisStorage struct {
+ c *redis.Client
+}
+
+func newRedisStorage(opts *setting.RemoteCacheOptions) *redisStorage {
+ opt := &redis.Options{
+ Network: "tcp",
+ Addr: opts.ConnStr,
+ }
+ return &redisStorage{c: redis.NewClient(opt)}
+}
+
+// Set sets value to given key in session.
+func (s *redisStorage) Set(key string, val interface{}, expires time.Duration) error {
+ item := &cachedItem{Val: val}
+ value, err := encodeGob(item)
+ if err != nil {
+ return err
+ }
+
+ status := s.c.SetEx(key, expires, string(value))
+ return status.Err()
+}
+
+// Get gets value by given key in session.
+func (s *redisStorage) Get(key string) (interface{}, error) {
+ v := s.c.Get(key)
+
+ item := &cachedItem{}
+ err := decodeGob([]byte(v.Val()), item)
+
+ if err == nil {
+ return item.Val, nil
+ }
+
+ if err.Error() == "EOF" {
+ return nil, ErrCacheItemNotFound
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return item.Val, nil
+}
+
+// Delete delete a key from session.
+func (s *redisStorage) Delete(key string) error {
+ cmd := s.c.Del(key)
+ return cmd.Err()
+}
diff --git a/pkg/infra/remotecache/redis_storage_integration_test.go b/pkg/infra/remotecache/redis_storage_integration_test.go
new file mode 100644
index 00000000000..8d54fc9ff14
--- /dev/null
+++ b/pkg/infra/remotecache/redis_storage_integration_test.go
@@ -0,0 +1,16 @@
+// +build redis
+
+package remotecache
+
+import (
+ "testing"
+
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+func TestRedisCacheStorage(t *testing.T) {
+
+ opts := &setting.RemoteCacheOptions{Name: redisCacheType, ConnStr: "localhost:6379"}
+ client := createTestClient(t, opts, nil)
+ runTestsForClient(t, client)
+}
diff --git a/pkg/infra/remotecache/remotecache.go b/pkg/infra/remotecache/remotecache.go
new file mode 100644
index 00000000000..9219fa33a08
--- /dev/null
+++ b/pkg/infra/remotecache/remotecache.go
@@ -0,0 +1,133 @@
+package remotecache
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+ "errors"
+ "time"
+
+ "github.com/grafana/grafana/pkg/setting"
+
+ "github.com/grafana/grafana/pkg/log"
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+
+ "github.com/grafana/grafana/pkg/registry"
+)
+
+var (
+ // ErrCacheItemNotFound is returned if cache does not exist
+ ErrCacheItemNotFound = errors.New("cache item not found")
+
+ // ErrInvalidCacheType is returned if the type is invalid
+ ErrInvalidCacheType = errors.New("invalid remote cache name")
+
+ defaultMaxCacheExpiration = time.Hour * 24
+)
+
+func init() {
+ registry.RegisterService(&RemoteCache{})
+}
+
+// CacheStorage allows the caller to set, get and delete items in the cache.
+// Cached items are stored as byte arrays and marshalled using "encoding/gob"
+// so any struct added to the cache needs to be registred with `remotecache.Register`
+// ex `remotecache.Register(CacheableStruct{})``
+type CacheStorage interface {
+ // Get reads object from Cache
+ Get(key string) (interface{}, error)
+
+ // Set sets an object into the cache. if `expire` is set to zero it will default to 24h
+ Set(key string, value interface{}, expire time.Duration) error
+
+ // Delete object from cache
+ Delete(key string) error
+}
+
+// RemoteCache allows Grafana to cache data outside its own process
+type RemoteCache struct {
+ log log.Logger
+ client CacheStorage
+ SQLStore *sqlstore.SqlStore `inject:""`
+ Cfg *setting.Cfg `inject:""`
+}
+
+// Get reads object from Cache
+func (ds *RemoteCache) Get(key string) (interface{}, error) {
+ return ds.client.Get(key)
+}
+
+// Set sets an object into the cache. if `expire` is set to zero it will default to 24h
+func (ds *RemoteCache) Set(key string, value interface{}, expire time.Duration) error {
+ if expire == 0 {
+ expire = defaultMaxCacheExpiration
+ }
+
+ return ds.client.Set(key, value, expire)
+}
+
+// Delete object from cache
+func (ds *RemoteCache) Delete(key string) error {
+ return ds.client.Delete(key)
+}
+
+// Init initializes the service
+func (ds *RemoteCache) Init() error {
+ ds.log = log.New("cache.remote")
+ var err error
+ ds.client, err = createClient(ds.Cfg.RemoteCacheOptions, ds.SQLStore)
+ return err
+}
+
+// Run start the backend processes for cache clients
+func (ds *RemoteCache) Run(ctx context.Context) error {
+ //create new interface if more clients need GC jobs
+ backgroundjob, ok := ds.client.(registry.BackgroundService)
+ if ok {
+ return backgroundjob.Run(ctx)
+ }
+
+ <-ctx.Done()
+ return ctx.Err()
+}
+
+func createClient(opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) (CacheStorage, error) {
+ if opts.Name == redisCacheType {
+ return newRedisStorage(opts), nil
+ }
+
+ if opts.Name == memcachedCacheType {
+ return newMemcachedStorage(opts), nil
+ }
+
+ if opts.Name == databaseCacheType {
+ return newDatabaseCache(sqlstore), nil
+ }
+
+ return nil, ErrInvalidCacheType
+}
+
+// Register records a type, identified by a value for that type, under its
+// internal type name. That name will identify the concrete type of a value
+// sent or received as an interface variable. Only types that will be
+// transferred as implementations of interface values need to be registered.
+// Expecting to be used only during initialization, it panics if the mapping
+// between types and names is not a bijection.
+func Register(value interface{}) {
+ gob.Register(value)
+}
+
+type cachedItem struct {
+ Val interface{}
+}
+
+func encodeGob(item *cachedItem) ([]byte, error) {
+ buf := bytes.NewBuffer(nil)
+ err := gob.NewEncoder(buf).Encode(item)
+ return buf.Bytes(), err
+}
+
+func decodeGob(data []byte, out *cachedItem) error {
+ buf := bytes.NewBuffer(data)
+ return gob.NewDecoder(buf).Decode(&out)
+}
diff --git a/pkg/infra/remotecache/remotecache_test.go b/pkg/infra/remotecache/remotecache_test.go
new file mode 100644
index 00000000000..bf1675ec87c
--- /dev/null
+++ b/pkg/infra/remotecache/remotecache_test.go
@@ -0,0 +1,93 @@
+package remotecache
+
+import (
+ "testing"
+ "time"
+
+ "github.com/bmizerany/assert"
+
+ "github.com/grafana/grafana/pkg/services/sqlstore"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+type CacheableStruct struct {
+ String string
+ Int64 int64
+}
+
+func init() {
+ Register(CacheableStruct{})
+}
+
+func createTestClient(t *testing.T, opts *setting.RemoteCacheOptions, sqlstore *sqlstore.SqlStore) CacheStorage {
+ t.Helper()
+
+ dc := &RemoteCache{
+ SQLStore: sqlstore,
+ Cfg: &setting.Cfg{
+ RemoteCacheOptions: opts,
+ },
+ }
+
+ err := dc.Init()
+ if err != nil {
+ t.Fatalf("failed to init client for test. error: %v", err)
+ }
+
+ return dc
+}
+
+func TestCachedBasedOnConfig(t *testing.T) {
+
+ cfg := setting.NewCfg()
+ cfg.Load(&setting.CommandLineArgs{
+ HomePath: "../../../",
+ })
+
+ client := createTestClient(t, cfg.RemoteCacheOptions, sqlstore.InitTestDB(t))
+ runTestsForClient(t, client)
+}
+
+func TestInvalidCacheTypeReturnsError(t *testing.T) {
+ _, err := createClient(&setting.RemoteCacheOptions{Name: "invalid"}, nil)
+ assert.Equal(t, err, ErrInvalidCacheType)
+}
+
+func runTestsForClient(t *testing.T, client CacheStorage) {
+ canPutGetAndDeleteCachedObjects(t, client)
+ canNotFetchExpiredItems(t, client)
+}
+
+func canPutGetAndDeleteCachedObjects(t *testing.T, client CacheStorage) {
+ cacheableStruct := CacheableStruct{String: "hej", Int64: 2000}
+
+ err := client.Set("key1", cacheableStruct, 0)
+ assert.Equal(t, err, nil, "expected nil. got: ", err)
+
+ data, err := client.Get("key1")
+ s, ok := data.(CacheableStruct)
+
+ assert.Equal(t, ok, true)
+ assert.Equal(t, s.String, "hej")
+ assert.Equal(t, s.Int64, int64(2000))
+
+ err = client.Delete("key1")
+ assert.Equal(t, err, nil)
+
+ _, err = client.Get("key1")
+ assert.Equal(t, err, ErrCacheItemNotFound)
+}
+
+func canNotFetchExpiredItems(t *testing.T, client CacheStorage) {
+ cacheableStruct := CacheableStruct{String: "hej", Int64: 2000}
+
+ err := client.Set("key1", cacheableStruct, time.Second)
+ assert.Equal(t, err, nil)
+
+ //not sure how this can be avoided when testing redis/memcached :/
+ <-time.After(time.Second + time.Millisecond)
+
+ // should not be able to read that value since its expired
+ _, err = client.Get("key1")
+ assert.Equal(t, err, ErrCacheItemNotFound)
+}
diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go
index 1fbd303bebd..2fc8e0c456f 100644
--- a/pkg/middleware/middleware_test.go
+++ b/pkg/middleware/middleware_test.go
@@ -11,6 +11,7 @@ import (
msession "github.com/go-macaron/session"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -155,7 +156,7 @@ func TestMiddlewareContext(t *testing.T) {
return nil
})
- sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: unhashedToken,
@@ -184,14 +185,14 @@ func TestMiddlewareContext(t *testing.T) {
return nil
})
- sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
}, nil
}
- sc.userAuthTokenService.tryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
+ sc.userAuthTokenService.TryRotateTokenProvider = func(userToken *m.UserToken, clientIP, userAgent string) (bool, error) {
userToken.UnhashedToken = "rotated"
return true, nil
}
@@ -226,7 +227,7 @@ func TestMiddlewareContext(t *testing.T) {
middlewareScenario("Invalid/expired auth token in cookie", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
- sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return nil, m.ErrUserTokenNotFound
}
@@ -562,7 +563,7 @@ func middlewareScenario(desc string, fn scenarioFunc) {
}))
session.Init(&msession.Options{}, 0)
- sc.userAuthTokenService = newFakeUserAuthTokenService()
+ sc.userAuthTokenService = auth.NewFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine
session.StartSessionGC = func() {}
@@ -595,7 +596,7 @@ type scenarioContext struct {
handlerFunc handlerFunc
defaultHandler macaron.Handler
url string
- userAuthTokenService *fakeUserAuthTokenService
+ userAuthTokenService *auth.FakeUserAuthTokenService
req *http.Request
}
@@ -676,57 +677,3 @@ func (sc *scenarioContext) exec() {
type scenarioFunc func(c *scenarioContext)
type handlerFunc func(c *m.ReqContext)
-
-type fakeUserAuthTokenService struct {
- createTokenProvider func(userId int64, clientIP, userAgent string) (*m.UserToken, error)
- tryRotateTokenProvider func(token *m.UserToken, clientIP, userAgent string) (bool, error)
- lookupTokenProvider func(unhashedToken string) (*m.UserToken, error)
- revokeTokenProvider func(token *m.UserToken) error
- activeAuthTokenCount func() (int64, error)
-}
-
-func newFakeUserAuthTokenService() *fakeUserAuthTokenService {
- return &fakeUserAuthTokenService{
- createTokenProvider: func(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
- return &m.UserToken{
- UserId: 0,
- UnhashedToken: "",
- }, nil
- },
- tryRotateTokenProvider: func(token *m.UserToken, clientIP, userAgent string) (bool, error) {
- return false, nil
- },
- lookupTokenProvider: func(unhashedToken string) (*m.UserToken, error) {
- return &m.UserToken{
- UserId: 0,
- UnhashedToken: "",
- }, nil
- },
- revokeTokenProvider: func(token *m.UserToken) error {
- return nil
- },
- activeAuthTokenCount: func() (int64, error) {
- return 10, nil
- },
- }
-}
-
-func (s *fakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*m.UserToken, error) {
- return s.createTokenProvider(userId, clientIP, userAgent)
-}
-
-func (s *fakeUserAuthTokenService) LookupToken(unhashedToken string) (*m.UserToken, error) {
- return s.lookupTokenProvider(unhashedToken)
-}
-
-func (s *fakeUserAuthTokenService) TryRotateToken(token *m.UserToken, clientIP, userAgent string) (bool, error) {
- return s.tryRotateTokenProvider(token, clientIP, userAgent)
-}
-
-func (s *fakeUserAuthTokenService) RevokeToken(token *m.UserToken) error {
- return s.revokeTokenProvider(token)
-}
-
-func (s *fakeUserAuthTokenService) ActiveTokenCount() (int64, error) {
- return s.activeAuthTokenCount()
-}
diff --git a/pkg/middleware/org_redirect_test.go b/pkg/middleware/org_redirect_test.go
index e01d1a68d21..fe5b2736035 100644
--- a/pkg/middleware/org_redirect_test.go
+++ b/pkg/middleware/org_redirect_test.go
@@ -24,7 +24,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
return nil
})
- sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 0,
UnhashedToken: "",
@@ -50,7 +50,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
return nil
})
- sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
diff --git a/pkg/middleware/quota_test.go b/pkg/middleware/quota_test.go
index 52b696cf037..0ba42e708bc 100644
--- a/pkg/middleware/quota_test.go
+++ b/pkg/middleware/quota_test.go
@@ -3,6 +3,7 @@ package middleware
import (
"testing"
+ "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/bus"
@@ -36,7 +37,7 @@ func TestMiddlewareQuota(t *testing.T) {
},
}
- fakeAuthTokenService := newFakeUserAuthTokenService()
+ fakeAuthTokenService := auth.NewFakeUserAuthTokenService()
qs := "a.QuotaService{
AuthTokenService: fakeAuthTokenService,
}
@@ -87,7 +88,7 @@ func TestMiddlewareQuota(t *testing.T) {
return nil
})
- sc.userAuthTokenService.lookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
+ sc.userAuthTokenService.LookupTokenProvider = func(unhashedToken string) (*m.UserToken, error) {
return &m.UserToken{
UserId: 12,
UnhashedToken: "",
diff --git a/pkg/middleware/recovery_test.go b/pkg/middleware/recovery_test.go
index 6736d699a39..00f3b7a3032 100644
--- a/pkg/middleware/recovery_test.go
+++ b/pkg/middleware/recovery_test.go
@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
+ "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
macaron "gopkg.in/macaron.v1"
@@ -62,7 +63,7 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
- sc.userAuthTokenService = newFakeUserAuthTokenService()
+ sc.userAuthTokenService = auth.NewFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
// mock out gc goroutine
sc.m.Use(OrgRedirect())
diff --git a/pkg/models/user_token.go b/pkg/models/user_token.go
index 388bc2dd4a2..8c3e7985995 100644
--- a/pkg/models/user_token.go
+++ b/pkg/models/user_token.go
@@ -1,6 +1,8 @@
package models
-import "errors"
+import (
+ "errors"
+)
// Typed errors
var (
@@ -23,11 +25,18 @@ type UserToken struct {
UnhashedToken string
}
+type RevokeAuthTokenCmd struct {
+ AuthTokenId int64 `json:"authTokenId"`
+}
+
// UserTokenService are used for generating and validating user tokens
type UserTokenService interface {
CreateToken(userId int64, clientIP, userAgent string) (*UserToken, error)
LookupToken(unhashedToken string) (*UserToken, error)
TryRotateToken(token *UserToken, clientIP, userAgent string) (bool, error)
RevokeToken(token *UserToken) error
+ RevokeAllUserTokens(userId int64) error
ActiveTokenCount() (int64, error)
+ GetUserToken(userId, userTokenId int64) (*UserToken, error)
+ GetUserTokens(userId int64) ([]*UserToken, error)
}
diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go
index 648575d54cd..255866a9ba0 100644
--- a/pkg/services/auth/auth_token.go
+++ b/pkg/services/auth/auth_token.go
@@ -221,6 +221,57 @@ func (s *UserAuthTokenService) RevokeToken(token *models.UserToken) error {
return nil
}
+func (s *UserAuthTokenService) RevokeAllUserTokens(userId int64) error {
+ sql := `DELETE from user_auth_token WHERE user_id = ?`
+ res, err := s.SQLStore.NewSession().Exec(sql, userId)
+ if err != nil {
+ return err
+ }
+
+ affected, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+
+ s.log.Debug("all user tokens for user revoked", "userId", userId, "count", affected)
+
+ return nil
+}
+
+func (s *UserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) {
+ var token userAuthToken
+ exists, err := s.SQLStore.NewSession().Where("id = ? AND user_id = ?", userTokenId, userId).Get(&token)
+ if err != nil {
+ return nil, err
+ }
+
+ if !exists {
+ return nil, models.ErrUserTokenNotFound
+ }
+
+ var result models.UserToken
+ token.toUserToken(&result)
+
+ return &result, nil
+}
+
+func (s *UserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) {
+ var tokens []*userAuthToken
+ err := s.SQLStore.NewSession().Where("user_id = ? AND created_at > ? AND rotated_at > ?", userId, s.createdAfterParam(), s.rotatedAfterParam()).Find(&tokens)
+ if err != nil {
+ return nil, err
+ }
+
+ result := []*models.UserToken{}
+ for _, token := range tokens {
+ var userToken models.UserToken
+ token.toUserToken(&userToken)
+ result = append(result, &userToken)
+ }
+
+ return result, nil
+}
+
func (s *UserAuthTokenService) createdAfterParam() int64 {
tokenMaxLifetime := time.Duration(s.Cfg.LoginMaxLifetimeDays) * 24 * time.Hour
return getTime().Add(-tokenMaxLifetime).Unix()
diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go
index 49e7acc3a5b..33eb309ad18 100644
--- a/pkg/services/auth/auth_token_test.go
+++ b/pkg/services/auth/auth_token_test.go
@@ -75,6 +75,47 @@ func TestUserAuthToken(t *testing.T) {
err = userAuthTokenService.RevokeToken(userToken)
So(err, ShouldEqual, models.ErrUserTokenNotFound)
})
+
+ Convey("When creating an additional token", func() {
+ userToken2, err := userAuthTokenService.CreateToken(userID, "192.168.10.11:1234", "some user agent")
+ So(err, ShouldBeNil)
+ So(userToken2, ShouldNotBeNil)
+
+ Convey("Can get first user token", func() {
+ token, err := userAuthTokenService.GetUserToken(userID, userToken.Id)
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+ So(token.Id, ShouldEqual, userToken.Id)
+ })
+
+ Convey("Can get second user token", func() {
+ token, err := userAuthTokenService.GetUserToken(userID, userToken2.Id)
+ So(err, ShouldBeNil)
+ So(token, ShouldNotBeNil)
+ So(token.Id, ShouldEqual, userToken2.Id)
+ })
+
+ Convey("Can get user tokens", func() {
+ tokens, err := userAuthTokenService.GetUserTokens(userID)
+ So(err, ShouldBeNil)
+ So(tokens, ShouldHaveLength, 2)
+ So(tokens[0].Id, ShouldEqual, userToken.Id)
+ So(tokens[1].Id, ShouldEqual, userToken2.Id)
+ })
+
+ Convey("Can revoke all user tokens", func() {
+ err := userAuthTokenService.RevokeAllUserTokens(userID)
+ So(err, ShouldBeNil)
+
+ model, err := ctx.getAuthTokenByID(userToken.Id)
+ So(err, ShouldBeNil)
+ So(model, ShouldBeNil)
+
+ model2, err := ctx.getAuthTokenByID(userToken2.Id)
+ So(err, ShouldBeNil)
+ So(model2, ShouldBeNil)
+ })
+ })
})
Convey("expires correctly", func() {
diff --git a/pkg/services/auth/testing.go b/pkg/services/auth/testing.go
new file mode 100644
index 00000000000..68e65466c3d
--- /dev/null
+++ b/pkg/services/auth/testing.go
@@ -0,0 +1,81 @@
+package auth
+
+import "github.com/grafana/grafana/pkg/models"
+
+type FakeUserAuthTokenService struct {
+ CreateTokenProvider func(userId int64, clientIP, userAgent string) (*models.UserToken, error)
+ TryRotateTokenProvider func(token *models.UserToken, clientIP, userAgent string) (bool, error)
+ LookupTokenProvider func(unhashedToken string) (*models.UserToken, error)
+ RevokeTokenProvider func(token *models.UserToken) error
+ RevokeAllUserTokensProvider func(userId int64) error
+ ActiveAuthTokenCount func() (int64, error)
+ GetUserTokenProvider func(userId, userTokenId int64) (*models.UserToken, error)
+ GetUserTokensProvider func(userId int64) ([]*models.UserToken, error)
+}
+
+func NewFakeUserAuthTokenService() *FakeUserAuthTokenService {
+ return &FakeUserAuthTokenService{
+ CreateTokenProvider: func(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
+ return &models.UserToken{
+ UserId: 0,
+ UnhashedToken: "",
+ }, nil
+ },
+ TryRotateTokenProvider: func(token *models.UserToken, clientIP, userAgent string) (bool, error) {
+ return false, nil
+ },
+ LookupTokenProvider: func(unhashedToken string) (*models.UserToken, error) {
+ return &models.UserToken{
+ UserId: 0,
+ UnhashedToken: "",
+ }, nil
+ },
+ RevokeTokenProvider: func(token *models.UserToken) error {
+ return nil
+ },
+ RevokeAllUserTokensProvider: func(userId int64) error {
+ return nil
+ },
+ ActiveAuthTokenCount: func() (int64, error) {
+ return 10, nil
+ },
+ GetUserTokenProvider: func(userId, userTokenId int64) (*models.UserToken, error) {
+ return nil, nil
+ },
+ GetUserTokensProvider: func(userId int64) ([]*models.UserToken, error) {
+ return nil, nil
+ },
+ }
+}
+
+func (s *FakeUserAuthTokenService) CreateToken(userId int64, clientIP, userAgent string) (*models.UserToken, error) {
+ return s.CreateTokenProvider(userId, clientIP, userAgent)
+}
+
+func (s *FakeUserAuthTokenService) LookupToken(unhashedToken string) (*models.UserToken, error) {
+ return s.LookupTokenProvider(unhashedToken)
+}
+
+func (s *FakeUserAuthTokenService) TryRotateToken(token *models.UserToken, clientIP, userAgent string) (bool, error) {
+ return s.TryRotateTokenProvider(token, clientIP, userAgent)
+}
+
+func (s *FakeUserAuthTokenService) RevokeToken(token *models.UserToken) error {
+ return s.RevokeTokenProvider(token)
+}
+
+func (s *FakeUserAuthTokenService) RevokeAllUserTokens(userId int64) error {
+ return s.RevokeAllUserTokensProvider(userId)
+}
+
+func (s *FakeUserAuthTokenService) ActiveTokenCount() (int64, error) {
+ return s.ActiveAuthTokenCount()
+}
+
+func (s *FakeUserAuthTokenService) GetUserToken(userId, userTokenId int64) (*models.UserToken, error) {
+ return s.GetUserTokenProvider(userId, userTokenId)
+}
+
+func (s *FakeUserAuthTokenService) GetUserTokens(userId int64) ([]*models.UserToken, error) {
+ return s.GetUserTokensProvider(userId)
+}
diff --git a/pkg/services/sqlstore/migrations/cache_data_mig.go b/pkg/services/sqlstore/migrations/cache_data_mig.go
new file mode 100644
index 00000000000..3467b88962b
--- /dev/null
+++ b/pkg/services/sqlstore/migrations/cache_data_mig.go
@@ -0,0 +1,22 @@
+package migrations
+
+import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+
+func addCacheMigration(mg *migrator.Migrator) {
+ var cacheDataV1 = migrator.Table{
+ Name: "cache_data",
+ Columns: []*migrator.Column{
+ {Name: "cache_key", Type: migrator.DB_NVarchar, IsPrimaryKey: true, Length: 168},
+ {Name: "data", Type: migrator.DB_Blob},
+ {Name: "expires", Type: migrator.DB_Integer, Length: 255, Nullable: false},
+ {Name: "created_at", Type: migrator.DB_Integer, Length: 255, Nullable: false},
+ },
+ Indices: []*migrator.Index{
+ {Cols: []string{"cache_key"}, Type: migrator.UniqueIndex},
+ },
+ }
+
+ mg.AddMigration("create cache_data table", migrator.NewAddTableMigration(cacheDataV1))
+
+ mg.AddMigration("add unique index cache_data.cache_key", migrator.NewAddIndexMigration(cacheDataV1, cacheDataV1.Indices[0]))
+}
diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go
index 931259ec3ed..3e40c749f37 100644
--- a/pkg/services/sqlstore/migrations/migrations.go
+++ b/pkg/services/sqlstore/migrations/migrations.go
@@ -33,6 +33,7 @@ func AddMigrations(mg *Migrator) {
addUserAuthMigrations(mg)
addServerlockMigrations(mg)
addUserAuthTokenMigrations(mg)
+ addCacheMigration(mg)
}
func addMigrationLogMigrations(mg *Migrator) {
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index 7b6e99255aa..bc57291b5f9 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -241,6 +241,12 @@ type Cfg struct {
// User
EditorsCanOwn bool
+
+ // Dataproxy
+ SendUserHeader bool
+
+ // DistributedCache
+ RemoteCacheOptions *RemoteCacheOptions
}
type CommandLineArgs struct {
@@ -601,6 +607,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
dataproxy := iniFile.Section("dataproxy")
DataProxyLogging = dataproxy.Key("logging").MustBool(false)
DataProxyTimeout = dataproxy.Key("timeout").MustInt(30)
+ cfg.SendUserHeader = dataproxy.Key("send_user_header").MustBool(false)
// read security settings
security := iniFile.Section("security")
@@ -781,9 +788,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
enterprise := iniFile.Section("enterprise")
cfg.EnterpriseLicensePath = enterprise.Key("license_path").MustString(filepath.Join(cfg.DataPath, "license.jwt"))
+ cacheServer := iniFile.Section("remote_cache")
+ cfg.RemoteCacheOptions = &RemoteCacheOptions{
+ Name: cacheServer.Key("type").MustString("database"),
+ ConnStr: cacheServer.Key("connstr").MustString(""),
+ }
+
return nil
}
+type RemoteCacheOptions struct {
+ Name string
+ ConnStr string
+}
+
func (cfg *Cfg) readSessionConfig() {
sec := cfg.Raw.Section("session")
SessionOptions = session.Options{}
diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go
index 12f2b6c03c9..c740d6cbe77 100644
--- a/pkg/tsdb/mssql/mssql.go
+++ b/pkg/tsdb/mssql/mssql.go
@@ -3,6 +3,7 @@ package mssql
import (
"database/sql"
"fmt"
+ "github.com/grafana/grafana/pkg/setting"
"strconv"
_ "github.com/denisenkom/go-mssqldb"
@@ -24,7 +25,9 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
if err != nil {
return nil, err
}
- logger.Debug("getEngine", "connection", cnnstr)
+ if setting.Env == setting.DEV {
+ logger.Debug("getEngine", "connection", cnnstr)
+ }
config := tsdb.SqlQueryEndpointConfiguration{
DriverName: "mssql",
diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go
index d307e12166c..0451f8f0dc1 100644
--- a/pkg/tsdb/mysql/mysql.go
+++ b/pkg/tsdb/mysql/mysql.go
@@ -3,6 +3,7 @@ package mysql
import (
"database/sql"
"fmt"
+ "github.com/grafana/grafana/pkg/setting"
"reflect"
"strconv"
"strings"
@@ -44,7 +45,9 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
cnnstr += "&tls=" + tlsConfigString
}
- logger.Debug("getEngine", "connection", cnnstr)
+ if setting.Env == setting.DEV {
+ logger.Debug("getEngine", "connection", cnnstr)
+ }
config := tsdb.SqlQueryEndpointConfiguration{
DriverName: "mysql",
diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go
index 4bcf06638f4..ae6b165e731 100644
--- a/pkg/tsdb/postgres/postgres.go
+++ b/pkg/tsdb/postgres/postgres.go
@@ -2,6 +2,7 @@ package postgres
import (
"database/sql"
+ "github.com/grafana/grafana/pkg/setting"
"net/url"
"strconv"
@@ -19,7 +20,9 @@ func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndp
logger := log.New("tsdb.postgres")
cnnstr := generateConnectionString(datasource)
- logger.Debug("getEngine", "connection", cnnstr)
+ if setting.Env == setting.DEV {
+ logger.Debug("getEngine", "connection", cnnstr)
+ }
config := tsdb.SqlQueryEndpointConfiguration{
DriverName: "postgres",
diff --git a/public/app/app.ts b/public/app/app.ts
index 03f332e357b..a54c9270e2c 100644
--- a/public/app/app.ts
+++ b/public/app/app.ts
@@ -17,12 +17,13 @@ import 'vendor/angular-other/angular-strap';
import $ from 'jquery';
import angular from 'angular';
import config from 'app/core/config';
+// @ts-ignore ignoring this for now, otherwise we would have to extend _ interface with move
import _ from 'lodash';
import moment from 'moment';
import { addClassIfNoOverlayScrollbar } from 'app/core/utils/scrollbar';
// add move to lodash for backward compatabiltiy
-_.move = (array, fromIndex, toIndex) => {
+_.move = (array: [], fromIndex: number, toIndex: number) => {
array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]);
return array;
};
@@ -36,7 +37,7 @@ import 'app/features/all';
// import symlinked extensions
const extensionsIndex = (require as any).context('.', true, /extensions\/index.ts/);
-extensionsIndex.keys().forEach(key => {
+extensionsIndex.keys().forEach((key: any) => {
extensionsIndex(key);
});
@@ -52,7 +53,7 @@ export class GrafanaApp {
this.ngModuleDependencies = [];
}
- useModule(module) {
+ useModule(module: angular.IModule) {
if (this.preBootModules) {
this.preBootModules.push(module);
} else {
@@ -67,40 +68,49 @@ export class GrafanaApp {
moment.locale(config.bootData.user.locale);
- app.config(($locationProvider, $controllerProvider, $compileProvider, $filterProvider, $httpProvider, $provide) => {
- // pre assing bindings before constructor calls
- $compileProvider.preAssignBindingsEnabled(true);
+ app.config(
+ (
+ $locationProvider: angular.ILocationProvider,
+ $controllerProvider: angular.IControllerProvider,
+ $compileProvider: angular.ICompileProvider,
+ $filterProvider: angular.IFilterProvider,
+ $httpProvider: angular.IHttpProvider,
+ $provide: angular.auto.IProvideService
+ ) => {
+ // pre assing bindings before constructor calls
+ $compileProvider.preAssignBindingsEnabled(true);
- if (config.buildInfo.env !== 'development') {
- $compileProvider.debugInfoEnabled(false);
- }
+ if (config.buildInfo.env !== 'development') {
+ $compileProvider.debugInfoEnabled(false);
+ }
- $httpProvider.useApplyAsync(true);
+ $httpProvider.useApplyAsync(true);
- this.registerFunctions.controller = $controllerProvider.register;
- this.registerFunctions.directive = $compileProvider.directive;
- this.registerFunctions.factory = $provide.factory;
- this.registerFunctions.service = $provide.service;
- this.registerFunctions.filter = $filterProvider.register;
+ this.registerFunctions.controller = $controllerProvider.register;
+ this.registerFunctions.directive = $compileProvider.directive;
+ this.registerFunctions.factory = $provide.factory;
+ this.registerFunctions.service = $provide.service;
+ this.registerFunctions.filter = $filterProvider.register;
- $provide.decorator('$http', [
- '$delegate',
- '$templateCache',
- ($delegate, $templateCache) => {
- const get = $delegate.get;
- $delegate.get = (url, config) => {
- if (url.match(/\.html$/)) {
- // some template's already exist in the cache
- if (!$templateCache.get(url)) {
- url += '?v=' + new Date().getTime();
+ $provide.decorator('$http', [
+ '$delegate',
+ '$templateCache',
+ ($delegate: any, $templateCache: any) => {
+ const get = $delegate.get;
+ $delegate.get = (url: string, config: any) => {
+ if (url.match(/\.html$/)) {
+ // some template's already exist in the cache
+ if (!$templateCache.get(url)) {
+ url += '?v=' + new Date().getTime();
+ }
}
- }
- return get(url, config);
- };
- return $delegate;
- },
- ]);
- });
+ return get(url, config);
+ };
+ return $delegate;
+ },
+ ]);
+ }
+ );
this.ngModuleDependencies = [
'grafana.core',
@@ -116,7 +126,7 @@ export class GrafanaApp {
];
// makes it possible to add dynamic stuff
- _.each(angularModules, m => {
+ _.each(angularModules, (m: angular.IModule) => {
this.useModule(m);
});
@@ -129,7 +139,7 @@ export class GrafanaApp {
// bootstrap the app
angular.bootstrap(document, this.ngModuleDependencies).invoke(() => {
- _.each(this.preBootModules, module => {
+ _.each(this.preBootModules, (module: angular.IModule) => {
_.extend(module, this.registerFunctions);
});
diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts
index 25a69b1f5e4..3f6dacd311d 100644
--- a/public/app/core/components/manage_dashboards/manage_dashboards.ts
+++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts
@@ -1,7 +1,30 @@
+// @ts-ignore
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import { SearchSrv } from 'app/core/services/search_srv';
+import { BackendSrv } from 'app/core/services/backend_srv';
+import { NavModelSrv } from 'app/core/nav_model_srv';
+import { ContextSrv } from 'app/core/services/context_srv';
+
+export interface Section {
+ id: number;
+ uid: string;
+ title: string;
+ expanded: false;
+ items: any[];
+ url: string;
+ icon: string;
+ score: number;
+ checked: boolean;
+ hideHeader: boolean;
+ toggle: Function;
+}
+
+export interface FoldersAndDashboardUids {
+ folderUids: string[];
+ dashboardUids: string[];
+}
class Query {
query: string;
@@ -14,7 +37,7 @@ class Query {
}
export class ManageDashboardsCtrl {
- sections: any[];
+ sections: Section[];
query: Query;
navModel: any;
@@ -45,7 +68,12 @@ export class ManageDashboardsCtrl {
hasEditPermissionInFolders: boolean;
/** @ngInject */
- constructor(private backendSrv, navModelSrv, private searchSrv: SearchSrv, private contextSrv) {
+ constructor(
+ private backendSrv: BackendSrv,
+ navModelSrv: NavModelSrv,
+ private searchSrv: SearchSrv,
+ private contextSrv: ContextSrv
+ ) {
this.isEditor = this.contextSrv.isEditor;
this.hasEditPermissionInFolders = this.contextSrv.hasEditPermissionInFolders;
@@ -73,7 +101,7 @@ export class ManageDashboardsCtrl {
refreshList() {
return this.searchSrv
.search(this.query)
- .then(result => {
+ .then((result: Section[]) => {
return this.initDashboardList(result);
})
.then(() => {
@@ -81,7 +109,7 @@ export class ManageDashboardsCtrl {
return;
}
- return this.backendSrv.getFolderByUid(this.folderUid).then(folder => {
+ return this.backendSrv.getFolderByUid(this.folderUid).then((folder: any) => {
this.canSave = folder.canSave;
if (!this.canSave) {
this.hasEditPermissionInFolders = false;
@@ -90,7 +118,7 @@ export class ManageDashboardsCtrl {
});
}
- initDashboardList(result: any) {
+ initDashboardList(result: Section[]) {
this.canMove = false;
this.canDelete = false;
this.selectAllChecked = false;
@@ -128,25 +156,25 @@ export class ManageDashboardsCtrl {
this.canDelete = selectedDashboards > 0 || selectedFolders > 0;
}
- getFoldersAndDashboardsToDelete() {
- const selectedDashboards = {
- folders: [],
- dashboards: [],
+ getFoldersAndDashboardsToDelete(): FoldersAndDashboardUids {
+ const selectedDashboards: FoldersAndDashboardUids = {
+ folderUids: [],
+ dashboardUids: [],
};
for (const section of this.sections) {
if (section.checked && section.id !== 0) {
- selectedDashboards.folders.push(section.uid);
+ selectedDashboards.folderUids.push(section.uid);
} else {
const selected = _.filter(section.items, { checked: true });
- selectedDashboards.dashboards.push(..._.map(selected, 'uid'));
+ selectedDashboards.dashboardUids.push(..._.map(selected, 'uid'));
}
}
return selectedDashboards;
}
- getFolderIds(sections) {
+ getFolderIds(sections: Section[]) {
const ids = [];
for (const s of sections) {
if (s.checked) {
@@ -158,8 +186,8 @@ export class ManageDashboardsCtrl {
delete() {
const data = this.getFoldersAndDashboardsToDelete();
- const folderCount = data.folders.length;
- const dashCount = data.dashboards.length;
+ const folderCount = data.folderUids.length;
+ const dashCount = data.dashboardUids.length;
let text = 'Do you want to delete the ';
let text2;
@@ -179,12 +207,12 @@ export class ManageDashboardsCtrl {
icon: 'fa-trash',
yesText: 'Delete',
onConfirm: () => {
- this.deleteFoldersAndDashboards(data.folders, data.dashboards);
+ this.deleteFoldersAndDashboards(data.folderUids, data.dashboardUids);
},
});
}
- private deleteFoldersAndDashboards(folderUids, dashboardUids) {
+ private deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) {
this.backendSrv.deleteFoldersAndDashboards(folderUids, dashboardUids).then(() => {
this.refreshList();
});
@@ -219,13 +247,13 @@ export class ManageDashboardsCtrl {
}
initTagFilter() {
- return this.searchSrv.getDashboardTags().then(results => {
+ return this.searchSrv.getDashboardTags().then((results: any) => {
this.tagFilterOptions = [{ term: 'Filter By Tag', disabled: true }].concat(results);
this.selectedTagFilter = this.tagFilterOptions[0];
});
}
- filterByTag(tag) {
+ filterByTag(tag: any) {
if (_.indexOf(this.query.tag, tag) === -1) {
this.query.tag.push(tag);
}
@@ -243,7 +271,7 @@ export class ManageDashboardsCtrl {
return res;
}
- removeTag(tag, evt) {
+ removeTag(tag: any, evt: Event) {
this.query.tag = _.without(this.query.tag, tag);
this.refreshList();
if (evt) {
@@ -269,7 +297,7 @@ export class ManageDashboardsCtrl {
section.checked = this.selectAllChecked;
}
- section.items = _.map(section.items, item => {
+ section.items = _.map(section.items, (item: any) => {
item.checked = this.selectAllChecked;
return item;
});
diff --git a/public/app/core/specs/manage_dashboards.test.ts b/public/app/core/specs/manage_dashboards.test.ts
index 5af0ebade02..ef5e240fd36 100644
--- a/public/app/core/specs/manage_dashboards.test.ts
+++ b/public/app/core/specs/manage_dashboards.test.ts
@@ -1,12 +1,39 @@
-import { ManageDashboardsCtrl } from 'app/core/components/manage_dashboards/manage_dashboards';
-import { SearchSrv } from 'app/core/services/search_srv';
+// @ts-ignore
import q from 'q';
+import {
+ ManageDashboardsCtrl,
+ Section,
+ FoldersAndDashboardUids,
+} from 'app/core/components/manage_dashboards/manage_dashboards';
+import { SearchSrv } from 'app/core/services/search_srv';
+import { BackendSrv } from '../services/backend_srv';
+import { NavModelSrv } from '../nav_model_srv';
+import { ContextSrv } from '../services/context_srv';
+
+const mockSection = (overides?: object): Section => {
+ const defaultSection: Section = {
+ id: 0,
+ items: [],
+ checked: false,
+ expanded: false,
+ hideHeader: false,
+ icon: '',
+ score: 0,
+ title: 'Some Section',
+ toggle: jest.fn(),
+ uid: 'someuid',
+ url: '/some/url/',
+ };
+
+ return { ...defaultSection, ...overides };
+};
describe('ManageDashboards', () => {
- let ctrl;
+ let ctrl: ManageDashboardsCtrl;
describe('when browsing dashboards', () => {
beforeEach(() => {
+ const tags: any[] = [];
const response = [
{
id: 410,
@@ -18,11 +45,11 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
- tags: [],
+ tags,
isStarred: false,
},
],
- tags: [],
+ tags,
isStarred: false,
},
{
@@ -37,11 +64,11 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
- tags: [],
+ tags,
isStarred: false,
},
],
- tags: [],
+ tags,
isStarred: false,
},
];
@@ -61,6 +88,7 @@ describe('ManageDashboards', () => {
describe('when browsing dashboards for a folder', () => {
beforeEach(() => {
+ const tags: any[] = [];
const response = [
{
id: 410,
@@ -72,11 +100,11 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
- tags: [],
+ tags,
isStarred: false,
},
],
- tags: [],
+ tags,
isStarred: false,
},
];
@@ -92,6 +120,7 @@ describe('ManageDashboards', () => {
describe('when searching dashboards', () => {
beforeEach(() => {
+ const tags: any[] = [];
const response = [
{
checked: false,
@@ -103,7 +132,7 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
- tags: [],
+ tags,
isStarred: false,
folderId: 410,
folderUid: 'uid',
@@ -115,7 +144,7 @@ describe('ManageDashboards', () => {
title: 'Dashboard Test',
url: 'dashboard/db/dashboard-test',
icon: 'fa fa-folder',
- tags: [],
+ tags,
folderId: 499,
isStarred: false,
},
@@ -245,7 +274,7 @@ describe('ManageDashboards', () => {
});
describe('when selecting dashboards', () => {
- let ctrl;
+ let ctrl: ManageDashboardsCtrl;
beforeEach(() => {
ctrl = createCtrlWithStubs([]);
@@ -254,16 +283,16 @@ describe('ManageDashboards', () => {
describe('and no dashboards are selected', () => {
beforeEach(() => {
ctrl.sections = [
- {
+ mockSection({
id: 1,
items: [{ id: 2, checked: false }],
checked: false,
- },
- {
+ }),
+ mockSection({
id: 0,
items: [{ id: 3, checked: false }],
checked: false,
- },
+ }),
];
ctrl.selectionChanged();
});
@@ -302,16 +331,16 @@ describe('ManageDashboards', () => {
describe('and all folders and dashboards are selected', () => {
beforeEach(() => {
ctrl.sections = [
- {
+ mockSection({
id: 1,
items: [{ id: 2, checked: true }],
checked: true,
- },
- {
+ }),
+ mockSection({
id: 0,
items: [{ id: 3, checked: true }],
checked: true,
- },
+ }),
];
ctrl.selectionChanged();
});
@@ -350,18 +379,18 @@ describe('ManageDashboards', () => {
describe('and one dashboard in root is selected', () => {
beforeEach(() => {
ctrl.sections = [
- {
+ mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: false }],
checked: false,
- },
- {
+ }),
+ mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: true }],
checked: false,
- },
+ }),
];
ctrl.selectionChanged();
});
@@ -378,18 +407,18 @@ describe('ManageDashboards', () => {
describe('and one child dashboard is selected', () => {
beforeEach(() => {
ctrl.sections = [
- {
+ mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: true }],
checked: false,
- },
- {
+ }),
+ mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: false }],
checked: false,
- },
+ }),
];
ctrl.selectionChanged();
@@ -407,18 +436,18 @@ describe('ManageDashboards', () => {
describe('and one child dashboard and one dashboard is selected', () => {
beforeEach(() => {
ctrl.sections = [
- {
+ mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: true }],
checked: false,
- },
- {
+ }),
+ mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: true }],
checked: false,
- },
+ }),
];
ctrl.selectionChanged();
@@ -436,24 +465,24 @@ describe('ManageDashboards', () => {
describe('and one child dashboard and one folder is selected', () => {
beforeEach(() => {
ctrl.sections = [
- {
+ mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: false }],
checked: true,
- },
- {
+ }),
+ mockSection({
id: 3,
title: 'folder',
items: [{ id: 4, checked: true }],
checked: false,
- },
- {
+ }),
+ mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: false }],
checked: false,
- },
+ }),
];
ctrl.selectionChanged();
@@ -470,55 +499,55 @@ describe('ManageDashboards', () => {
});
describe('when deleting dashboards', () => {
- let toBeDeleted: any;
+ let toBeDeleted: FoldersAndDashboardUids;
beforeEach(() => {
ctrl = createCtrlWithStubs([]);
ctrl.sections = [
- {
+ mockSection({
id: 1,
uid: 'folder',
title: 'folder',
items: [{ id: 2, checked: true, uid: 'folder-dash' }],
checked: true,
- },
- {
+ }),
+ mockSection({
id: 3,
title: 'folder-2',
items: [{ id: 3, checked: true, uid: 'folder-2-dash' }],
checked: false,
uid: 'folder-2',
- },
- {
+ }),
+ mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: true, uid: 'root-dash' }],
checked: true,
- },
+ }),
];
toBeDeleted = ctrl.getFoldersAndDashboardsToDelete();
});
it('should return 1 folder', () => {
- expect(toBeDeleted.folders.length).toEqual(1);
+ expect(toBeDeleted.folderUids.length).toEqual(1);
});
it('should return 2 dashboards', () => {
- expect(toBeDeleted.dashboards.length).toEqual(2);
+ expect(toBeDeleted.dashboardUids.length).toEqual(2);
});
it('should filter out children if parent is checked', () => {
- expect(toBeDeleted.folders[0]).toEqual('folder');
+ expect(toBeDeleted.folderUids[0]).toEqual('folder');
});
it('should not filter out children if parent not is checked', () => {
- expect(toBeDeleted.dashboards[0]).toEqual('folder-2-dash');
+ expect(toBeDeleted.dashboardUids[0]).toEqual('folder-2-dash');
});
it('should not filter out children if parent is checked and root', () => {
- expect(toBeDeleted.dashboards[1]).toEqual('root-dash');
+ expect(toBeDeleted.dashboardUids[1]).toEqual('root-dash');
});
});
@@ -527,19 +556,19 @@ describe('ManageDashboards', () => {
ctrl = createCtrlWithStubs([]);
ctrl.sections = [
- {
+ mockSection({
id: 1,
title: 'folder',
items: [{ id: 2, checked: true, uid: 'dash' }],
checked: false,
uid: 'folder',
- },
- {
+ }),
+ mockSection({
id: 0,
title: 'General',
items: [{ id: 3, checked: true, uid: 'dash-2' }],
checked: false,
- },
+ }),
];
});
@@ -562,5 +591,10 @@ function createCtrlWithStubs(searchResponse: any, tags?: any) {
},
};
- return new ManageDashboardsCtrl({}, { getNav: () => {} }, searchSrvStub as SearchSrv, { isEditor: true });
+ return new ManageDashboardsCtrl(
+ {} as BackendSrv,
+ { getNav: () => {} } as NavModelSrv,
+ searchSrvStub as SearchSrv,
+ { isEditor: true } as ContextSrv
+ );
}
diff --git a/public/app/features/explore/Table.tsx b/public/app/features/explore/Table.tsx
index 4946a6a505d..bbf338df8f9 100644
--- a/public/app/features/explore/Table.tsx
+++ b/public/app/features/explore/Table.tsx
@@ -40,11 +40,15 @@ export default class Table extends PureComponent {
const tableModel = data || EMPTY_TABLE;
const columnNames = tableModel.columns.map(({ text }) => text);
const columns = tableModel.columns.map(({ filterable, text }) => ({
- Header: text,
+ Header: () => {text},
accessor: text,
className: VALUE_REGEX.test(text) ? 'text-right' : '',
show: text !== 'Time',
- Cell: row => {row.value},
+ Cell: row => (
+
+ {row.value}
+
+ ),
}));
const noDataText = data ? 'The queries returned no data for a table.' : '';
diff --git a/scripts/circle-test-cache-servers.sh b/scripts/circle-test-cache-servers.sh
new file mode 100755
index 00000000000..bacd9928362
--- /dev/null
+++ b/scripts/circle-test-cache-servers.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+function exit_if_fail {
+ command=$@
+ echo "Executing '$command'"
+ eval $command
+ rc=$?
+ if [ $rc -ne 0 ]; then
+ echo "'$command' returned $rc."
+ exit $rc
+ fi
+}
+
+echo "running redis and memcache tests"
+
+time exit_if_fail go test -tags=redis ./pkg/infra/remotecache/...
+time exit_if_fail go test -tags=memcached ./pkg/infra/remotecache/...