From 49673c509d54e522171bf8afc9aaf9ca01a3c1f3 Mon Sep 17 00:00:00 2001 From: bergquist Date: Thu, 25 Jan 2018 11:05:59 +0100 Subject: [PATCH 01/55] fixes broken phantomjs rendering when migrating from govendor to dep we broke the phantomjs rendering. ref #10602 --- .gitignore | 4 +- docs/sources/reference/sharing.md | 2 +- pkg/setting/setting.go | 2 +- scripts/grunt/options/phantomjs.js | 2 +- scripts/grunt/release_task.js | 2 +- tools/phantomjs/render.js | 86 ++++++++++++++++++++++++++++++ 6 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 tools/phantomjs/render.js diff --git a/.gitignore b/.gitignore index deb1c1f882a..72f6684ef20 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ awsconfig /public_gen /public/vendor/npm /tmp -vendor/phantomjs/phantomjs -vendor/phantomjs/phantomjs.exe +tools/phantomjs/phantomjs +tools/phantomjs/phantomjs.exe profile.out coverage.txt diff --git a/docs/sources/reference/sharing.md b/docs/sources/reference/sharing.md index badd3b5712a..20aea1acd2e 100644 --- a/docs/sources/reference/sharing.md +++ b/docs/sources/reference/sharing.md @@ -39,7 +39,7 @@ Click a panel title to open the panel menu, then click share in the panel menu t ### Direct Link Rendered Image -You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `vendor/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig) are available. +You also get a link to service side rendered PNG of the panel. Useful if you want to share an image of the panel. Please note that for OSX and Windows, you will need to ensure that a `phantomjs` binary is available under `tools/phantomjs/phantomjs`. For Linux, a `phantomjs` binary is included - however, you should ensure that any requisite libraries (e.g. libfontconfig) are available. Example of a link to a server-side rendered PNG: diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 8cdb94bd413..d236446eb71 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -578,7 +578,7 @@ func NewConfigContext(args *CommandLineArgs) error { // PhantomJS rendering ImagesDir = filepath.Join(DataPath, "png") - PhantomDir = filepath.Join(HomePath, "vendor/phantomjs") + PhantomDir = filepath.Join(HomePath, "tools/phantomjs") analytics := Cfg.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) diff --git a/scripts/grunt/options/phantomjs.js b/scripts/grunt/options/phantomjs.js index af7deff305c..8c4e8dbd254 100644 --- a/scripts/grunt/options/phantomjs.js +++ b/scripts/grunt/options/phantomjs.js @@ -3,7 +3,7 @@ module.exports = function(config,grunt) { grunt.registerTask('phantomjs', 'Copy phantomjs binary to vendor/', function() { - var dest = './vendor/phantomjs/phantomjs'; + var dest = './tools/phantomjs/phantomjs'; var confDir = './node_modules/phantomjs-prebuilt/lib/'; if (process.platform === "win32") { diff --git a/scripts/grunt/release_task.js b/scripts/grunt/release_task.js index 28208ed0086..4fbc41cfb08 100644 --- a/scripts/grunt/release_task.js +++ b/scripts/grunt/release_task.js @@ -26,7 +26,7 @@ module.exports = function(grunt) { }); grunt.config('copy.backend_files', { expand: true, - src: ['conf/**', 'vendor/phantomjs/*', 'scripts/*'], + src: ['conf/**', 'tools/phantomjs/*', 'scripts/*'], options: { mode: true}, dest: '<%= tempDir %>' }); diff --git a/tools/phantomjs/render.js b/tools/phantomjs/render.js new file mode 100644 index 00000000000..6ae9b5773b0 --- /dev/null +++ b/tools/phantomjs/render.js @@ -0,0 +1,86 @@ +(function() { + 'use strict'; + + var page = require('webpage').create(); + var args = require('system').args; + var params = {}; + var regexp = /^([^=]+)=([^$]+)/; + + args.forEach(function(arg) { + var parts = arg.match(regexp); + if (!parts) { return; } + params[parts[1]] = parts[2]; + }); + + var usage = "url= png= width= height= renderKey="; + + if (!params.url || !params.png || !params.renderKey || !params.domain) { + console.log(usage); + phantom.exit(); + } + + phantom.addCookie({ + 'name': 'renderKey', + 'value': params.renderKey, + 'domain': params.domain, + }); + + page.viewportSize = { + width: params.width || '800', + height: params.height || '400' + }; + + var timeoutMs = (parseInt(params.timeout) || 10) * 1000; + var waitBetweenReadyCheckMs = 50; + var totalWaitMs = 0; + + page.open(params.url, function (status) { + console.log('Loading a web page: ' + params.url + ' status: ' + status, timeoutMs); + + page.onError = function(msg, trace) { + var msgStack = ['ERROR: ' + msg]; + if (trace && trace.length) { + msgStack.push('TRACE:'); + trace.forEach(function(t) { + msgStack.push(' -> ' + t.file + ': ' + t.line + (t.function ? ' (in function "' + t.function +'")' : '')); + }); + } + console.error(msgStack.join('\n')); + }; + + function checkIsReady() { + var panelsRendered = page.evaluate(function() { + if (!window.angular) { return false; } + var body = window.angular.element(document.body); + if (!body.injector) { return false; } + if (!body.injector()) { return false; } + + var rootScope = body.injector().get('$rootScope'); + if (!rootScope) {return false;} + var panels = angular.element('div.panel:visible').length; + return rootScope.panelsRendered >= panels; + }); + + if (panelsRendered || totalWaitMs > timeoutMs) { + var bb = page.evaluate(function () { + return document.getElementsByClassName("main-view")[0].getBoundingClientRect(); + }); + + page.clipRect = { + top: bb.top, + left: bb.left, + width: bb.width, + height: bb.height + }; + + page.render(params.png); + phantom.exit(); + } else { + totalWaitMs += waitBetweenReadyCheckMs; + setTimeout(checkIsReady, waitBetweenReadyCheckMs); + } + } + + setTimeout(checkIsReady, waitBetweenReadyCheckMs); + }); + })(); \ No newline at end of file From b79017e4a48bf0f1ced4f743b51fef93a42afa76 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 26 Jan 2018 11:23:56 +0300 Subject: [PATCH 02/55] graph: fix series sorting issue (#10617) --- public/app/plugins/panel/graph/graph.ts | 31 ++++++------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 91ca17c52f6..e23cad63305 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -355,33 +355,16 @@ function graphDirective(timeSrv, popoverSrv, contextSrv) { function sortSeries(series, panel) { var sortBy = panel.legend.sort; var sortOrder = panel.legend.sortDesc; - var haveSortBy = sortBy !== null || sortBy !== undefined; - var haveSortOrder = sortOrder !== null || sortOrder !== undefined; + var haveSortBy = sortBy !== null && sortBy !== undefined; + var haveSortOrder = sortOrder !== null && sortOrder !== undefined; var shouldSortBy = panel.stack && haveSortBy && haveSortOrder; var sortDesc = panel.legend.sortDesc === true ? -1 : 1; - series.sort((x, y) => { - if (x.zindex > y.zindex) { - return 1; - } - - if (x.zindex < y.zindex) { - return -1; - } - - if (shouldSortBy) { - if (x.stats[sortBy] > y.stats[sortBy]) { - return 1 * sortDesc; - } - if (x.stats[sortBy] < y.stats[sortBy]) { - return -1 * sortDesc; - } - } - - return 0; - }); - - return series; + if (shouldSortBy) { + return _.sortBy(series, s => s.stats[sortBy] * sortDesc); + } else { + return _.sortBy(series, s => s.zindex); + } } function translateFillOption(fill) { From cffbb6afd59a61032365d5cef1731f25f736a7d3 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 26 Jan 2018 11:24:56 +0300 Subject: [PATCH 03/55] fix vertical panel repeat (#10619) --- .../app/features/dashboard/dashboard_model.ts | 8 +++++ .../features/dashboard/specs/repeat.jest.ts | 34 ++++++------------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 072b38fba22..6c291c3b69a 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -354,6 +354,14 @@ export class DashboardModel { if (panel.repeatDirection === REPEAT_DIR_VERTICAL) { copy.gridPos.y = yPos; yPos += copy.gridPos.h; + + // Update gridPos for panels below + let panelBelowIndex = panelIndex + index + 1; + for (let i = panelBelowIndex; i < this.panels.length; i++) { + if (this.panels[i].gridPos.y < yPos) { + this.panels[i].gridPos.y += copy.gridPos.h; + } + } } else { // set width based on how many are selected // assumed the repeated panels should take up full row width diff --git a/public/app/features/dashboard/specs/repeat.jest.ts b/public/app/features/dashboard/specs/repeat.jest.ts index fbf1f836191..e05162dc69f 100644 --- a/public/app/features/dashboard/specs/repeat.jest.ts +++ b/public/app/features/dashboard/specs/repeat.jest.ts @@ -142,12 +142,9 @@ describe('given dashboard with panel repeat in vertical direction', function() { beforeEach(function() { dashboard = new DashboardModel({ panels: [ - { - id: 2, - repeat: 'apps', - repeatDirection: 'v', - gridPos: { x: 5, y: 0, h: 2, w: 8 }, - }, + { id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, repeat: 'apps', repeatDirection: 'v', gridPos: { x: 5, y: 1, h: 2, w: 8 } }, + { id: 3, type: 'row', gridPos: { x: 0, y: 3, h: 1, w: 24 } }, ], templating: { list: [ @@ -171,24 +168,13 @@ describe('given dashboard with panel repeat in vertical direction', function() { }); it('should place on items on top of each other and keep witdh', function() { - expect(dashboard.panels[0].gridPos).toMatchObject({ - x: 5, - y: 0, - h: 2, - w: 8, - }); - expect(dashboard.panels[1].gridPos).toMatchObject({ - x: 5, - y: 2, - h: 2, - w: 8, - }); - expect(dashboard.panels[2].gridPos).toMatchObject({ - x: 5, - y: 4, - h: 2, - w: 8, - }); + expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // first row + + expect(dashboard.panels[1].gridPos).toMatchObject({ x: 5, y: 1, h: 2, w: 8 }); + expect(dashboard.panels[2].gridPos).toMatchObject({ x: 5, y: 3, h: 2, w: 8 }); + expect(dashboard.panels[3].gridPos).toMatchObject({ x: 5, y: 5, h: 2, w: 8 }); + + expect(dashboard.panels[4].gridPos).toMatchObject({ x: 0, y: 7, h: 1, w: 24 }); // last row }); }); From 475febd0048dcd5fc1a161556f0e909efec33405 Mon Sep 17 00:00:00 2001 From: James Westover Date: Fri, 26 Jan 2018 02:27:06 -0600 Subject: [PATCH 04/55] Fix typeahead to avoid generating new backend request on each keypress. (#10596) * Fix typeahead to not generate new request on each keypress. * Change to debounce method --- public/app/core/components/query_part/query_part_editor.ts | 4 ++-- public/app/core/directives/metric_segment.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/app/core/components/query_part/query_part_editor.ts b/public/app/core/components/query_part/query_part_editor.ts index 138da186238..c78ca8d74e6 100644 --- a/public/app/core/components/query_part/query_part_editor.ts +++ b/public/app/core/components/query_part/query_part_editor.ts @@ -123,11 +123,11 @@ export function queryPartEditorDirective($compile, templateSrv) { }); var typeahead = $input.data('typeahead'); - typeahead.lookup = function() { + typeahead.lookup = _.debounce(function() { this.query = this.$element.val() || ''; var items = this.source(this.query, $.proxy(this.process, this)); return items ? this.process(items) : items; - }; + }, 500); } $scope.showActionsMenu = function() { diff --git a/public/app/core/directives/metric_segment.js b/public/app/core/directives/metric_segment.js index 2754f8d8c6e..b11625227c7 100644 --- a/public/app/core/directives/metric_segment.js +++ b/public/app/core/directives/metric_segment.js @@ -129,11 +129,11 @@ function (_, $, coreModule) { $input.typeahead({ source: $scope.source, minLength: 0, items: 10000, updater: $scope.updater, matcher: $scope.matcher }); var typeahead = $input.data('typeahead'); - typeahead.lookup = function () { + typeahead.lookup = _.debounce(function() { this.query = this.$element.val() || ''; var items = this.source(this.query, $.proxy(this.process, this)); return items ? this.process(items) : items; - }; + }, 500); $button.keydown(function(evt) { // trigger typeahead on down arrow or enter key From 3d1c624c12e16da69e9fd1be66173c96502e69a6 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Fri, 26 Jan 2018 10:41:41 +0100 Subject: [PATCH 05/55] WIP: Protect against brute force (frequent) login attempts (#10031) * db: add login attempt migrations * db: add possibility to create login attempts * db: add possibility to retrieve login attempt count per username * auth: validation and update of login attempts for invalid credentials If login attempt count for user authenticating is 5 or more the last 5 minutes we temporarily block the user access to login * db: add possibility to delete expired login attempts * cleanup: Delete login attempts older than 10 minutes The cleanup job are running continuously and triggering each 10 minute * fix typo: rename consequent to consequent * auth: enable login attempt validation for ldap logins * auth: disable login attempts validation by configuration Setting is named DisableLoginAttemptsValidation and is false by default Config disable_login_attempts_validation is placed under security section #7616 * auth: don't run cleanup of login attempts if feature is disabled #7616 * auth: rename settings.go to ldap_settings.go * auth: refactor AuthenticateUser Extract grafana login, ldap login and login attemp validation together with their tests to separate files. Enables testing of many more aspects when authenticating a user. #7616 * auth: rename login attempt validation to brute force login protection Setting DisableLoginAttemptsValidation => DisableBruteForceLoginProtection Configuration disable_login_attempts_validation => disable_brute_force_login_protection #7616 --- conf/defaults.ini | 3 + conf/sample.ini | 3 + pkg/api/login.go | 7 +- pkg/login/auth.go | 63 +++--- pkg/login/auth_test.go | 214 ++++++++++++++++++ pkg/login/brute_force_login_protection.go | 48 ++++ .../brute_force_login_protection_test.go | 125 ++++++++++ pkg/login/grafana_login.go | 35 +++ pkg/login/grafana_login_test.go | 139 ++++++++++++ pkg/login/ldap_login.go | 21 ++ pkg/login/ldap_login_test.go | 172 ++++++++++++++ pkg/login/{settings.go => ldap_settings.go} | 0 pkg/models/login_attempt.go | 36 +++ pkg/services/cleanup/cleanup.go | 16 ++ pkg/services/sqlstore/login_attempt.go | 91 ++++++++ pkg/services/sqlstore/login_attempt_test.go | 125 ++++++++++ .../sqlstore/migrations/login_attempt_mig.go | 23 ++ .../sqlstore/migrations/migrations.go | 1 + pkg/services/sqlstore/migrator/dialect.go | 5 + .../sqlstore/migrator/sqlite_dialect.go | 4 + pkg/services/sqlstore/sqlutil/sqlutil.go | 2 +- pkg/setting/setting.go | 16 +- 22 files changed, 1101 insertions(+), 48 deletions(-) create mode 100644 pkg/login/auth_test.go create mode 100644 pkg/login/brute_force_login_protection.go create mode 100644 pkg/login/brute_force_login_protection_test.go create mode 100644 pkg/login/grafana_login.go create mode 100644 pkg/login/grafana_login_test.go create mode 100644 pkg/login/ldap_login.go create mode 100644 pkg/login/ldap_login_test.go rename pkg/login/{settings.go => ldap_settings.go} (100%) create mode 100644 pkg/models/login_attempt.go create mode 100644 pkg/services/sqlstore/login_attempt.go create mode 100644 pkg/services/sqlstore/login_attempt_test.go create mode 100644 pkg/services/sqlstore/migrations/login_attempt_mig.go diff --git a/conf/defaults.ini b/conf/defaults.ini index 5439a373bbb..3766c829323 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -174,6 +174,9 @@ disable_gravatar = false # data source proxy whitelist (ip_or_domain:port separated by spaces) data_source_proxy_whitelist = +# disable protection against brute force login attempts +disable_brute_force_login_protection = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options diff --git a/conf/sample.ini b/conf/sample.ini index 59bd5845ffe..784f6b7cfc9 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -162,6 +162,9 @@ log_queries = # data source proxy whitelist (ip_or_domain:port separated by spaces) ;data_source_proxy_whitelist = +# disable protection against brute force login attempts +;disable_brute_force_login_protection = false + #################################### Snapshots ########################### [snapshots] # snapshot sharing options diff --git a/pkg/api/login.go b/pkg/api/login.go index ebfe672f825..b6855af7baf 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -102,12 +102,13 @@ func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response { } authQuery := login.LoginUserQuery{ - Username: cmd.User, - Password: cmd.Password, + Username: cmd.User, + Password: cmd.Password, + IpAddress: c.Req.RemoteAddr, } if err := bus.Dispatch(&authQuery); err != nil { - if err == login.ErrInvalidCredentials { + if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts { return ApiError(401, "Invalid username or password", err) } diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 45561783e43..5527c7271d6 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -3,21 +3,20 @@ package login import ( "errors" - "crypto/subtle" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) var ( - ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") ) type LoginUserQuery struct { - Username string - Password string - User *m.User + Username string + Password string + User *m.User + IpAddress string } func Init() { @@ -26,41 +25,31 @@ func Init() { } func AuthenticateUser(query *LoginUserQuery) error { - err := loginUsingGrafanaDB(query) - if err == nil || err != ErrInvalidCredentials { + if err := validateLoginAttempts(query.Username); err != nil { return err } - if setting.LdapEnabled { - for _, server := range LdapCfg.Servers { - author := NewLdapAuthenticator(server) - err = author.Login(query) - if err == nil || err != ErrInvalidCredentials { - return err - } + err := loginUsingGrafanaDB(query) + if err == nil || (err != m.ErrUserNotFound && err != ErrInvalidCredentials) { + return err + } + + ldapEnabled, ldapErr := loginUsingLdap(query) + if ldapEnabled { + if ldapErr == nil || ldapErr != ErrInvalidCredentials { + return ldapErr } + + err = ldapErr + } + + if err == ErrInvalidCredentials { + saveInvalidLoginAttempt(query) + } + + if err == m.ErrUserNotFound { + return ErrInvalidCredentials } return err } - -func loginUsingGrafanaDB(query *LoginUserQuery) error { - userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} - - if err := bus.Dispatch(&userQuery); err != nil { - if err == m.ErrUserNotFound { - return ErrInvalidCredentials - } - return err - } - - user := userQuery.Result - - passwordHashed := util.EncodePassword(query.Password, user.Salt) - if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(user.Password)) != 1 { - return ErrInvalidCredentials - } - - query.User = user - return nil -} diff --git a/pkg/login/auth_test.go b/pkg/login/auth_test.go new file mode 100644 index 00000000000..59d3c8f2b33 --- /dev/null +++ b/pkg/login/auth_test.go @@ -0,0 +1,214 @@ +package login + +import ( + "errors" + "testing" + + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestAuthenticateUser(t *testing.T) { + Convey("Authenticate user", t, func() { + authScenario("When a user authenticates having too many login attempts", func(sc *authScenarioContext) { + mockLoginAttemptValidation(ErrTooManyLoginAttempts, sc) + mockLoginUsingGrafanaDB(nil, sc) + mockLoginUsingLdap(true, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeFalse) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate with valid credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(nil, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, nil) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate and unexpected error occurs", func(sc *authScenarioContext) { + customErr := errors.New("custom") + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(customErr, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, customErr) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeFalse) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and ldap disabled", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(false, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and invalid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue) + }) + }) + + authScenario("When a non-existing grafana user authenticate and valid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, nil, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldBeNil) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When a non-existing grafana user authenticate and ldap returns unexpected error", func(sc *authScenarioContext) { + customErr := errors.New("custom") + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) + mockLoginUsingLdap(true, customErr, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, customErr) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) + }) + }) + + authScenario("When grafana user authenticate with invalid credentials and invalid ldap credentials", func(sc *authScenarioContext) { + mockLoginAttemptValidation(nil, sc) + mockLoginUsingGrafanaDB(ErrInvalidCredentials, sc) + mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockSaveInvalidLoginAttempt(sc) + + err := AuthenticateUser(sc.loginUserQuery) + + Convey("it should result in", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) + So(sc.grafanaLoginWasCalled, ShouldBeTrue) + So(sc.ldapLoginWasCalled, ShouldBeTrue) + So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue) + }) + }) + }) +} + +type authScenarioContext struct { + loginUserQuery *LoginUserQuery + grafanaLoginWasCalled bool + ldapLoginWasCalled bool + loginAttemptValidationWasCalled bool + saveInvalidLoginAttemptWasCalled bool +} + +type authScenarioFunc func(sc *authScenarioContext) + +func mockLoginUsingGrafanaDB(err error, sc *authScenarioContext) { + loginUsingGrafanaDB = func(query *LoginUserQuery) error { + sc.grafanaLoginWasCalled = true + return err + } +} + +func mockLoginUsingLdap(enabled bool, err error, sc *authScenarioContext) { + loginUsingLdap = func(query *LoginUserQuery) (bool, error) { + sc.ldapLoginWasCalled = true + return enabled, err + } +} + +func mockLoginAttemptValidation(err error, sc *authScenarioContext) { + validateLoginAttempts = func(username string) error { + sc.loginAttemptValidationWasCalled = true + return err + } +} + +func mockSaveInvalidLoginAttempt(sc *authScenarioContext) { + saveInvalidLoginAttempt = func(query *LoginUserQuery) { + sc.saveInvalidLoginAttemptWasCalled = true + } +} + +func authScenario(desc string, fn authScenarioFunc) { + Convey(desc, func() { + origLoginUsingGrafanaDB := loginUsingGrafanaDB + origLoginUsingLdap := loginUsingLdap + origValidateLoginAttempts := validateLoginAttempts + origSaveInvalidLoginAttempt := saveInvalidLoginAttempt + + sc := &authScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + } + + defer func() { + loginUsingGrafanaDB = origLoginUsingGrafanaDB + loginUsingLdap = origLoginUsingLdap + validateLoginAttempts = origValidateLoginAttempts + saveInvalidLoginAttempt = origSaveInvalidLoginAttempt + }() + + fn(sc) + }) +} diff --git a/pkg/login/brute_force_login_protection.go b/pkg/login/brute_force_login_protection.go new file mode 100644 index 00000000000..2ea93979c7a --- /dev/null +++ b/pkg/login/brute_force_login_protection.go @@ -0,0 +1,48 @@ +package login + +import ( + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +var ( + maxInvalidLoginAttempts int64 = 5 + loginAttemptsWindow time.Duration = time.Minute * 5 +) + +var validateLoginAttempts = func(username string) error { + if setting.DisableBruteForceLoginProtection { + return nil + } + + loginAttemptCountQuery := m.GetUserLoginAttemptCountQuery{ + Username: username, + Since: time.Now().Add(-loginAttemptsWindow), + } + + if err := bus.Dispatch(&loginAttemptCountQuery); err != nil { + return err + } + + if loginAttemptCountQuery.Result >= maxInvalidLoginAttempts { + return ErrTooManyLoginAttempts + } + + return nil +} + +var saveInvalidLoginAttempt = func(query *LoginUserQuery) { + if setting.DisableBruteForceLoginProtection { + return + } + + loginAttemptCommand := m.CreateLoginAttemptCommand{ + Username: query.Username, + IpAddress: query.IpAddress, + } + + bus.Dispatch(&loginAttemptCommand) +} diff --git a/pkg/login/brute_force_login_protection_test.go b/pkg/login/brute_force_login_protection_test.go new file mode 100644 index 00000000000..5375134ba88 --- /dev/null +++ b/pkg/login/brute_force_login_protection_test.go @@ -0,0 +1,125 @@ +package login + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestLoginAttemptsValidation(t *testing.T) { + Convey("Validate login attempts", t, func() { + Convey("Given brute force login protection enabled", func() { + setting.DisableBruteForceLoginProtection = false + + Convey("When user login attempt count equals max-1 ", func() { + withLoginAttempts(maxInvalidLoginAttempts - 1) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count equals max ", func() { + withLoginAttempts(maxInvalidLoginAttempts) + err := validateLoginAttempts("user") + + Convey("it should result in too many login attempts error", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + }) + }) + + Convey("When user login attempt count is greater than max ", func() { + withLoginAttempts(maxInvalidLoginAttempts + 5) + err := validateLoginAttempts("user") + + Convey("it should result in too many login attempts error", func() { + So(err, ShouldEqual, ErrTooManyLoginAttempts) + }) + }) + + Convey("When saving invalid login attempt", func() { + defer bus.ClearBusHandlers() + createLoginAttemptCmd := &m.CreateLoginAttemptCommand{} + + bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error { + createLoginAttemptCmd = cmd + return nil + }) + + saveInvalidLoginAttempt(&LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }) + + Convey("it should dispatch command", func() { + So(createLoginAttemptCmd, ShouldNotBeNil) + So(createLoginAttemptCmd.Username, ShouldEqual, "user") + So(createLoginAttemptCmd.IpAddress, ShouldEqual, "192.168.1.1:56433") + }) + }) + }) + + Convey("Given brute force login protection disabled", func() { + setting.DisableBruteForceLoginProtection = true + + Convey("When user login attempt count equals max-1 ", func() { + withLoginAttempts(maxInvalidLoginAttempts - 1) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count equals max ", func() { + withLoginAttempts(maxInvalidLoginAttempts) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When user login attempt count is greater than max ", func() { + withLoginAttempts(maxInvalidLoginAttempts + 5) + err := validateLoginAttempts("user") + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("When saving invalid login attempt", func() { + defer bus.ClearBusHandlers() + createLoginAttemptCmd := (*m.CreateLoginAttemptCommand)(nil) + + bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error { + createLoginAttemptCmd = cmd + return nil + }) + + saveInvalidLoginAttempt(&LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }) + + Convey("it should not dispatch command", func() { + So(createLoginAttemptCmd, ShouldBeNil) + }) + }) + }) + }) +} + +func withLoginAttempts(loginAttempts int64) { + bus.AddHandler("test", func(query *m.GetUserLoginAttemptCountQuery) error { + query.Result = loginAttempts + return nil + }) +} diff --git a/pkg/login/grafana_login.go b/pkg/login/grafana_login.go new file mode 100644 index 00000000000..677ba776e4f --- /dev/null +++ b/pkg/login/grafana_login.go @@ -0,0 +1,35 @@ +package login + +import ( + "crypto/subtle" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +var validatePassword = func(providedPassword string, userPassword string, userSalt string) error { + passwordHashed := util.EncodePassword(providedPassword, userSalt) + if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(userPassword)) != 1 { + return ErrInvalidCredentials + } + + return nil +} + +var loginUsingGrafanaDB = func(query *LoginUserQuery) error { + userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} + + if err := bus.Dispatch(&userQuery); err != nil { + return err + } + + user := userQuery.Result + + if err := validatePassword(query.Password, user.Password, user.Salt); err != nil { + return err + } + + query.User = user + return nil +} diff --git a/pkg/login/grafana_login_test.go b/pkg/login/grafana_login_test.go new file mode 100644 index 00000000000..88e52224113 --- /dev/null +++ b/pkg/login/grafana_login_test.go @@ -0,0 +1,139 @@ +package login + +import ( + "testing" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGrafanaLogin(t *testing.T) { + Convey("Login using Grafana DB", t, func() { + grafanaLoginScenario("When login with non-existing user", func(sc *grafanaLoginScenarioContext) { + sc.withNonExistingUser() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should result in user not found error", func() { + So(err, ShouldEqual, m.ErrUserNotFound) + }) + + Convey("it should not call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeFalse) + }) + + Convey("it should not pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldBeNil) + }) + }) + + grafanaLoginScenario("When login with invalid credentials", func(sc *grafanaLoginScenarioContext) { + sc.withInvalidPassword() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should result in invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeTrue) + }) + + Convey("it should not pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldBeNil) + }) + }) + + grafanaLoginScenario("When login with valid credentials", func(sc *grafanaLoginScenarioContext) { + sc.withValidCredentials() + err := loginUsingGrafanaDB(sc.loginUserQuery) + + Convey("it should not result in error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should call password validation", func() { + So(sc.validatePasswordCalled, ShouldBeTrue) + }) + + Convey("it should pupulate user object", func() { + So(sc.loginUserQuery.User, ShouldNotBeNil) + So(sc.loginUserQuery.User.Login, ShouldEqual, sc.loginUserQuery.Username) + So(sc.loginUserQuery.User.Password, ShouldEqual, sc.loginUserQuery.Password) + }) + }) + }) +} + +type grafanaLoginScenarioContext struct { + loginUserQuery *LoginUserQuery + validatePasswordCalled bool +} + +type grafanaLoginScenarioFunc func(c *grafanaLoginScenarioContext) + +func grafanaLoginScenario(desc string, fn grafanaLoginScenarioFunc) { + Convey(desc, func() { + origValidatePassword := validatePassword + + sc := &grafanaLoginScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + validatePasswordCalled: false, + } + + defer func() { + validatePassword = origValidatePassword + }() + + fn(sc) + }) +} + +func mockPasswordValidation(valid bool, sc *grafanaLoginScenarioContext) { + validatePassword = func(providedPassword string, userPassword string, userSalt string) error { + sc.validatePasswordCalled = true + + if !valid { + return ErrInvalidCredentials + } + + return nil + } +} + +func (sc *grafanaLoginScenarioContext) getUserByLoginQueryReturns(user *m.User) { + bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error { + if user == nil { + return m.ErrUserNotFound + } + + query.Result = user + return nil + }) +} + +func (sc *grafanaLoginScenarioContext) withValidCredentials() { + sc.getUserByLoginQueryReturns(&m.User{ + Id: 1, + Login: sc.loginUserQuery.Username, + Password: sc.loginUserQuery.Password, + Salt: "salt", + }) + mockPasswordValidation(true, sc) +} + +func (sc *grafanaLoginScenarioContext) withNonExistingUser() { + sc.getUserByLoginQueryReturns(nil) +} + +func (sc *grafanaLoginScenarioContext) withInvalidPassword() { + sc.getUserByLoginQueryReturns(&m.User{ + Password: sc.loginUserQuery.Password, + Salt: "salt", + }) + mockPasswordValidation(false, sc) +} diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go new file mode 100644 index 00000000000..b74b69db036 --- /dev/null +++ b/pkg/login/ldap_login.go @@ -0,0 +1,21 @@ +package login + +import ( + "github.com/grafana/grafana/pkg/setting" +) + +var loginUsingLdap = func(query *LoginUserQuery) (bool, error) { + if !setting.LdapEnabled { + return false, nil + } + + for _, server := range LdapCfg.Servers { + author := NewLdapAuthenticator(server) + err := author.Login(query) + if err == nil || err != ErrInvalidCredentials { + return true, err + } + } + + return true, ErrInvalidCredentials +} diff --git a/pkg/login/ldap_login_test.go b/pkg/login/ldap_login_test.go new file mode 100644 index 00000000000..6af125566e8 --- /dev/null +++ b/pkg/login/ldap_login_test.go @@ -0,0 +1,172 @@ +package login + +import ( + "testing" + + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + . "github.com/smartystreets/goconvey/convey" +) + +func TestLdapLogin(t *testing.T) { + Convey("Login using ldap", t, func() { + Convey("Given ldap enabled and a server configured", func() { + setting.LdapEnabled = true + LdapCfg.Servers = append(LdapCfg.Servers, + &LdapServerConf{ + Host: "", + }) + + ldapLoginScenario("When login with invalid credentials", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(false) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should return invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) + }) + }) + + ldapLoginScenario("When login with valid credentials", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(true) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should not return error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) + }) + }) + }) + + Convey("Given ldap enabled and no server configured", func() { + setting.LdapEnabled = true + LdapCfg.Servers = make([]*LdapServerConf, 0) + + ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(true) + enabled, err := loginUsingLdap(sc.loginUserQuery) + + Convey("it should return true", func() { + So(enabled, ShouldBeTrue) + }) + + Convey("it should return invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + + Convey("it should not call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse) + }) + }) + }) + + Convey("Given ldap disabled", func() { + setting.LdapEnabled = false + + ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { + sc.withLoginResult(false) + enabled, err := loginUsingLdap(&LoginUserQuery{ + Username: "user", + Password: "pwd", + }) + + Convey("it should return false", func() { + So(enabled, ShouldBeFalse) + }) + + Convey("it should not return error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should not call ldap login", func() { + So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse) + }) + }) + }) + }) +} + +func mockLdapAuthenticator(valid bool) *mockLdapAuther { + mock := &mockLdapAuther{ + validLogin: valid, + } + + NewLdapAuthenticator = func(server *LdapServerConf) ILdapAuther { + return mock + } + + return mock +} + +type mockLdapAuther struct { + validLogin bool + loginCalled bool +} + +func (a *mockLdapAuther) Login(query *LoginUserQuery) error { + a.loginCalled = true + + if !a.validLogin { + return ErrInvalidCredentials + } + + return nil +} + +func (a *mockLdapAuther) SyncSignedInUser(signedInUser *m.SignedInUser) error { + return nil +} + +func (a *mockLdapAuther) GetGrafanaUserFor(ldapUser *LdapUserInfo) (*m.User, error) { + return nil, nil +} + +func (a *mockLdapAuther) SyncOrgRoles(user *m.User, ldapUser *LdapUserInfo) error { + return nil +} + +type ldapLoginScenarioContext struct { + loginUserQuery *LoginUserQuery + ldapAuthenticatorMock *mockLdapAuther +} + +type ldapLoginScenarioFunc func(c *ldapLoginScenarioContext) + +func ldapLoginScenario(desc string, fn ldapLoginScenarioFunc) { + Convey(desc, func() { + origNewLdapAuthenticator := NewLdapAuthenticator + + sc := &ldapLoginScenarioContext{ + loginUserQuery: &LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + ldapAuthenticatorMock: &mockLdapAuther{}, + } + + defer func() { + NewLdapAuthenticator = origNewLdapAuthenticator + }() + + fn(sc) + }) +} + +func (sc *ldapLoginScenarioContext) withLoginResult(valid bool) { + sc.ldapAuthenticatorMock = mockLdapAuthenticator(valid) +} diff --git a/pkg/login/settings.go b/pkg/login/ldap_settings.go similarity index 100% rename from pkg/login/settings.go rename to pkg/login/ldap_settings.go diff --git a/pkg/models/login_attempt.go b/pkg/models/login_attempt.go new file mode 100644 index 00000000000..e4391927702 --- /dev/null +++ b/pkg/models/login_attempt.go @@ -0,0 +1,36 @@ +package models + +import ( + "time" +) + +type LoginAttempt struct { + Id int64 + Username string + IpAddress string + Created time.Time +} + +// --------------------- +// COMMANDS + +type CreateLoginAttemptCommand struct { + Username string + IpAddress string + + Result LoginAttempt +} + +type DeleteOldLoginAttemptsCommand struct { + OlderThan time.Time + DeletedRows int64 +} + +// --------------------- +// QUERIES + +type GetUserLoginAttemptCountQuery struct { + Username string + Since time.Time + Result int64 +} diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index 6e5e7684100..f9dcfce51b7 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -46,6 +46,7 @@ func (service *CleanUpService) start(ctx context.Context) error { service.cleanUpTmpFiles() service.deleteExpiredSnapshots() service.deleteExpiredDashboardVersions() + service.deleteOldLoginAttempts() case <-ctx.Done(): return ctx.Err() } @@ -88,3 +89,18 @@ func (service *CleanUpService) deleteExpiredSnapshots() { func (service *CleanUpService) deleteExpiredDashboardVersions() { bus.Dispatch(&m.DeleteExpiredVersionsCommand{}) } + +func (service *CleanUpService) deleteOldLoginAttempts() { + if setting.DisableBruteForceLoginProtection { + return + } + + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: time.Now().Add(time.Minute * -10), + } + if err := bus.Dispatch(&cmd); err != nil { + service.log.Error("Problem deleting expired login attempts", "error", err.Error()) + } else { + service.log.Debug("Deleted expired login attempts", "rows affected", cmd.DeletedRows) + } +} diff --git a/pkg/services/sqlstore/login_attempt.go b/pkg/services/sqlstore/login_attempt.go new file mode 100644 index 00000000000..805d726df48 --- /dev/null +++ b/pkg/services/sqlstore/login_attempt.go @@ -0,0 +1,91 @@ +package sqlstore + +import ( + "strconv" + "time" + + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +var getTimeNow = time.Now + +func init() { + bus.AddHandler("sql", CreateLoginAttempt) + bus.AddHandler("sql", DeleteOldLoginAttempts) + bus.AddHandler("sql", GetUserLoginAttemptCount) +} + +func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error { + return inTransaction(func(sess *DBSession) error { + loginAttempt := m.LoginAttempt{ + Username: cmd.Username, + IpAddress: cmd.IpAddress, + Created: getTimeNow(), + } + + if _, err := sess.Insert(&loginAttempt); err != nil { + return err + } + + cmd.Result = loginAttempt + + return nil + }) +} + +func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error { + return inTransaction(func(sess *DBSession) error { + var maxId int64 + sql := "SELECT max(id) as id FROM login_attempt WHERE created < " + dialect.DateTimeFunc("?") + result, err := sess.Query(sql, cmd.OlderThan) + + if err != nil { + return err + } + + maxId = toInt64(result[0]["id"]) + + if maxId == 0 { + return nil + } + + sql = "DELETE FROM login_attempt WHERE id <= ?" + + if result, err := sess.Exec(sql, maxId); err != nil { + return err + } else if cmd.DeletedRows, err = result.RowsAffected(); err != nil { + return err + } + + return nil + }) +} + +func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error { + loginAttempt := new(m.LoginAttempt) + total, err := x. + Where("username = ?", query.Username). + And("created >="+dialect.DateTimeFunc("?"), query.Since). + Count(loginAttempt) + + if err != nil { + return err + } + + query.Result = total + return nil +} + +func toInt64(i interface{}) int64 { + switch i.(type) { + case []byte: + n, _ := strconv.ParseInt(string(i.([]byte)), 10, 64) + return n + case int: + return int64(i.(int)) + case int64: + return i.(int64) + } + return 0 +} diff --git a/pkg/services/sqlstore/login_attempt_test.go b/pkg/services/sqlstore/login_attempt_test.go new file mode 100644 index 00000000000..8008e2d8a62 --- /dev/null +++ b/pkg/services/sqlstore/login_attempt_test.go @@ -0,0 +1,125 @@ +package sqlstore + +import ( + "testing" + "time" + + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func mockTime(mock time.Time) time.Time { + getTimeNow = func() time.Time { return mock } + return mock +} + +func TestLoginAttempts(t *testing.T) { + Convey("Testing Login Attempts DB Access", t, func() { + InitTestDB(t) + + user := "user" + beginningOfTime := mockTime(time.Date(2017, 10, 22, 8, 0, 0, 0, time.Local)) + + err := CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + timePlusOneMinute := mockTime(beginningOfTime.Add(time.Minute * 1)) + + err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + timePlusTwoMinutes := mockTime(beginningOfTime.Add(time.Minute * 2)) + + err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{ + Username: user, + IpAddress: "192.168.0.1", + }) + So(err, ShouldBeNil) + + Convey("Should return a total count of zero login attempts when comparing since beginning of time + 2min and 1s", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusTwoMinutes.Add(time.Second * 1), + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 0) + }) + + Convey("Should return the total count of login attempts since beginning of time", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: beginningOfTime, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 3) + }) + + Convey("Should return the total count of login attempts since beginning of time + 1min", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusOneMinute, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 2) + }) + + Convey("Should return the total count of login attempts since beginning of time + 2min", func() { + query := m.GetUserLoginAttemptCountQuery{ + Username: user, + Since: timePlusTwoMinutes, + } + err := GetUserLoginAttemptCount(&query) + So(err, ShouldBeNil) + So(query.Result, ShouldEqual, 1) + }) + + Convey("Should return deleted rows older than beginning of time", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: beginningOfTime, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 0) + }) + + Convey("Should return deleted rows older than beginning of time + 1min", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusOneMinute, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 1) + }) + + Convey("Should return deleted rows older than beginning of time + 2min", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusTwoMinutes, + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 2) + }) + + Convey("Should return deleted rows older than beginning of time + 2min and 1s", func() { + cmd := m.DeleteOldLoginAttemptsCommand{ + OlderThan: timePlusTwoMinutes.Add(time.Second * 1), + } + err := DeleteOldLoginAttempts(&cmd) + + So(err, ShouldBeNil) + So(cmd.DeletedRows, ShouldEqual, 3) + }) + }) +} diff --git a/pkg/services/sqlstore/migrations/login_attempt_mig.go b/pkg/services/sqlstore/migrations/login_attempt_mig.go new file mode 100644 index 00000000000..e576ccd1a50 --- /dev/null +++ b/pkg/services/sqlstore/migrations/login_attempt_mig.go @@ -0,0 +1,23 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addLoginAttemptMigrations(mg *Migrator) { + loginAttemptV1 := Table{ + Name: "login_attempt", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "username", Type: DB_NVarchar, Length: 190, Nullable: false}, + {Name: "ip_address", Type: DB_NVarchar, Length: 30, Nullable: false}, + {Name: "created", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"username"}}, + }, + } + + // create table + mg.AddMigration("create login attempt table", NewAddTableMigration(loginAttemptV1)) + // add indices + mg.AddMigration("add index login_attempt.username", NewAddIndexMigration(loginAttemptV1, loginAttemptV1.Indices[0])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 8e9268779ef..282f98e7318 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -29,6 +29,7 @@ func AddMigrations(mg *Migrator) { addTeamMigrations(mg) addDashboardAclMigrations(mg) addTagMigration(mg) + addLoginAttemptMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index 651405921d9..064b5981063 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -19,6 +19,7 @@ type Dialect interface { LikeStr() string Default(col *Column) string BooleanStr(bool) string + DateTimeFunc(string) string CreateIndexSql(tableName string, index *Index) string CreateTableSql(table *Table) string @@ -78,6 +79,10 @@ func (b *BaseDialect) Default(col *Column) string { return col.Default } +func (db *BaseDialect) DateTimeFunc(value string) string { + return value +} + func (b *BaseDialect) CreateTableSql(table *Table) string { var sql string sql = "CREATE TABLE IF NOT EXISTS " diff --git a/pkg/services/sqlstore/migrator/sqlite_dialect.go b/pkg/services/sqlstore/migrator/sqlite_dialect.go index fe1e781c8df..1a31cee4f5e 100644 --- a/pkg/services/sqlstore/migrator/sqlite_dialect.go +++ b/pkg/services/sqlstore/migrator/sqlite_dialect.go @@ -36,6 +36,10 @@ func (db *Sqlite3) BooleanStr(value bool) string { return "0" } +func (db *Sqlite3) DateTimeFunc(value string) string { + return "datetime(" + value + ")" +} + func (db *Sqlite3) SqlType(c *Column) string { switch c.Type { case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time: diff --git a/pkg/services/sqlstore/sqlutil/sqlutil.go b/pkg/services/sqlstore/sqlutil/sqlutil.go index 4aa2ec27216..a33872ed687 100644 --- a/pkg/services/sqlstore/sqlutil/sqlutil.go +++ b/pkg/services/sqlstore/sqlutil/sqlutil.go @@ -12,7 +12,7 @@ type TestDB struct { } var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"} -var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"} +var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci&loc=Local"} var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} func CleanDB(x *xorm.Engine) { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index d236446eb71..6ce80a69957 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -75,13 +75,14 @@ var ( EnforceDomain bool // Security settings. - SecretKey string - LogInRememberDays int - CookieUserName string - CookieRememberName string - DisableGravatar bool - EmailCodeValidMinutes int - DataProxyWhiteList map[string]bool + SecretKey string + LogInRememberDays int + CookieUserName string + CookieRememberName string + DisableGravatar bool + EmailCodeValidMinutes int + DataProxyWhiteList map[string]bool + DisableBruteForceLoginProtection bool // Snapshots ExternalSnapshotUrl string @@ -514,6 +515,7 @@ func NewConfigContext(args *CommandLineArgs) error { CookieUserName = security.Key("cookie_username").String() CookieRememberName = security.Key("cookie_remember_name").String() DisableGravatar = security.Key("disable_gravatar").MustBool(true) + DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) // read snapshots settings snapshots := Cfg.Section("snapshots") From a17dbf9af82ad8e0132826f1e24e01a39b0888d1 Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 26 Jan 2018 10:50:42 +0100 Subject: [PATCH 06/55] changelog: be more explicit about backwards compatibility --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c17d684b8..5e205509ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana ha ## New Dashboard Grid -The new grid engine is major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backwards compatible. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. +The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution. Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: 24, h: 5}`. Units are in grid dimensions (24 columns, 1 height unit 30px). Rows and Panels objects exist (together) in a flat array directly on the dashboard root object. Rows are not needed for layouts anymore and are mainly there for backward compatibility. Some panel plugins that do not respect their panel height might require an update. From 0c6a90db089e7934f3430bdcedd47cae7104c460 Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 26 Jan 2018 11:16:09 +0100 Subject: [PATCH 07/55] changelog: move all 4.7 changes into 5.0 --- CHANGELOG.md | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e205509ebc..d7dd13dba98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana has ever had, coming with a ton of UX improvements, a new dashboard grid engine, dashboard folders, user teams and permissions. Checkout out this [video preview](https://www.youtube.com/watch?v=BC_YRNpqj5k) of Grafana v5. -### New Features +### New Major Features - **Dashboards** Dashboard folders, [#1611](https://github.com/grafana/grafana/issues/1611) - **Teams** User groups (teams) implemented. Can be used in folder & dashboard permission list. - **Dashboard grid**: Panels are now layed out in a two dimensional grid (with x, y, w, h). [#9093](https://github.com/grafana/grafana/issues/9093). @@ -10,6 +10,14 @@ Grafana v5.0 is going to be the biggest and most foundational release Grafana ha - **UX**: Major update to page header and navigation - **Dashboard settings**: Combine dashboard settings views into one with side menu, [#9750](https://github.com/grafana/grafana/issues/9750) +## Breaking changes + +* **[dashboard.json]** have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). +Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. +From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. + +* **Pagerduty** The notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) + ## New Dashboard Grid The new grid engine is a major upgrade for how you can position and move panels. It enables new layouts and a much easier dashboard building experience. The change is backward compatible. So you can upgrade your current version to 5.0 without breaking dashboards, but you cannot downgrade from 5.0 to previous versions. Grafana will automatically upgrade your dashboards to the new schema and position panels to match your existing layout. There might be minor differences in panel height. If you upgrade to 5.0 and for some reason want to rollback to the previous version you can restore dashboards to previous versions using dashboard history. But that should only be seen as an emergency solution. @@ -18,23 +26,6 @@ Dashboard panels and rows are positioned using a gridPos object `{x: 0, y: 0, w: ## New Features * **Alerting**: Add support for internal image store [#6922](https://github.com/grafana/grafana/issues/6922), thx [@FunkyM](https://github.com/FunkyM) - -## Minor -* **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc) -* **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda) - -# 4.7.0 (unreleased / v4.7.x branch) - -## Breaking changes - -`[dashboard.json]` have been replaced with [dashboard provisioning](http://docs.grafana.org/administration/provisioning/). - -Config files for provisioning datasources as configuration have changed from `/conf/datasources` to `/conf/provisioning/datasources`. -From `/etc/grafana/datasources` to `/etc/grafana/provisioning/datasources` when installed with deb/rpm packages. - -The pagerduty notifier now defaults to not auto resolve incidents. More details at [#10222](https://github.com/grafana/grafana/issues/10222) - -## New Features * **Data Source Proxy**: Add support for whitelisting specified cookies that will be passed through to the data source when proxying data source requests [#5457](https://github.com/grafana/grafana/issues/5457), thanks [@robingustafsson](https://github.com/robingustafsson) * **Postgres/MySQL**: add __timeGroup macro for mysql [#9596](https://github.com/grafana/grafana/pull/9596), thanks [@svenklemm](https://github.com/svenklemm) * **Text**: Text panel are now edited in the ace editor. [#9698](https://github.com/grafana/grafana/pull/9698), thx [@mtanda](https://github.com/mtanda) @@ -45,8 +36,11 @@ The pagerduty notifier now defaults to not auto resolve incidents. More details * **Dashboard as cfg**: Load dashboards from file into Grafana on startup/change [#9654](https://github.com/grafana/grafana/issues/9654) [#5269](https://github.com/grafana/grafana/issues/5269) * **Prometheus**: Grafana can now send alerts to Prometheus Alertmanager while firing [#7481](https://github.com/grafana/grafana/issues/7481), thx [@Thib17](https://github.com/Thib17) and [@mtanda](https://github.com/mtanda) * **Table**: Support multiple table formated queries in table panel [#9170](https://github.com/grafana/grafana/issues/9170), thx [@davkal](https://github.com/davkal) +* **Security**: Protect against brute force (frequent) login attempts [#7616](https://github.com/grafana/grafana/issues/7616) ## Minor +* **Graph**: Don't hide graph display options (Lines/Points) when draw mode is unchecked [#9770](https://github.com/grafana/grafana/issues/9770), thx [@Jonnymcc](https://github.com/Jonnymcc) +* **Prometheus**: Show label name in paren after by/without/on/ignoring/group_left/group_right [#9664](https://github.com/grafana/grafana/pull/9664), thx [@mtanda](https://github.com/mtanda) * **Alert panel**: Adds placeholder text when no alerts are within the time range [#9624](https://github.com/grafana/grafana/issues/9624), thx [@straend](https://github.com/straend) * **Mysql**: MySQL enable MaxOpenCon and MaxIdleCon regards how constring is configured. [#9784](https://github.com/grafana/grafana/issues/9784), thx [@dfredell](https://github.com/dfredell) * **Cloudwatch**: Fixes broken query inspector for cloudwatch [#9661](https://github.com/grafana/grafana/issues/9661), thx [@mtanda](https://github.com/mtanda) @@ -59,16 +53,15 @@ The pagerduty notifier now defaults to not auto resolve incidents. More details * **Azure**: Adds support for Azure blob storage as external image stor [#8955](https://github.com/grafana/grafana/issues/8955), thx [@saada](https://github.com/saada) * **Telegram**: Add support for inline image uploads to telegram notifier plugin [#9967](https://github.com/grafana/grafana/pull/9967), thx [@rburchell](https://github.com/rburchell) -## Tech -* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) - - ## Fixes * **Sensu**: Send alert message to sensu output [#9551](https://github.com/grafana/grafana/issues/9551), thx [@cjchand](https://github.com/cjchand) * **Singlestat**: suppress error when result contains no datapoints [#9636](https://github.com/grafana/grafana/issues/9636), thx [@utkarshcmu](https://github.com/utkarshcmu) * **Postgres/MySQL**: Control quoting in SQL-queries when using template variables [#9030](https://github.com/grafana/grafana/issues/9030), thanks [@svenklemm](https://github.com/svenklemm) * **Pagerduty**: Pagerduty dont auto resolve incidents by default anymore. [#10222](https://github.com/grafana/grafana/issues/10222) +## Tech +* **RabbitMq**: Remove support for publishing events to RabbitMQ [#9645](https://github.com/grafana/grafana/issues/9645) + # 4.6.3 (2017-12-14) ## Fixes From eefcb3080abacfd6d2a773e7591d7cd762fb04a5 Mon Sep 17 00:00:00 2001 From: hannes Date: Fri, 26 Jan 2018 14:10:17 +0100 Subject: [PATCH 08/55] fix typo in parameter. (#10613) * options.scopedVars was called without 'd', so a undefined was passed to the function convertDimensionFormat() --- public/app/plugins/datasource/cloudwatch/datasource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/cloudwatch/datasource.ts b/public/app/plugins/datasource/cloudwatch/datasource.ts index 997a84a4a99..facddd2e18e 100644 --- a/public/app/plugins/datasource/cloudwatch/datasource.ts +++ b/public/app/plugins/datasource/cloudwatch/datasource.ts @@ -36,7 +36,7 @@ export default class CloudWatchDatasource { item.region = this.templateSrv.replace(this.getActualRegion(item.region), options.scopedVars); item.namespace = this.templateSrv.replace(item.namespace, options.scopedVars); item.metricName = this.templateSrv.replace(item.metricName, options.scopedVars); - item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopeVars); + item.dimensions = this.convertDimensionFormat(item.dimensions, options.scopedVars); item.period = String(this.getPeriod(item, options)); // use string format for period in graph query, and alerting return _.extend( From d85b9c28c12cb8c18f1f595caeec65eb63c7f5e8 Mon Sep 17 00:00:00 2001 From: Leonard Gram Date: Fri, 26 Jan 2018 14:30:07 +0100 Subject: [PATCH 09/55] Locks down prometheus1 to v1.8.2 in live-test. --- docker/blocks/prometheus/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/blocks/prometheus/Dockerfile b/docker/blocks/prometheus/Dockerfile index 1ad28f524ff..2098e6527d3 100644 --- a/docker/blocks/prometheus/Dockerfile +++ b/docker/blocks/prometheus/Dockerfile @@ -1,3 +1,3 @@ -FROM prom/prometheus +FROM prom/prometheus:v1.8.2 ADD prometheus.yml /etc/prometheus/ ADD alert.rules /etc/prometheus/ From 33beacff0a255ebd7f9153b4b44e6e93ccb28017 Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 26 Jan 2018 14:34:47 +0100 Subject: [PATCH 10/55] tech: upgrade to golang 1.9.3 --- circle.yml | 2 +- scripts/build/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/circle.yml b/circle.yml index 4eb600bfde3..bf013e3f5b1 100644 --- a/circle.yml +++ b/circle.yml @@ -9,7 +9,7 @@ machine: GOPATH: "/home/ubuntu/.go_workspace" ORG_PATH: "github.com/grafana" REPO_PATH: "${ORG_PATH}/grafana" - GODIST: "go1.9.2.linux-amd64.tar.gz" + GODIST: "go1.9.3.linux-amd64.tar.gz" post: - mkdir -p ~/download - mkdir -p ~/docker diff --git a/scripts/build/Dockerfile b/scripts/build/Dockerfile index 6e48b42c8e8..8fde9eb1dcd 100644 --- a/scripts/build/Dockerfile +++ b/scripts/build/Dockerfile @@ -21,7 +21,7 @@ RUN gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A170311380 RUN curl --silent --location https://rpm.nodesource.com/setup_6.x | bash - && \ yum install -y nodejs --nogpgcheck -ENV GOLANG_VERSION 1.9.2 +ENV GOLANG_VERSION 1.9.3 RUN wget https://dl.yarnpkg.com/rpm/yarn.repo -O /etc/yum.repos.d/yarn.repo && \ yum install -y yarn --nogpgcheck && \ From 08822fbdcacb0a0461379e42150b20e09c6be357 Mon Sep 17 00:00:00 2001 From: Patrick O'Carroll Date: Fri, 26 Jan 2018 14:46:52 +0100 Subject: [PATCH 11/55] added media break for md and sm --- public/sass/components/_search.scss | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index d90943f4b60..f92f4dd1f27 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -248,6 +248,39 @@ background: $panel-bg; } +@include media-breakpoint-down(md) { + .search-dropdown { + flex-direction: column; + } + + .search-dropdown__col_1 { + height: 100%; + } + + .search-dropdown__col_2 { + max-width: 700px; + flex-direction: row; + margin: 0; + justify-content: space-between; + height: 260px; + } + + .search-filter-box { + margin: 0; + } +} + +@include media-breakpoint-down(sm) { + .search-dropdown__col_2 { + flex-direction: column; + height: 100%; + } + + .search-filter-box { + margin-bottom: 1.5rem; + } +} + @include media-breakpoint-down(xs) { .search-container { left: 0; From 2f891726c325fadde967e2ea232ee326b3e382a2 Mon Sep 17 00:00:00 2001 From: Patrick O'Carroll Date: Fri, 26 Jan 2018 14:57:55 +0100 Subject: [PATCH 12/55] fix for sm --- public/sass/components/_search.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index f92f4dd1f27..9ae277ce0a5 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -273,7 +273,8 @@ @include media-breakpoint-down(sm) { .search-dropdown__col_2 { flex-direction: column; - height: 100%; + height: 80%; + justify-content: flex-start; } .search-filter-box { From e6c19eb8e9453ca88fb5ef42f9340831a36c8024 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Fri, 26 Jan 2018 17:05:05 +0300 Subject: [PATCH 13/55] graphite: fix nested alerting queries (#10633) --- .../datasource/graphite/graphite_query.ts | 22 ++++++++- .../graphite/specs/graphite_query.jest.ts | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 public/app/plugins/datasource/graphite/specs/graphite_query.jest.ts diff --git a/public/app/plugins/datasource/graphite/graphite_query.ts b/public/app/plugins/datasource/graphite/graphite_query.ts index 10e0144e3ff..baa58237708 100644 --- a/public/app/plugins/datasource/graphite/graphite_query.ts +++ b/public/app/plugins/datasource/graphite/graphite_query.ts @@ -181,6 +181,22 @@ export default class GraphiteQuery { var nestedSeriesRefRegex = /\#([A-Z])/g; var targetWithNestedQueries = target.target; + // Use ref count to track circular references + function countTargetRefs(targetsByRefId, refId) { + let refCount = 0; + _.each(targetsByRefId, (t, id) => { + if (id !== refId) { + let match = nestedSeriesRefRegex.exec(t.target); + let count = match && match.length ? match.length - 1 : 0; + refCount += count; + } + }); + targetsByRefId[refId].refCount = refCount; + } + _.each(targetsByRefId, (t, id) => { + countTargetRefs(targetsByRefId, id); + }); + // Keep interpolating until there are no query references // The reason for the loop is that the referenced query might contain another reference to another query while (targetWithNestedQueries.match(nestedSeriesRefRegex)) { @@ -191,7 +207,11 @@ export default class GraphiteQuery { } // no circular references - delete targetsByRefId[g1]; + if (t.refCount === 0) { + delete targetsByRefId[g1]; + } + t.refCount--; + return t.target; }); diff --git a/public/app/plugins/datasource/graphite/specs/graphite_query.jest.ts b/public/app/plugins/datasource/graphite/specs/graphite_query.jest.ts new file mode 100644 index 00000000000..d54caae05f8 --- /dev/null +++ b/public/app/plugins/datasource/graphite/specs/graphite_query.jest.ts @@ -0,0 +1,47 @@ +import gfunc from '../gfunc'; +import GraphiteQuery from '../graphite_query'; + +describe('Graphite query model', () => { + let ctx: any = { + datasource: { + getFuncDef: gfunc.getFuncDef, + getFuncDefs: jest.fn().mockReturnValue(Promise.resolve(gfunc.getFuncDefs('1.0'))), + waitForFuncDefsLoaded: jest.fn().mockReturnValue(Promise.resolve(null)), + createFuncInstance: gfunc.createFuncInstance, + }, + templateSrv: {}, + targets: [], + }; + + beforeEach(() => { + ctx.target = { refId: 'A', target: 'scaleToSeconds(#A, 60)' }; + ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv); + }); + + describe('when updating targets with nested queries', () => { + beforeEach(() => { + ctx.target = { refId: 'D', target: 'asPercent(#A, #C)' }; + ctx.targets = [ + { refId: 'A', target: 'first.query.count' }, + { refId: 'B', target: 'second.query.count' }, + { refId: 'C', target: 'diffSeries(#A, #B)' }, + { refId: 'D', target: 'asPercent(#A, #C)' }, + ]; + ctx.queryModel = new GraphiteQuery(ctx.datasource, ctx.target, ctx.templateSrv); + }); + + it('targetFull should include nested queries', () => { + ctx.queryModel.updateRenderedTarget(ctx.target, ctx.targets); + const targetFullExpected = 'asPercent(first.query.count, diffSeries(first.query.count, second.query.count))'; + expect(ctx.queryModel.target.targetFull).toBe(targetFullExpected); + }); + + it('should not hang on circular references', () => { + ctx.target.target = 'asPercent(#A, #B)'; + ctx.targets = [{ refId: 'A', target: 'asPercent(#B, #C)' }, { refId: 'B', target: 'asPercent(#A, #C)' }]; + ctx.queryModel.updateRenderedTarget(ctx.target, ctx.targets); + // Just ensure updateRenderedTarget() is completed and doesn't hang + expect(ctx.queryModel.target.targetFull).toBeDefined(); + }); + }); +}); From ffff75b01a20ee8692c3462334fd814a89d8405b Mon Sep 17 00:00:00 2001 From: Patrick O'Carroll Date: Fri, 26 Jan 2018 16:59:16 +0100 Subject: [PATCH 14/55] reverted media queries --- public/sass/components/_search.scss | 63 +++++++++++++---------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index 9ae277ce0a5..98616571ed8 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -10,7 +10,7 @@ } .search-container { - left: $side-menu-width; + left: 0; top: 0; right: 0; bottom: 0; @@ -58,7 +58,7 @@ .search-dropdown { display: flex; - flex-direction: row; + flex-direction: column; height: calc(100% - #{$navbarHeight}); } @@ -74,7 +74,7 @@ flex-grow: 1; height: 100%; padding-top: 16px; - display: flex; + display: none; flex-direction: column; align-items: flex-start; } @@ -85,7 +85,6 @@ padding: $spacer*1.5; min-width: 340px; margin-bottom: $spacer * 1.5; - margin-left: $spacer * 1.5; } .search-filter-box__header { @@ -215,7 +214,8 @@ } .search-item__tags { - padding: 10px; + display: none; + //padding: 10px; } .search-item__actions { @@ -248,50 +248,45 @@ background: $panel-bg; } -@include media-breakpoint-down(md) { - .search-dropdown { - flex-direction: column; +@include media-breakpoint-up(sm) { + .search-container { + left: $side-menu-width; + } + + .search-dropdown__col_2 { + display: flex; + margin-bottom: 1rem; + } +} + +@include media-breakpoint-up(md) { + .search-dropdown__col_2 { + flex-direction: row; + justify-content: space-between; + max-width: 700px; + height: 260px; } .search-dropdown__col_1 { height: 100%; } - .search-dropdown__col_2 { - max-width: 700px; - flex-direction: row; - margin: 0; - justify-content: space-between; - height: 260px; - } - .search-filter-box { margin: 0; } } -@include media-breakpoint-down(sm) { +@include media-breakpoint-up(lg) { + .search-dropdown { + flex-direction: row; + } + .search-dropdown__col_2 { flex-direction: column; - height: 80%; - justify-content: flex-start; } .search-filter-box { - margin-bottom: 1.5rem; - } -} - -@include media-breakpoint-down(xs) { - .search-container { - left: 0; - } - - .search-dropdown__col_2 { - display: none; - } - - .search-item__tags { - display: none; + margin-left: $spacer * 1.5; + margin-bottom: $spacer * 1.5; } } From 5f81f401e36a9a87a61454a83d930a71ca1953cd Mon Sep 17 00:00:00 2001 From: Patrick O'Carroll Date: Fri, 26 Jan 2018 17:25:12 +0100 Subject: [PATCH 15/55] replaced input with gf-form-dropdown --- .../plugins/panel/table/column_options.html | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/public/app/plugins/panel/table/column_options.html b/public/app/plugins/panel/table/column_options.html index 996f10960f9..c5c7b8dbab4 100644 --- a/public/app/plugins/panel/table/column_options.html +++ b/public/app/plugins/panel/table/column_options.html @@ -1,17 +1,16 @@ -
-
@@ -20,7 +19,9 @@
- +
@@ -39,18 +40,20 @@
-
+
-
- -
+ +
- +
- +
@@ -60,16 +63,20 @@
- +
-
+
Thresholds
- - + +
@@ -102,21 +109,23 @@

Specify an URL (relative or absolute)

- Use special variables to specify cell values:
- $__cell refers to current cell value
+ Use special variables to specify cell values: +
+ $__cell refers to current cell value +
$__cell_n refers to Nth column value in current row. Column indexes are started from 0. For instance, - $__cell_1 refers to second column's value. + $__cell_1 refers to second column's value.
- +

Specify text for link tooltip.

- This title appears when user hovers pointer over the cell with link. - Use the same variables as for URL. + This title appears when user hovers pointer over the cell with link. Use the same variables as for URL.
From cd2161e7966fd05daed843ee5e45cac78b335d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 29 Jan 2018 08:38:23 +0100 Subject: [PATCH 16/55] Revert "Fix typeahead to avoid generating new backend request on each keypress. (#10596)" This reverts commit 475febd0048dcd5fc1a161556f0e909efec33405. --- public/app/core/components/query_part/query_part_editor.ts | 4 ++-- public/app/core/directives/metric_segment.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/app/core/components/query_part/query_part_editor.ts b/public/app/core/components/query_part/query_part_editor.ts index c78ca8d74e6..138da186238 100644 --- a/public/app/core/components/query_part/query_part_editor.ts +++ b/public/app/core/components/query_part/query_part_editor.ts @@ -123,11 +123,11 @@ export function queryPartEditorDirective($compile, templateSrv) { }); var typeahead = $input.data('typeahead'); - typeahead.lookup = _.debounce(function() { + typeahead.lookup = function() { this.query = this.$element.val() || ''; var items = this.source(this.query, $.proxy(this.process, this)); return items ? this.process(items) : items; - }, 500); + }; } $scope.showActionsMenu = function() { diff --git a/public/app/core/directives/metric_segment.js b/public/app/core/directives/metric_segment.js index b11625227c7..2754f8d8c6e 100644 --- a/public/app/core/directives/metric_segment.js +++ b/public/app/core/directives/metric_segment.js @@ -129,11 +129,11 @@ function (_, $, coreModule) { $input.typeahead({ source: $scope.source, minLength: 0, items: 10000, updater: $scope.updater, matcher: $scope.matcher }); var typeahead = $input.data('typeahead'); - typeahead.lookup = _.debounce(function() { + typeahead.lookup = function () { this.query = this.$element.val() || ''; var items = this.source(this.query, $.proxy(this.process, this)); return items ? this.process(items) : items; - }, 500); + }; $button.keydown(function(evt) { // trigger typeahead on down arrow or enter key From 2782ad09553d79705152361e72074a8cd3df8182 Mon Sep 17 00:00:00 2001 From: bergquist Date: Mon, 29 Jan 2018 09:52:35 +0100 Subject: [PATCH 17/55] plugins: only set error if errorstring is not empty --- .../datasource/wrapper/datasource_plugin_wrapper.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go index 91511f96685..f9c9f9d3b16 100644 --- a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper.go @@ -69,10 +69,13 @@ func (tw *DatasourcePluginWrapper) Query(ctx context.Context, ds *models.DataSou for _, r := range pbres.Results { qr := &tsdb.QueryResult{ - RefId: r.RefId, - Series: []*tsdb.TimeSeries{}, - Error: errors.New(r.Error), - ErrorString: r.Error, + RefId: r.RefId, + Series: []*tsdb.TimeSeries{}, + } + + if r.Error != "" { + qr.Error = errors.New(r.Error) + qr.ErrorString = r.Error } for _, s := range r.GetSeries() { From b6ce16ebae60448930f8a9b72a0573da203dc5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 29 Jan 2018 10:41:06 +0100 Subject: [PATCH 18/55] ux: minor tweak of #10634 --- public/sass/components/_search.scss | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index 98616571ed8..700a4d878e2 100644 --- a/public/sass/components/_search.scss +++ b/public/sass/components/_search.scss @@ -38,13 +38,6 @@ background-color: $navbarButtonBackground; flex-grow: 10; } - - // .tag-filter { - // .Select-control { - // width: 300px; - // background-color: $navbarBackground; - // } - // } } .search-field-spacer { @@ -76,7 +69,6 @@ padding-top: 16px; display: none; flex-direction: column; - align-items: flex-start; } .search-filter-box { @@ -265,6 +257,7 @@ justify-content: space-between; max-width: 700px; height: 260px; + align-items: flex-start; } .search-dropdown__col_1 { From a0323e96fac3b6fdfb5298c52cfdd61a972fb8af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 29 Jan 2018 10:46:05 +0100 Subject: [PATCH 19/55] fix: tweak of PR #10635 --- public/app/plugins/panel/table/column_options.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/plugins/panel/table/column_options.html b/public/app/plugins/panel/table/column_options.html index c5c7b8dbab4..bebc05f0e53 100644 --- a/public/app/plugins/panel/table/column_options.html +++ b/public/app/plugins/panel/table/column_options.html @@ -42,8 +42,8 @@
- +
From b3ac85766ee345ade473db5c2dae3662afbb4585 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 29 Jan 2018 15:38:37 +0300 Subject: [PATCH 20/55] fix: show sidebar after mouse wheel scrolling (#10657) --- public/app/core/components/grafana_app.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index c00b7b2181b..13c7f48de3e 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -167,6 +167,8 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop // mouse and keyboard is user activity body.mousemove(userActivityDetected); body.keydown(userActivityDetected); + // set useCapture = true to catch event here + document.addEventListener('wheel', userActivityDetected, true); // treat tab change as activity document.addEventListener('visibilitychange', userActivityDetected); From 04053ec56cae4948fc0e4d24b2dcfbdc574fd837 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 29 Jan 2018 16:33:41 +0300 Subject: [PATCH 21/55] fix: don't show manually hidden sidemenu after view mode toggle (#10659) --- public/app/core/components/grafana_app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 13c7f48de3e..31b6f5b8096 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -71,6 +71,7 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop body.toggleClass('sidemenu-open', sidemenuOpen); appEvents.on('toggle-sidemenu', () => { + sidemenuOpen = scope.contextSrv.sidemenu; body.toggleClass('sidemenu-open'); }); From 479658489ad7a215ee8f06f861b3a0374534c682 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 29 Jan 2018 17:17:27 +0300 Subject: [PATCH 22/55] fix: remove repeated rows when repeat was disabled. (#10653) --- public/app/features/dashboard/dashgrid/DashboardRow.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard/dashgrid/DashboardRow.tsx b/public/app/features/dashboard/dashgrid/DashboardRow.tsx index fad7c120f65..7a4d6cf8070 100644 --- a/public/app/features/dashboard/dashgrid/DashboardRow.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardRow.tsx @@ -27,6 +27,7 @@ export class DashboardRow extends React.Component { this.toggle = this.toggle.bind(this); this.openSettings = this.openSettings.bind(this); this.delete = this.delete.bind(this); + this.update = this.update.bind(this); } toggle() { @@ -37,13 +38,18 @@ export class DashboardRow extends React.Component { }); } + update() { + this.dashboard.processRepeats(); + this.forceUpdate(); + } + openSettings() { appEvents.emit('show-modal', { templateHtml: ``, modalClass: 'modal--narrow', model: { row: this.props.panel, - onUpdated: this.forceUpdate.bind(this), + onUpdated: this.update.bind(this), }, }); } From b1cf4cf01c291ffb4ed8d19eec54ba844714afb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 29 Jan 2018 17:04:11 +0100 Subject: [PATCH 23/55] fix: InfluxDB Query Editor and selecting template variable in where clause caused issue, fixes #10402, fixes #10663 --- public/app/core/services/segment_srv.js | 2 +- public/app/plugins/datasource/influxdb/query_ctrl.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/core/services/segment_srv.js b/public/app/core/services/segment_srv.js index 6c126da1acd..71d0cbfe7a9 100644 --- a/public/app/core/services/segment_srv.js +++ b/public/app/core/services/segment_srv.js @@ -89,7 +89,7 @@ function (angular, _, coreModule) { if (addTemplateVars) { _.each(templateSrv.variables, function(variable) { if (variableTypeFilter === void 0 || variableTypeFilter === variable.type) { - segments.unshift(self.newSegment({ type: 'template', value: '$' + variable.name, expandable: true })); + segments.unshift(self.newSegment({ type: 'value', value: '$' + variable.name, expandable: true })); } }); } diff --git a/public/app/plugins/datasource/influxdb/query_ctrl.ts b/public/app/plugins/datasource/influxdb/query_ctrl.ts index c67b65334af..ce669c9f458 100644 --- a/public/app/plugins/datasource/influxdb/query_ctrl.ts +++ b/public/app/plugins/datasource/influxdb/query_ctrl.ts @@ -255,7 +255,7 @@ export class InfluxQueryCtrl extends QueryCtrl { for (let variable of this.templateSrv.variables) { segments.unshift( this.uiSegmentSrv.newSegment({ - type: 'template', + type: 'value', value: '/^$' + variable.name + '$/', expandable: true, }) From 0573545d5a4a12cd32917eb199990d401b2ea561 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Mon, 29 Jan 2018 17:06:16 +0100 Subject: [PATCH 24/55] ui: Fix Firefox align issue in dropdowns #10527 (#10662) --- public/sass/components/_dropdown.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/public/sass/components/_dropdown.scss b/public/sass/components/_dropdown.scss index 1bdda9a0b6e..d91a6d0a5a7 100644 --- a/public/sass/components/_dropdown.scss +++ b/public/sass/components/_dropdown.scss @@ -33,7 +33,7 @@ border-top: 4px solid $text-color-weak; border-right: 4px solid transparent; border-left: 4px solid transparent; - content: ""; + content: ''; } // Place the caret @@ -218,7 +218,7 @@ .caret { border-top: 0; border-bottom: 4px solid $black; - content: ""; + content: ''; } // Different positioning for bottom up menu .dropdown-menu { @@ -255,9 +255,9 @@ } // Caret to indicate there is a submenu -.dropdown-submenu > a::after { +.dropdown-submenu > a::before { display: block; - content: " "; + content: ' '; float: right; width: 0; height: 0; @@ -312,7 +312,7 @@ width: 2rem; display: inline-block; text-align: center; - content: "\f11c"; + content: '\f11c'; } } From 1c95da8f23f409b467c41db5a6365889b0a4bb42 Mon Sep 17 00:00:00 2001 From: ilgizar Date: Tue, 30 Jan 2018 09:52:30 +0500 Subject: [PATCH 25/55] Fix horizontal panel repeat. Fix #10672. --- .../app/features/dashboard/dashboard_model.ts | 19 ++-- .../features/dashboard/specs/repeat.jest.ts | 92 +++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 6c291c3b69a..eba4cfa398f 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -342,6 +342,7 @@ export class DashboardModel { let minWidth = panel.minSpan || 6; let xPos = 0; let yPos = panel.gridPos.y; + let yOffset = 0; for (let index = 0; index < selectedOptions.length; index++) { let option = selectedOptions[index]; @@ -354,13 +355,8 @@ export class DashboardModel { if (panel.repeatDirection === REPEAT_DIR_VERTICAL) { copy.gridPos.y = yPos; yPos += copy.gridPos.h; - - // Update gridPos for panels below - let panelBelowIndex = panelIndex + index + 1; - for (let i = panelBelowIndex; i < this.panels.length; i++) { - if (this.panels[i].gridPos.y < yPos) { - this.panels[i].gridPos.y += copy.gridPos.h; - } + if (index > 0) { + yOffset += copy.gridPos.h; } } else { // set width based on how many are selected @@ -375,9 +371,18 @@ export class DashboardModel { if (xPos + copy.gridPos.w > GRID_COLUMN_COUNT) { xPos = 0; yPos += copy.gridPos.h; + yOffset += copy.gridPos.h; } } } + + // Update gridPos for panels below + if (yOffset > 0) { + let panelBelowIndex = panelIndex + selectedOptions.length; + for (let i = panelBelowIndex; i < this.panels.length; i++) { + this.panels[i].gridPos.y += yOffset; + } + } } repeatRow(panel: PanelModel, panelIndex: number, variable) { diff --git a/public/app/features/dashboard/specs/repeat.jest.ts b/public/app/features/dashboard/specs/repeat.jest.ts index e05162dc69f..ec3a1fbb7af 100644 --- a/public/app/features/dashboard/specs/repeat.jest.ts +++ b/public/app/features/dashboard/specs/repeat.jest.ts @@ -178,6 +178,98 @@ describe('given dashboard with panel repeat in vertical direction', function() { }); }); +describe('given dashboard with row repeat and panel repeat in horizontal direction', () => { + let dashboard, dashboardJSON; + + beforeEach(() => { + dashboardJSON = { + panels: [ + { + id: 1, + type: 'row', + repeat: 'region', + gridPos: { x: 0, y: 0, h: 1, w: 24 }, + }, + { + id: 2, + type: 'graph', + repeat: 'app', + gridPos: { x: 0, y: 1, h: 2, w: 6 }, + }, + ], + templating: { + list: [ + { + name: 'region', + current: { + text: 'reg1, reg2', + value: ['reg1', 'reg2'], + }, + options: [{ text: 'reg1', value: 'reg1', selected: true }, { text: 'reg2', value: 'reg2', selected: true }], + }, + { + name: 'app', + current: { + text: 'se1, se2, se3, se4, se5, se6', + value: ['se1', 'se2', 'se3', 'se4', 'se5', 'se6'], + }, + options: [ + { text: 'se1', value: 'se1', selected: true }, + { text: 'se2', value: 'se2', selected: true }, + { text: 'se3', value: 'se3', selected: true }, + { text: 'se4', value: 'se4', selected: true }, + { text: 'se5', value: 'se5', selected: true }, + { text: 'se6', value: 'se6', selected: true }, + ], + }, + ], + }, + }; + dashboard = new DashboardModel(dashboardJSON); + dashboard.processRepeats(false); + }); + + it('should panels in self row', () => { + const panel_types = _.map(dashboard.panels, 'type'); + expect(panel_types).toEqual([ + 'row', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'row', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + 'graph', + ]); + }); + + it('should be placed in their places', function() { + expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); + + expect(dashboard.panels[1].gridPos).toMatchObject({ x: 0, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[2].gridPos).toMatchObject({ x: 6, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[3].gridPos).toMatchObject({ x: 12, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[4].gridPos).toMatchObject({ x: 18, y: 1, h: 2, w: 6 }); + expect(dashboard.panels[5].gridPos).toMatchObject({ x: 0, y: 3, h: 2, w: 6 }); + expect(dashboard.panels[6].gridPos).toMatchObject({ x: 6, y: 3, h: 2, w: 6 }); + + expect(dashboard.panels[7].gridPos).toMatchObject({ x: 0, y: 5, h: 1, w: 24 }); + + expect(dashboard.panels[8].gridPos).toMatchObject({ x: 0, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[9].gridPos).toMatchObject({ x: 6, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[10].gridPos).toMatchObject({ x: 12, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[11].gridPos).toMatchObject({ x: 18, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[12].gridPos).toMatchObject({ x: 0, y: 8, h: 2, w: 6 }); + expect(dashboard.panels[13].gridPos).toMatchObject({ x: 6, y: 8, h: 2, w: 6 }); + }); +}); + describe('given dashboard with row repeat', function() { let dashboard, dashboardJSON; From 526960058a7b7aa661694759de9d081ac14fe0dd Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Tue, 30 Jan 2018 14:19:10 +0300 Subject: [PATCH 26/55] repeat panel: minor refactor --- .../app/features/dashboard/dashboard_model.ts | 8 +++---- .../features/dashboard/specs/repeat.jest.ts | 22 +++++-------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index eba4cfa398f..dccd910a85e 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -342,7 +342,6 @@ export class DashboardModel { let minWidth = panel.minSpan || 6; let xPos = 0; let yPos = panel.gridPos.y; - let yOffset = 0; for (let index = 0; index < selectedOptions.length; index++) { let option = selectedOptions[index]; @@ -353,11 +352,10 @@ export class DashboardModel { copy.scopedVars[variable.name] = option; if (panel.repeatDirection === REPEAT_DIR_VERTICAL) { - copy.gridPos.y = yPos; - yPos += copy.gridPos.h; if (index > 0) { - yOffset += copy.gridPos.h; + yPos += copy.gridPos.h; } + copy.gridPos.y = yPos; } else { // set width based on how many are selected // assumed the repeated panels should take up full row width @@ -371,12 +369,12 @@ export class DashboardModel { if (xPos + copy.gridPos.w > GRID_COLUMN_COUNT) { xPos = 0; yPos += copy.gridPos.h; - yOffset += copy.gridPos.h; } } } // Update gridPos for panels below + let yOffset = yPos - panel.gridPos.y; if (yOffset > 0) { let panelBelowIndex = panelIndex + selectedOptions.length; for (let i = panelBelowIndex; i < this.panels.length; i++) { diff --git a/public/app/features/dashboard/specs/repeat.jest.ts b/public/app/features/dashboard/specs/repeat.jest.ts index ec3a1fbb7af..77b658e7780 100644 --- a/public/app/features/dashboard/specs/repeat.jest.ts +++ b/public/app/features/dashboard/specs/repeat.jest.ts @@ -184,18 +184,8 @@ describe('given dashboard with row repeat and panel repeat in horizontal directi beforeEach(() => { dashboardJSON = { panels: [ - { - id: 1, - type: 'row', - repeat: 'region', - gridPos: { x: 0, y: 0, h: 1, w: 24 }, - }, - { - id: 2, - type: 'graph', - repeat: 'app', - gridPos: { x: 0, y: 1, h: 2, w: 6 }, - }, + { id: 1, type: 'row', repeat: 'region', gridPos: { x: 0, y: 0, h: 1, w: 24 } }, + { id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 2, w: 6 } }, ], templating: { list: [ @@ -250,21 +240,21 @@ describe('given dashboard with row repeat and panel repeat in horizontal directi }); it('should be placed in their places', function() { - expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); + expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // 1st row expect(dashboard.panels[1].gridPos).toMatchObject({ x: 0, y: 1, h: 2, w: 6 }); expect(dashboard.panels[2].gridPos).toMatchObject({ x: 6, y: 1, h: 2, w: 6 }); expect(dashboard.panels[3].gridPos).toMatchObject({ x: 12, y: 1, h: 2, w: 6 }); expect(dashboard.panels[4].gridPos).toMatchObject({ x: 18, y: 1, h: 2, w: 6 }); - expect(dashboard.panels[5].gridPos).toMatchObject({ x: 0, y: 3, h: 2, w: 6 }); + expect(dashboard.panels[5].gridPos).toMatchObject({ x: 0, y: 3, h: 2, w: 6 }); // next row expect(dashboard.panels[6].gridPos).toMatchObject({ x: 6, y: 3, h: 2, w: 6 }); expect(dashboard.panels[7].gridPos).toMatchObject({ x: 0, y: 5, h: 1, w: 24 }); - expect(dashboard.panels[8].gridPos).toMatchObject({ x: 0, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[8].gridPos).toMatchObject({ x: 0, y: 6, h: 2, w: 6 }); // 2nd row expect(dashboard.panels[9].gridPos).toMatchObject({ x: 6, y: 6, h: 2, w: 6 }); expect(dashboard.panels[10].gridPos).toMatchObject({ x: 12, y: 6, h: 2, w: 6 }); - expect(dashboard.panels[11].gridPos).toMatchObject({ x: 18, y: 6, h: 2, w: 6 }); + expect(dashboard.panels[11].gridPos).toMatchObject({ x: 18, y: 6, h: 2, w: 6 }); // next row expect(dashboard.panels[12].gridPos).toMatchObject({ x: 0, y: 8, h: 2, w: 6 }); expect(dashboard.panels[13].gridPos).toMatchObject({ x: 6, y: 8, h: 2, w: 6 }); }); From a00a42d9ee2ad611193a766b8dda7ef6c782491d Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 30 Jan 2018 13:17:48 +0100 Subject: [PATCH 27/55] api: extract api test code to common_test.go --- pkg/api/annotations_test.go | 33 +---------- pkg/api/common_test.go | 105 ++++++++++++++++++++++++++++++++++ pkg/api/dashboard.go | 1 - pkg/api/dashboard_acl_test.go | 17 +----- pkg/api/dashboard_test.go | 19 +----- pkg/api/datasources_test.go | 91 ----------------------------- 6 files changed, 109 insertions(+), 157 deletions(-) create mode 100644 pkg/api/common_test.go diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 6b8cd443298..480962d8826 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -1,16 +1,13 @@ package api import ( - "path/filepath" "testing" - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/api/dtos" "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/annotations" - macaron "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" ) @@ -202,20 +199,7 @@ func postAnnotationScenario(desc string, url string, routePattern string, role m Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() - sc := &scenarioContext{ - url: url, - } - viewsPath, _ := filepath.Abs("../../public/views") - - sc.m = macaron.New() - sc.m.Use(macaron.Renderer(macaron.RenderOptions{ - Directory: viewsPath, - Delims: macaron.Delims{Left: "[[", Right: "]]"}, - })) - - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) - + sc := setupScenarioContext(url) sc.defaultHandler = wrap(func(c *middleware.Context) Response { sc.context = c sc.context.UserId = TestUserID @@ -238,20 +222,7 @@ func putAnnotationScenario(desc string, url string, routePattern string, role m. Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() - sc := &scenarioContext{ - url: url, - } - viewsPath, _ := filepath.Abs("../../public/views") - - sc.m = macaron.New() - sc.m.Use(macaron.Renderer(macaron.RenderOptions{ - Directory: viewsPath, - Delims: macaron.Delims{Left: "[[", Right: "]]"}, - })) - - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) - + sc := setupScenarioContext(url) sc.defaultHandler = wrap(func(c *middleware.Context) Response { sc.context = c sc.context.UserId = TestUserID diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go new file mode 100644 index 00000000000..33fc1688603 --- /dev/null +++ b/pkg/api/common_test.go @@ -0,0 +1,105 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + + "github.com/go-macaron/session" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + macaron "gopkg.in/macaron.v1" + + . "github.com/smartystreets/goconvey/convey" +) + +func loggedInUserScenario(desc string, url string, fn scenarioFunc) { + loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn) +} + +func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + if sc.handlerFunc != nil { + return sc.handlerFunc(sc.context) + } + + return nil + }) + + switch method { + case "GET": + sc.m.Get(routePattern, sc.defaultHandler) + case "DELETE": + sc.m.Delete(routePattern, sc.defaultHandler) + } + + fn(sc) + }) +} + +func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + So(err, ShouldBeNil) + sc.req = req + + return sc +} + +func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { + sc.resp = httptest.NewRecorder() + req, err := http.NewRequest(method, url, nil) + q := req.URL.Query() + for k, v := range queryParams { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + So(err, ShouldBeNil) + sc.req = req + + return sc +} + +type scenarioContext struct { + m *macaron.Macaron + context *middleware.Context + resp *httptest.ResponseRecorder + handlerFunc handlerFunc + defaultHandler macaron.Handler + req *http.Request + url string +} + +func (sc *scenarioContext) exec() { + sc.m.ServeHTTP(sc.resp, sc.req) +} + +type scenarioFunc func(c *scenarioContext) +type handlerFunc func(c *middleware.Context) Response + +func setupScenarioContext(url string) *scenarioContext { + sc := &scenarioContext{ + url: url, + } + viewsPath, _ := filepath.Abs("../../public/views") + + sc.m = macaron.New() + sc.m.Use(macaron.Renderer(macaron.RenderOptions{ + Directory: viewsPath, + Delims: macaron.Delims{Left: "[[", Right: "]]"}, + })) + + sc.m.Use(middleware.GetContextHandler()) + sc.m.Use(middleware.Sessioner(&session.Options{})) + + return sc +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index cf9f6b6bb8b..206b7f890b5 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -51,7 +51,6 @@ func GetDashboard(c *middleware.Context) Response { guardian := guardian.NewDashboardGuardian(dash.Id, c.OrgId, c.SignedInUser) if canView, err := guardian.CanView(); err != nil || !canView { - fmt.Printf("%v", err) return dashboardGuardianResponse(err) } diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go index c617a70cc0d..4187f93570e 100644 --- a/pkg/api/dashboard_acl_test.go +++ b/pkg/api/dashboard_acl_test.go @@ -1,16 +1,13 @@ package api import ( - "path/filepath" "testing" - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/middleware" m "github.com/grafana/grafana/pkg/models" - macaron "gopkg.in/macaron.v1" . "github.com/smartystreets/goconvey/convey" ) @@ -236,19 +233,7 @@ func postAclScenario(desc string, url string, routePattern string, role m.RoleTy Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() - sc := &scenarioContext{ - url: url, - } - viewsPath, _ := filepath.Abs("../../public/views") - - sc.m = macaron.New() - sc.m.Use(macaron.Renderer(macaron.RenderOptions{ - Directory: viewsPath, - Delims: macaron.Delims{Left: "[[", Right: "]]"}, - })) - - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) + sc := setupScenarioContext(url) sc.defaultHandler = wrap(func(c *middleware.Context) Response { sc.context = c diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index e6228878625..35a2fc0f22a 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -2,12 +2,8 @@ package api import ( "encoding/json" - "path/filepath" "testing" - macaron "gopkg.in/macaron.v1" - - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" @@ -488,20 +484,7 @@ func postDashboardScenario(desc string, url string, routePattern string, role m. Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() - sc := &scenarioContext{ - url: url, - } - viewsPath, _ := filepath.Abs("../../public/views") - - sc.m = macaron.New() - sc.m.Use(macaron.Renderer(macaron.RenderOptions{ - Directory: viewsPath, - Delims: macaron.Delims{Left: "[[", Right: "]]"}, - })) - - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) - + sc := setupScenarioContext(url) sc.defaultHandler = wrap(func(c *middleware.Context) Response { sc.context = c sc.context.UserId = TestUserID diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 72336693363..490393727d6 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -2,17 +2,11 @@ package api import ( "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" "testing" "github.com/grafana/grafana/pkg/models" - macaron "gopkg.in/macaron.v1" - "github.com/go-macaron/session" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/middleware" . "github.com/smartystreets/goconvey/convey" ) @@ -54,88 +48,3 @@ func TestDataSourcesProxy(t *testing.T) { }) }) } - -func loggedInUserScenario(desc string, url string, fn scenarioFunc) { - loggedInUserScenarioWithRole(desc, "GET", url, url, models.ROLE_EDITOR, fn) -} - -func loggedInUserScenarioWithRole(desc string, method string, url string, routePattern string, role models.RoleType, fn scenarioFunc) { - Convey(desc+" "+url, func() { - defer bus.ClearBusHandlers() - - sc := &scenarioContext{ - url: url, - } - viewsPath, _ := filepath.Abs("../../public/views") - - sc.m = macaron.New() - sc.m.Use(macaron.Renderer(macaron.RenderOptions{ - Directory: viewsPath, - Delims: macaron.Delims{Left: "[[", Right: "]]"}, - })) - - sc.m.Use(middleware.GetContextHandler()) - sc.m.Use(middleware.Sessioner(&session.Options{})) - - sc.defaultHandler = wrap(func(c *middleware.Context) Response { - sc.context = c - sc.context.UserId = TestUserID - sc.context.OrgId = TestOrgID - sc.context.OrgRole = role - if sc.handlerFunc != nil { - return sc.handlerFunc(sc.context) - } - - return nil - }) - - switch method { - case "GET": - sc.m.Get(routePattern, sc.defaultHandler) - case "DELETE": - sc.m.Delete(routePattern, sc.defaultHandler) - } - - fn(sc) - }) -} - -func (sc *scenarioContext) fakeReq(method, url string) *scenarioContext { - sc.resp = httptest.NewRecorder() - req, err := http.NewRequest(method, url, nil) - So(err, ShouldBeNil) - sc.req = req - - return sc -} - -func (sc *scenarioContext) fakeReqWithParams(method, url string, queryParams map[string]string) *scenarioContext { - sc.resp = httptest.NewRecorder() - req, err := http.NewRequest(method, url, nil) - q := req.URL.Query() - for k, v := range queryParams { - q.Add(k, v) - } - req.URL.RawQuery = q.Encode() - So(err, ShouldBeNil) - sc.req = req - - return sc -} - -type scenarioContext struct { - m *macaron.Macaron - context *middleware.Context - resp *httptest.ResponseRecorder - handlerFunc handlerFunc - defaultHandler macaron.Handler - req *http.Request - url string -} - -func (sc *scenarioContext) exec() { - sc.m.ServeHTTP(sc.resp, sc.req) -} - -type scenarioFunc func(c *scenarioContext) -type handlerFunc func(c *middleware.Context) Response From 2b7984355a47d2130211f78fd9bd71ea99eeef78 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 30 Jan 2018 13:28:00 +0100 Subject: [PATCH 28/55] dashfolders: adds comment for dashboard acl test --- pkg/api/dashboard_acl_test.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/pkg/api/dashboard_acl_test.go b/pkg/api/dashboard_acl_test.go index 4187f93570e..467045e360a 100644 --- a/pkg/api/dashboard_acl_test.go +++ b/pkg/api/dashboard_acl_test.go @@ -39,6 +39,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) { return nil }) + // This tests four scenarios: + // 1. user is an org admin + // 2. user is an org editor AND has been granted admin permission for the dashboard + // 3. user is an org viewer AND has been granted edit permission for the dashboard + // 4. user is an org editor AND has no permissions for the dashboard + Convey("When user is org admin", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_ADMIN, func(sc *scenarioContext) { Convey("Should be able to access ACL", func() { @@ -56,7 +62,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and has admin permission in the ACL", func() { + Convey("When user is org editor and has admin permission in the ACL", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 6, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_ADMIN}) @@ -147,11 +153,12 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and has edit permission in the ACL", func() { - loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { + Convey("When user is org viewer and has edit permission in the ACL", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardId/acl", m.ROLE_VIEWER, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) - Convey("Should not be able to access ACL", func() { + // Getting the permissions is an Admin permission + Convey("Should not be able to get list of permissions from ACL", func() { sc.handlerFunc = GetDashboardAclList sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() @@ -159,7 +166,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_EDITOR, func(sc *scenarioContext) { + loggedInUserScenarioWithRole("When calling DELETE on", "DELETE", "/api/dashboards/id/1/acl/1", "/api/dashboards/id/:dashboardId/acl/:aclId", m.ROLE_VIEWER, func(sc *scenarioContext) { mockResult = append(mockResult, &m.DashboardAclInfoDTO{Id: 1, OrgId: 1, DashboardId: 1, UserId: 1, Permission: m.PERMISSION_EDIT}) bus.AddHandler("test3", func(cmd *m.RemoveDashboardAclCommand) error { @@ -175,7 +182,7 @@ func TestDashboardAclApiEndpoint(t *testing.T) { }) }) - Convey("When user is editor and not in the ACL", func() { + Convey("When user is org editor and not in the ACL", func() { loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/acl", "/api/dashboards/id/:dashboardsId/acl", m.ROLE_EDITOR, func(sc *scenarioContext) { Convey("Should not be able to access ACL", func() { From b4aa92e7ff825aae1be37f12ea66353ef6a80d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 30 Jan 2018 13:27:55 +0100 Subject: [PATCH 29/55] docs: added versions file --- docs/Dockerfile | 1 + docs/versions.json | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 docs/versions.json diff --git a/docs/Dockerfile b/docs/Dockerfile index 7679f2b7e4b..faf90ea0ecd 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -9,5 +9,6 @@ FROM grafana/docs-base:latest COPY config.toml /site COPY awsconfig /site +COPY versions.json /site/static/js VOLUME ["/site/content"] diff --git a/docs/versions.json b/docs/versions.json new file mode 100644 index 00000000000..6a4e3fa502e --- /dev/null +++ b/docs/versions.json @@ -0,0 +1,8 @@ +[ + { "version": "v4.6", "path": "/", "archived": false, "current": true }, + { "version": "v4.5", "path": "/v4.5", "archived": true }, + { "version": "v4.4", "path": "/v4.4", "archived": true }, + { "version": "v4.3", "path": "/v4.3", "archived": true }, + { "version": "v4.1", "path": "/v4.1", "archived": true }, + { "version": "v3.1", "path": "/v3.1", "archived": true } +] From 87d6d791d5c38a9077002a324d6add509ffe5109 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 30 Jan 2018 14:09:30 +0100 Subject: [PATCH 30/55] dashfolders: adds comment for dashboard api tests --- pkg/api/dashboard_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 35a2fc0f22a..c83479242fc 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -28,6 +28,10 @@ func (repo *fakeDashboardRepo) SaveDashboard(json *dashboards.SaveDashboardItem) var fakeRepo *fakeDashboardRepo +// This tests two main scenarios. If a user has access to execute an action on a dashboard: +// 1. and the dashboard is in a folder which does not have an acl +// 2. and the dashboard is in a folder which does have an acl + func TestDashboardApiEndpoint(t *testing.T) { Convey("Given a dashboard with a parent folder which does not have an acl", t, func() { fakeDash := m.NewDashboard("Child dash") @@ -66,6 +70,10 @@ func TestDashboardApiEndpoint(t *testing.T) { }), } + // This tests two scenarios: + // 1. user is an org viewer + // 2. user is an org editor + Convey("When user is an Org Viewer", func() { role := m.ROLE_VIEWER @@ -196,6 +204,14 @@ func TestDashboardApiEndpoint(t *testing.T) { }), } + // This tests six scenarios: + // 1. user is an org viewer AND has no permissions for this dashboard + // 2. user is an org editor AND has no permissions for this dashboard + // 3. user is an org viewer AND has been granted edit permission for the dashboard + // 4. user is an org viewer AND all viewers have edit permission for this dashboard + // 5. user is an org viewer AND has been granted an admin permission + // 6. user is an org editor AND has been granted a view permission + Convey("When user is an Org Viewer and has no permissions for this dashboard", func() { role := m.ROLE_VIEWER From f5107d50239835f2461a72f60512136e0450b080 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 30 Jan 2018 14:41:25 +0100 Subject: [PATCH 31/55] alerting: add permission check in api for pausing alerts --- pkg/api/alerting.go | 35 ++++++------ pkg/api/alerting_test.go | 97 ++++++++++++++++++++++++++++++++++ pkg/models/alert.go | 4 -- pkg/services/sqlstore/alert.go | 7 --- 4 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 pkg/api/alerting_test.go diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index d11122016c4..61232f3752f 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/services/guardian" ) func ValidateOrgAlert(c *middleware.Context) { @@ -155,24 +156,6 @@ func GetAlert(c *middleware.Context) Response { return Json(200, &query.Result) } -// DEL /api/alerts/:id -func DelAlert(c *middleware.Context) Response { - alertId := c.ParamsInt64(":alertId") - - if alertId == 0 { - return ApiError(401, "Failed to parse alertid", nil) - } - - cmd := models.DeleteAlertCommand{AlertId: alertId} - - if err := bus.Dispatch(&cmd); err != nil { - return ApiError(500, "Failed to delete alert", err) - } - - var resp = map[string]interface{}{"alertId": alertId} - return Json(200, resp) -} - func GetAlertNotifiers(c *middleware.Context) Response { return Json(200, alerting.GetNotifiers()) } @@ -267,6 +250,22 @@ func NotificationTest(c *middleware.Context, dto dtos.NotificationTestCommand) R //POST /api/alerts/:alertId/pause func PauseAlert(c *middleware.Context, dto dtos.PauseAlertCommand) Response { alertId := c.ParamsInt64("alertId") + + query := models.GetAlertByIdQuery{Id: alertId} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Get Alert failed", err) + } + + guardian := guardian.NewDashboardGuardian(query.Result.DashboardId, c.OrgId, c.SignedInUser) + if canEdit, err := guardian.CanEdit(); err != nil || !canEdit { + if err != nil { + return ApiError(500, "Error while checking permissions for Alert", err) + } + + return ApiError(403, "Access denied to this dashboard and alert", nil) + } + cmd := models.PauseAlertCommand{ OrgId: c.OrgId, AlertIds: []int64{alertId}, diff --git a/pkg/api/alerting_test.go b/pkg/api/alerting_test.go new file mode 100644 index 00000000000..6b030053e22 --- /dev/null +++ b/pkg/api/alerting_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestAlertingApiEndpoint(t *testing.T) { + Convey("Given an alert in a dashboard with an acl", t, func() { + + singleAlert := &m.Alert{Id: 1, DashboardId: 1, Name: "singlealert"} + + bus.AddHandler("test", func(query *m.GetAlertByIdQuery) error { + query.Result = singleAlert + return nil + }) + + viewerRole := m.ROLE_VIEWER + editorRole := m.ROLE_EDITOR + + aclMockResp := []*m.DashboardAclInfoDTO{} + bus.AddHandler("test", func(query *m.GetDashboardAclInfoListQuery) error { + query.Result = aclMockResp + return nil + }) + + bus.AddHandler("test", func(query *m.GetTeamsByUserQuery) error { + query.Result = []*m.Team{} + return nil + }) + + Convey("When user is editor and not in the ACL", func() { + Convey("Should not be able to pause the alert", func() { + cmd := dtos.PauseAlertCommand{ + AlertId: 1, + Paused: true, + } + postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + CallPauseAlert(sc) + So(sc.resp.Code, ShouldEqual, 403) + }) + }) + }) + + Convey("When user is editor and dashboard has default ACL", func() { + aclMockResp = []*m.DashboardAclInfoDTO{ + {Role: &viewerRole, Permission: m.PERMISSION_VIEW}, + {Role: &editorRole, Permission: m.PERMISSION_EDIT}, + } + + Convey("Should be able to pause the alert", func() { + cmd := dtos.PauseAlertCommand{ + AlertId: 1, + Paused: true, + } + postAlertScenario("When calling POST on", "/api/alerts/1/pause", "/api/alerts/:alertId/pause", m.ROLE_EDITOR, cmd, func(sc *scenarioContext) { + CallPauseAlert(sc) + So(sc.resp.Code, ShouldEqual, 200) + }) + }) + }) + }) +} + +func CallPauseAlert(sc *scenarioContext) { + bus.AddHandler("test", func(cmd *m.PauseAlertCommand) error { + return nil + }) + + sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec() +} + +func postAlertScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.PauseAlertCommand, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = wrap(func(c *middleware.Context) Response { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + return PauseAlert(c, cmd) + }) + + sc.m.Post(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/models/alert.go b/pkg/models/alert.go index fa3f4b466a8..b378c5cf90f 100644 --- a/pkg/models/alert.go +++ b/pkg/models/alert.go @@ -159,10 +159,6 @@ type SetAlertStateCommand struct { Timestamp time.Time } -type DeleteAlertCommand struct { - AlertId int64 -} - //Queries type GetAlertsQuery struct { OrgId int64 diff --git a/pkg/services/sqlstore/alert.go b/pkg/services/sqlstore/alert.go index cb1aec8cd65..96af8bc49ee 100644 --- a/pkg/services/sqlstore/alert.go +++ b/pkg/services/sqlstore/alert.go @@ -14,7 +14,6 @@ func init() { bus.AddHandler("sql", SaveAlerts) bus.AddHandler("sql", HandleAlertsQuery) bus.AddHandler("sql", GetAlertById) - bus.AddHandler("sql", DeleteAlertById) bus.AddHandler("sql", GetAllAlertQueryHandler) bus.AddHandler("sql", SetAlertState) bus.AddHandler("sql", GetAlertStatesForDashboard) @@ -61,12 +60,6 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *DBSession) erro return nil } -func DeleteAlertById(cmd *m.DeleteAlertCommand) error { - return inTransaction(func(sess *DBSession) error { - return deleteAlertByIdInternal(cmd.AlertId, "DeleteAlertCommand", sess) - }) -} - func HandleAlertsQuery(query *m.GetAlertsQuery) error { var sql bytes.Buffer params := make([]interface{}, 0) From cca050ea140a4ee78f94539d8fa9c9c17479c80c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 30 Jan 2018 15:07:52 +0100 Subject: [PATCH 32/55] docs: updated versions.json --- docs/versions.json | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/versions.json b/docs/versions.json index 6a4e3fa502e..03cb40f0e1f 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,4 +1,5 @@ [ + { "version": "v5.0", "path": "/v5.0", "archived": false }, { "version": "v4.6", "path": "/", "archived": false, "current": true }, { "version": "v4.5", "path": "/v4.5", "archived": true }, { "version": "v4.4", "path": "/v4.4", "archived": true }, From 8c8328eac56a604418816a29759387e5001e336c Mon Sep 17 00:00:00 2001 From: bergquist Date: Tue, 30 Jan 2018 16:00:16 +0100 Subject: [PATCH 33/55] test: fixes failing test in go1.10 --- .../datasource/wrapper/datasource_plugin_wrapper_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go index c85cb6cea52..834e8238e3a 100644 --- a/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go +++ b/pkg/plugins/datasource/wrapper/datasource_plugin_wrapper_test.go @@ -75,7 +75,7 @@ func TestMappingRowValue(t *testing.T) { boolRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_BOOL, BoolValue: true}) haveBool, ok := boolRowValue.(bool) if !ok || haveBool != true { - t.Fatalf("Expected true, was %s", haveBool) + t.Fatalf("Expected true, was %v", haveBool) } intRowValue, _ := dpw.mapRowValue(&datasource.RowValue{Kind: datasource.RowValue_TYPE_INT64, Int64Value: 42}) From 7cd379ac9f035b25b5333103685099a51170c1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 30 Jan 2018 15:21:43 +0100 Subject: [PATCH 34/55] docs: fixed order of sidemenu --- docs/sources/features/index.md | 2 +- docs/sources/guides/basic_concepts.md | 1 + docs/sources/guides/getting_started.md | 3 ++- docs/sources/guides/index.md | 2 +- docs/sources/index.md | 4 ---- docs/sources/installation/index.md | 1 + docs/sources/{features => }/whatsnew/index.md | 2 +- 7 files changed, 7 insertions(+), 8 deletions(-) rename docs/sources/{features => }/whatsnew/index.md (90%) diff --git a/docs/sources/features/index.md b/docs/sources/features/index.md index 1352e9f0576..d7fae41682b 100644 --- a/docs/sources/features/index.md +++ b/docs/sources/features/index.md @@ -5,7 +5,7 @@ type = "docs" [menu.docs] name = "Features" identifier = "features" -weight = 3 +weight = 4 +++ diff --git a/docs/sources/guides/basic_concepts.md b/docs/sources/guides/basic_concepts.md index cb64b105349..b710a227a79 100644 --- a/docs/sources/guides/basic_concepts.md +++ b/docs/sources/guides/basic_concepts.md @@ -7,6 +7,7 @@ type = "docs" name = "Basic Concepts" identifier = "basic_concepts" parent = "guides" +weight = 2 +++ # Basic Concepts diff --git a/docs/sources/guides/getting_started.md b/docs/sources/guides/getting_started.md index 5c02042481f..2f6f9a30def 100644 --- a/docs/sources/guides/getting_started.md +++ b/docs/sources/guides/getting_started.md @@ -8,6 +8,7 @@ aliases = ["/guides/gettingstarted"] name = "Getting Started" identifier = "getting_started_guide" parent = "guides" +weight = 1 +++ # Getting started @@ -24,7 +25,7 @@ Read the [Basic Concepts](/guides/basic_concepts) document to get a crash course ### Top header -Let's start with creating a new Dashboard. You can find the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard. +Let's start with creating a new Dashboard. You can find the new Dashboard link on the right side of the Dashboard picker. You now have a blank Dashboard. diff --git a/docs/sources/guides/index.md b/docs/sources/guides/index.md index c80dd624624..2ea09142880 100644 --- a/docs/sources/guides/index.md +++ b/docs/sources/guides/index.md @@ -4,6 +4,6 @@ type = "docs" [menu.docs] name = "Getting Started" identifier = "guides" -weight = 2 +weight = 3 +++ diff --git a/docs/sources/index.md b/docs/sources/index.md index 7a431e29692..3536eb5437e 100644 --- a/docs/sources/index.md +++ b/docs/sources/index.md @@ -4,10 +4,6 @@ description = "Install guide for Grafana" keywords = ["grafana", "installation", "documentation"] type = "docs" aliases = ["v1.1", "guides/reference/admin"] -[menu.docs] -name = "Welcome to the Docs" -identifier = "root" -weight = -1 +++ # Welcome to the Grafana Documentation diff --git a/docs/sources/installation/index.md b/docs/sources/installation/index.md index 47d52817b64..a86f426dc13 100644 --- a/docs/sources/installation/index.md +++ b/docs/sources/installation/index.md @@ -7,6 +7,7 @@ aliases = ["installation/installation/", "v2.1/installation/install/"] [menu.docs] name = "Installation" identifier = "installation" +weight = 1 +++ ## Installing Grafana diff --git a/docs/sources/features/whatsnew/index.md b/docs/sources/whatsnew/index.md similarity index 90% rename from docs/sources/features/whatsnew/index.md rename to docs/sources/whatsnew/index.md index 30357af4668..df472f07093 100644 --- a/docs/sources/features/whatsnew/index.md +++ b/docs/sources/whatsnew/index.md @@ -3,7 +3,7 @@ title = "What's New in Grafana" [menu.docs] name = "What's New In Grafana" identifier = "whatsnew" -weight = 2 +weight = 3 +++ From 4bc5945c17ca090ff0821538a106af455f29aa68 Mon Sep 17 00:00:00 2001 From: Daniel Lee Date: Tue, 30 Jan 2018 17:26:44 +0100 Subject: [PATCH 35/55] dashfolders: remove inline styles --- .../app/core/components/Picker/DescriptionOption.tsx | 10 ++-------- .../app/stores/PermissionsStore/PermissionsStore.ts | 4 ---- public/sass/_grafana.scss | 1 + public/sass/components/_description-picker.scss | 11 +++++++++++ 4 files changed, 14 insertions(+), 12 deletions(-) create mode 100644 public/sass/components/_description-picker.scss diff --git a/public/app/core/components/Picker/DescriptionOption.tsx b/public/app/core/components/Picker/DescriptionOption.tsx index 565191a5166..12a1fdd9163 100644 --- a/public/app/core/components/Picker/DescriptionOption.tsx +++ b/public/app/core/components/Picker/DescriptionOption.tsx @@ -41,18 +41,12 @@ class DescriptionOption extends Component { onMouseEnter={this.handleMouseEnter} onMouseMove={this.handleMouseMove} title={option.title} - className={`user-picker-option__button btn btn-link ${className} width-19`} - style={{ - whiteSpace: 'normal', - // height: '55px', - }} + className={`description-picker-option__button btn btn-link ${className} width-19`} >
{children}
{option.description}
- {className.indexOf('is-selected') > -1 && ( -