Commit 67cbc7d4 by Oleg Gaidarenko Committed by GitHub

Chore: use remote cache instead of session storage (#16114)

Replaces session storage in auth_proxy middleware with remote cache

Fixes #15161
parent 645a6e58
......@@ -2,7 +2,7 @@
Any ldif files added to the prepopulate subdirectory will be automatically imported into the OpenLdap database.
The ldif files add three users, `ldapviewer`, `ldapeditor` and `ldapadmin`. Two groups, `admins` and `users`, are added that correspond with the group mappings in the default conf/ldap.toml. `ldapadmin` is a member of `admins` and `ldapeditor` is a member of `users`.
The ldif files add eight users, `ldap-admin`, `ldap-editor`, `ldap-viewer`, `ldap-carl`, `ldap-daniel`, `ldap-leo`, `ldap-tobias` and `ldap-torkel`. Two groups, `admins` and `users`, are added that correspond with the group mappings in the default conf/ldap.toml. `ldap-admin` is a member of `admins` and `ldap-editor` is a member of `users`.
Note that users that are added here need to specify a `memberOf` attribute manually as well as the `member` attribute for the group. The `memberOf` module usually does this automatically (if you add a group in Apache Directory Studio for example) but this does not work in the entrypoint script as it uses the `slapadd` command to add entries before the server has started and before the `memberOf` module is loaded.
......
......@@ -9,9 +9,8 @@ import (
"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"
"gopkg.in/macaron.v1"
)
func loggedInUserScenario(desc string, url string, fn scenarioFunc) {
......@@ -124,7 +123,7 @@ func setupScenarioContext(url string) *scenarioContext {
Delims: macaron.Delims{Left: "[[", Right: "]]"},
}))
sc.m.Use(middleware.GetContextHandler(nil))
sc.m.Use(middleware.GetContextHandler(nil, nil))
return sc
}
......@@ -16,6 +16,7 @@ import (
httpstatic "github.com/grafana/grafana/pkg/api/static"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/models"
......@@ -26,7 +27,6 @@ import (
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
......@@ -48,15 +48,16 @@ type HTTPServer struct {
streamManager *live.StreamManager
httpSrv *http.Server
RouteRegister routing.RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
HooksService *hooks.HooksService `inject:""`
CacheService *cache.CacheService `inject:""`
DatasourceCache datasources.CacheService `inject:""`
AuthTokenService models.UserTokenService `inject:""`
QuotaService *quota.QuotaService `inject:""`
RouteRegister routing.RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
HooksService *hooks.HooksService `inject:""`
CacheService *cache.CacheService `inject:""`
DatasourceCache datasources.CacheService `inject:""`
AuthTokenService models.UserTokenService `inject:""`
QuotaService *quota.QuotaService `inject:""`
RemoteCacheService *remotecache.RemoteCache `inject:""`
}
func (hs *HTTPServer) Init() error {
......@@ -66,8 +67,6 @@ func (hs *HTTPServer) Init() error {
hs.macaron = hs.newMacaron()
hs.registerRoutes()
session.Init(&setting.SessionOptions, setting.SessionConnMaxLifetime)
return nil
}
......@@ -226,7 +225,10 @@ func (hs *HTTPServer) addMiddlewaresAndStaticRoutes() {
m.Use(hs.healthHandler)
m.Use(hs.metricsEndpoint)
m.Use(middleware.GetContextHandler(hs.AuthTokenService))
m.Use(middleware.GetContextHandler(
hs.AuthTokenService,
hs.RemoteCacheService,
))
m.Use(middleware.OrgRedirect())
// needs to be after context handler
......
......@@ -7,12 +7,10 @@ import (
"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"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
var (
......
......@@ -5,7 +5,6 @@ import (
"time"
"github.com/bmizerany/assert"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
......
package remotecache
import (
"testing"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
// NewFakeStore creates store for testing
func NewFakeStore(t *testing.T) *RemoteCache {
t.Helper()
opts := &setting.RemoteCacheOptions{
Name: "database",
ConnStr: "",
}
SQLStore := sqlstore.InitTestDB(t)
dc := &RemoteCache{
SQLStore: SQLStore,
Cfg: &setting.Cfg{
RemoteCacheOptions: opts,
},
}
err := dc.Init()
if err != nil {
t.Fatalf("failed to init remote cache for test. error: %v", err)
}
return dc
}
......@@ -9,18 +9,19 @@ import (
"time"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/login"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
)
var (
AUTH_PROXY_SESSION_VAR = "authProxyHeaderValue"
const (
// cachePrefix is a prefix for the cache key
cachePrefix = "auth-proxy-sync-ttl:%s"
)
func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
func initContextWithAuthProxy(store *remotecache.RemoteCache, ctx *m.ReqContext, orgID int64) bool {
if !setting.AuthProxyEnabled {
return false
}
......@@ -36,46 +37,17 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
return true
}
// initialize session
if err := ctx.Session.Start(ctx.Context); err != nil {
log.Error(3, "Failed to start session. error %v", err)
return false
}
defer func() {
if err := ctx.Session.Release(); err != nil {
ctx.Logger.Error("failed to save session data", "error", err)
}
}()
query := &m.GetSignedInUserQuery{OrgId: orgID}
cacheKey := fmt.Sprintf(cachePrefix, proxyHeaderValue)
userID, err := store.Get(cacheKey)
inCache := err == nil
// if this session has already been authenticated by authProxy just load the user
sessProxyValue := ctx.Session.Get(AUTH_PROXY_SESSION_VAR)
if sessProxyValue != nil && sessProxyValue.(string) == proxyHeaderValue && getRequestUserId(ctx) > 0 {
// if we're using ldap, sync user periodically
if setting.LdapEnabled {
syncQuery := &m.LoginUserQuery{
ReqContext: ctx,
Username: proxyHeaderValue,
}
if err := syncGrafanaUserWithLdapUser(syncQuery); err != nil {
if err == login.ErrInvalidCredentials {
ctx.Handle(500, "Unable to authenticate user", err)
return false
}
// load the user if we have them
if inCache {
query.UserId = userID.(int64)
ctx.Handle(500, "Failed to sync user", err)
return false
}
}
query.UserId = getRequestUserId(ctx)
// if we're using ldap, pass authproxy login name to ldap user sync
} else if setting.LdapEnabled {
ctx.Session.Delete(session.SESS_KEY_LASTLDAPSYNC)
syncQuery := &m.LoginUserQuery{
ReqContext: ctx,
Username: proxyHeaderValue,
......@@ -86,9 +58,6 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
ctx.Handle(500, "Unable to authenticate user", err)
return false
}
ctx.Handle(500, "Failed to sync user", err)
return false
}
if syncQuery.User == nil {
......@@ -149,67 +118,40 @@ func initContextWithAuthProxy(ctx *m.ReqContext, orgID int64) bool {
ctx.Handle(500, "Failed to find user", err)
return true
}
ctx.SignedInUser = query.Result
ctx.IsSignedIn = true
// Make sure that we cannot share a session between different users!
if getRequestUserId(ctx) > 0 && getRequestUserId(ctx) != query.Result.UserId {
// remove session
if err := ctx.Session.Destory(ctx.Context); err != nil {
log.Error(3, "Failed to destroy session. error: %v", err)
}
expiration := time.Duration(-setting.AuthProxyLdapSyncTtl) * time.Minute
value := query.UserId
// initialize a new session
if err := ctx.Session.Start(ctx.Context); err != nil {
log.Error(3, "Failed to start session. error: %v", err)
// This <if> is here to make sure we do not
// rewrite the expiration all the time
if inCache == false {
if err = store.Set(cacheKey, value, expiration); err != nil {
ctx.Handle(500, "Couldn't write a user in cache key", err)
return true
}
}
ctx.Session.Set(AUTH_PROXY_SESSION_VAR, proxyHeaderValue)
ctx.SignedInUser = query.Result
ctx.IsSignedIn = true
ctx.Session.Set(session.SESS_KEY_USERID, ctx.UserId)
return true
}
var syncGrafanaUserWithLdapUser = func(query *m.LoginUserQuery) error {
expireEpoch := time.Now().Add(time.Duration(-setting.AuthProxyLdapSyncTtl) * time.Minute).Unix()
var lastLdapSync int64
if lastLdapSyncInSession := query.ReqContext.Session.Get(session.SESS_KEY_LASTLDAPSYNC); lastLdapSyncInSession != nil {
lastLdapSync = lastLdapSyncInSession.(int64)
ldapCfg := login.LdapCfg
if len(ldapCfg.Servers) < 1 {
return fmt.Errorf("No LDAP servers available")
}
if lastLdapSync < expireEpoch {
ldapCfg := login.LdapCfg
if len(ldapCfg.Servers) < 1 {
return fmt.Errorf("No LDAP servers available")
for _, server := range ldapCfg.Servers {
author := login.NewLdapAuthenticator(server)
if err := author.SyncUser(query); err != nil {
return err
}
for _, server := range ldapCfg.Servers {
author := login.NewLdapAuthenticator(server)
if err := author.SyncUser(query); err != nil {
return err
}
}
query.ReqContext.Session.Set(session.SESS_KEY_LASTLDAPSYNC, time.Now().Unix())
}
return nil
}
func getRequestUserId(c *m.ReqContext) int64 {
userID := c.Session.Get(session.SESS_KEY_USERID)
if userID != nil {
return userID.(int64)
}
return 0
}
func checkAuthenticationProxy(remoteAddr string, proxyHeaderValue string) error {
if len(strings.TrimSpace(setting.AuthProxyWhitelist)) == 0 {
return nil
......
package middleware
import (
"testing"
"time"
"github.com/grafana/grafana/pkg/login"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
"gopkg.in/macaron.v1"
)
func TestAuthProxyWithLdapEnabled(t *testing.T) {
Convey("When calling sync grafana user with ldap user", t, func() {
setting.LdapEnabled = true
setting.AuthProxyLdapSyncTtl = 60
servers := []*login.LdapServerConf{{Host: "127.0.0.1"}}
login.LdapCfg = login.LdapConfig{Servers: servers}
mockLdapAuther := mockLdapAuthenticator{}
login.NewLdapAuthenticator = func(server *login.LdapServerConf) login.ILdapAuther {
return &mockLdapAuther
}
Convey("When user logs in, call SyncUser", func() {
// arrange
sess := newMockSession()
ctx := m.ReqContext{Session: &sess}
So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldBeNil)
// act
syncGrafanaUserWithLdapUser(&m.LoginUserQuery{
ReqContext: &ctx,
Username: "test",
})
// assert
So(mockLdapAuther.syncUserCalled, ShouldBeTrue)
So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldBeGreaterThan, 0)
})
Convey("When session variable not expired, don't sync and don't change session var", func() {
// arrange
sess := newMockSession()
ctx := m.ReqContext{Session: &sess}
now := time.Now().Unix()
sess.Set(session.SESS_KEY_LASTLDAPSYNC, now)
sess.Set(AUTH_PROXY_SESSION_VAR, "test")
// act
syncGrafanaUserWithLdapUser(&m.LoginUserQuery{
ReqContext: &ctx,
Username: "test",
})
// assert
So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldEqual, now)
So(mockLdapAuther.syncUserCalled, ShouldBeFalse)
})
Convey("When lastldapsync is expired, session variable should be updated", func() {
// arrange
sess := newMockSession()
ctx := m.ReqContext{Session: &sess}
expiredTime := time.Now().Add(time.Duration(-120) * time.Minute).Unix()
sess.Set(session.SESS_KEY_LASTLDAPSYNC, expiredTime)
sess.Set(AUTH_PROXY_SESSION_VAR, "test")
// act
syncGrafanaUserWithLdapUser(&m.LoginUserQuery{
ReqContext: &ctx,
Username: "test",
})
// assert
So(sess.Get(session.SESS_KEY_LASTLDAPSYNC), ShouldBeGreaterThan, expiredTime)
So(mockLdapAuther.syncUserCalled, ShouldBeTrue)
})
})
}
type mockSession struct {
value map[interface{}]interface{}
}
func newMockSession() mockSession {
session := mockSession{}
session.value = make(map[interface{}]interface{})
return session
}
func (s *mockSession) Start(c *macaron.Context) error {
return nil
}
func (s *mockSession) Set(k interface{}, v interface{}) error {
s.value[k] = v
return nil
}
func (s *mockSession) Get(k interface{}) interface{} {
return s.value[k]
}
func (s *mockSession) Delete(k interface{}) interface{} {
delete(s.value, k)
return nil
}
func (s *mockSession) ID() string {
return ""
}
func (s *mockSession) Release() error {
return nil
}
func (s *mockSession) Destory(c *macaron.Context) error {
return nil
}
func (s *mockSession) RegenerateId(c *macaron.Context) error {
return nil
}
type mockLdapAuthenticator struct {
syncUserCalled bool
}
func (a *mockLdapAuthenticator) Login(query *m.LoginUserQuery) error {
return nil
}
func (a *mockLdapAuthenticator) SyncUser(query *m.LoginUserQuery) error {
a.syncUserCalled = true
return nil
}
func (a *mockLdapAuthenticator) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *login.LdapUserInfo) (*m.User, error) {
return nil, nil
}
......@@ -11,7 +11,7 @@ func TestMiddlewareAuth(t *testing.T) {
Convey("Given the grafana middleware", t, func() {
reqSignIn := Auth(&AuthOptions{ReqSignedIn: true})
middlewareScenario("ReqSignIn true and unauthenticated request", func(sc *scenarioContext) {
middlewareScenario(t, "ReqSignIn true and unauthenticated request", func(sc *scenarioContext) {
sc.m.Get("/secure", reqSignIn, sc.defaultHandler)
sc.fakeReq("GET", "/secure").exec()
......@@ -21,7 +21,7 @@ func TestMiddlewareAuth(t *testing.T) {
})
})
middlewareScenario("ReqSignIn true and unauthenticated API request", func(sc *scenarioContext) {
middlewareScenario(t, "ReqSignIn true and unauthenticated API request", func(sc *scenarioContext) {
sc.m.Get("/api/secure", reqSignIn, sc.defaultHandler)
sc.fakeReq("GET", "/api/secure").exec()
......
......@@ -27,7 +27,7 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
return nil
})
middlewareScenario("GET dashboard by legacy url", func(sc *scenarioContext) {
middlewareScenario(t, "GET dashboard by legacy url", func(sc *scenarioContext) {
sc.m.Get("/dashboard/db/:slug", redirectFromLegacyDashboardUrl, sc.defaultHandler)
sc.fakeReqWithParams("GET", "/dashboard/db/dash?orgId=1&panelId=2", map[string]string{}).exec()
......@@ -40,7 +40,7 @@ func TestMiddlewareDashboardRedirect(t *testing.T) {
})
})
middlewareScenario("GET dashboard solo by legacy url", func(sc *scenarioContext) {
middlewareScenario(t, "GET dashboard solo by legacy url", func(sc *scenarioContext) {
sc.m.Get("/dashboard-solo/db/:slug", redirectFromLegacyDashboardSoloUrl, sc.defaultHandler)
sc.fakeReqWithParams("GET", "/dashboard-solo/db/dash?orgId=1&panelId=2", map[string]string{}).exec()
......
......@@ -8,9 +8,9 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/infra/remotecache"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/session"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
macaron "gopkg.in/macaron.v1"
......@@ -23,12 +23,11 @@ var (
ReqOrgAdmin = RoleAuth(m.ROLE_ADMIN)
)
func GetContextHandler(ats m.UserTokenService) macaron.Handler {
func GetContextHandler(ats m.UserTokenService, remoteCache *remotecache.RemoteCache) macaron.Handler {
return func(c *macaron.Context) {
ctx := &m.ReqContext{
Context: c,
SignedInUser: &m.SignedInUser{},
Session: session.GetSession(), // should only be used by auth_proxy
IsSignedIn: false,
AllowAnonymous: false,
SkipCache: false,
......@@ -50,7 +49,7 @@ func GetContextHandler(ats m.UserTokenService) macaron.Handler {
case initContextWithRenderAuth(ctx):
case initContextWithApiKey(ctx):
case initContextWithBasicAuth(ctx, orgId):
case initContextWithAuthProxy(ctx, orgId):
case initContextWithAuthProxy(remoteCache, ctx, orgId):
case initContextWithToken(ats, ctx, orgId):
case initContextWithAnonymousUser(ctx):
}
......
package middleware
import (
"testing"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/bus"
m "github.com/grafana/grafana/pkg/models"
......@@ -13,7 +12,7 @@ import (
func TestOrgRedirectMiddleware(t *testing.T) {
Convey("Can redirect to correct org", t, func() {
middlewareScenario("when setting a correct org for the user", func(sc *scenarioContext) {
middlewareScenario(t, "when setting a correct org for the user", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return nil
......@@ -39,7 +38,7 @@ func TestOrgRedirectMiddleware(t *testing.T) {
})
})
middlewareScenario("when setting an invalid org for user", func(sc *scenarioContext) {
middlewareScenario(t, "when setting an invalid org for user", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.SetUsingOrgCommand) error {
return fmt.Errorf("")
......
......@@ -3,11 +3,10 @@ package middleware
import (
"testing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/quota"
"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/quota"
"github.com/grafana/grafana/pkg/setting"
. "github.com/smartystreets/goconvey/convey"
)
......@@ -43,7 +42,7 @@ func TestMiddlewareQuota(t *testing.T) {
}
QuotaFn := Quota(qs)
middlewareScenario("with user not logged in", func(sc *scenarioContext) {
middlewareScenario(t, "with user not logged in", func(sc *scenarioContext) {
bus.AddHandler("globalQuota", func(query *m.GetGlobalQuotaByTargetQuery) error {
query.Result = &m.GlobalQuotaDTO{
Target: query.Target,
......@@ -81,7 +80,7 @@ func TestMiddlewareQuota(t *testing.T) {
})
})
middlewareScenario("with user logged in", func(sc *scenarioContext) {
middlewareScenario(t, "with user logged in", func(sc *scenarioContext) {
sc.withTokenSessionCookie("token")
bus.AddHandler("test", func(query *m.GetSignedInUserQuery) error {
query.Result = &m.SignedInUser{OrgId: 2, UserId: 12}
......
......@@ -5,6 +5,7 @@ import (
"testing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/remotecache"
m "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/setting"
......@@ -17,7 +18,7 @@ func TestRecoveryMiddleware(t *testing.T) {
Convey("Given an api route that panics", t, func() {
apiURL := "/api/whatever"
recoveryScenario("recovery middleware should return json", apiURL, func(sc *scenarioContext) {
recoveryScenario(t, "recovery middleware should return json", apiURL, func(sc *scenarioContext) {
sc.handlerFunc = PanicHandler
sc.fakeReq("GET", apiURL).exec()
sc.req.Header.Add("content-type", "application/json")
......@@ -30,7 +31,7 @@ func TestRecoveryMiddleware(t *testing.T) {
Convey("Given a non-api route that panics", t, func() {
apiURL := "/whatever"
recoveryScenario("recovery middleware should return html", apiURL, func(sc *scenarioContext) {
recoveryScenario(t, "recovery middleware should return html", apiURL, func(sc *scenarioContext) {
sc.handlerFunc = PanicHandler
sc.fakeReq("GET", apiURL).exec()
......@@ -45,7 +46,7 @@ func PanicHandler(c *m.ReqContext) {
panic("Handler has panicked")
}
func recoveryScenario(desc string, url string, fn scenarioFunc) {
func recoveryScenario(t *testing.T, desc string, url string, fn scenarioFunc) {
Convey(desc, func() {
defer bus.ClearBusHandlers()
......@@ -64,7 +65,9 @@ func recoveryScenario(desc string, url string, fn scenarioFunc) {
}))
sc.userAuthTokenService = auth.NewFakeUserAuthTokenService()
sc.m.Use(GetContextHandler(sc.userAuthTokenService))
sc.remoteCacheService = remotecache.NewFakeStore(t)
sc.m.Use(GetContextHandler(sc.userAuthTokenService, sc.remoteCacheService))
// mock out gc goroutine
sc.m.Use(OrgRedirect())
sc.m.Use(AddDefaultResponseHeaders())
......
......@@ -11,6 +11,8 @@ import (
"testing"
"time"
"github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/log"
m "github.com/grafana/grafana/pkg/models"
......@@ -21,12 +23,8 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/go-sql-driver/mysql"
"github.com/go-xorm/xorm"
_ "github.com/grafana/grafana/pkg/tsdb/mssql"
"github.com/grafana/grafana/pkg/util"
_ "github.com/lib/pq"
sqlite3 "github.com/mattn/go-sqlite3"
)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment