From e08c442e39b03d7eef636d853032e691f7348b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 21 Feb 2020 15:32:28 +0000 Subject: [PATCH 1/6] feat(backend): add basic auth support --- cmd/karma/main.go | 30 +++++++++++++------ .../tests/testscript/log_full_config_env.txt | 2 ++ .../tests/testscript/log_full_config_file.txt | 12 ++++++++ cmd/karma/views.go | 9 ++++++ cmd/karma/views_test.go | 15 ++++++++++ docs/CONFIGURATION.md | 16 ++++++++++ internal/config/config.go | 16 ++++++++++ internal/config/config_test.go | 4 ++- internal/config/models.go | 8 +++++ internal/models/api.go | 28 ++++++++++------- 10 files changed, 119 insertions(+), 21 deletions(-) diff --git a/cmd/karma/main.go b/cmd/karma/main.go index 613c4549d..ff0dbdee1 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -98,16 +98,28 @@ func setupRouter(router *gin.Engine) { ExposeHeaders: []string{"Content-Length"}, })) - router.GET(getViewURL("/"), index) - router.GET(getViewURL("/health"), pong) - router.GET(getViewURL("/alerts.json"), alerts) - router.GET(getViewURL("/autocomplete.json"), autocomplete) - router.GET(getViewURL("/labelNames.json"), knownLabelNames) - router.GET(getViewURL("/labelValues.json"), knownLabelValues) - router.GET(getViewURL("/silences.json"), silences) + var protected *gin.RouterGroup + if len(config.Config.Authentication.Users) > 0 { + users := map[string]string{} + for _, u := range config.Config.Authentication.Users { + users[u.Username] = u.Password + } + protected = router.Group(getViewURL("/"), gin.BasicAuth(users)) + } else { + protected = router.Group(getViewURL("/")) + } - router.GET(getViewURL("/custom.css"), customCSS) - router.GET(getViewURL("/custom.js"), customJS) + router.GET(getViewURL("/health"), pong) + + protected.GET("/", index) + protected.GET("/alerts.json", alerts) + protected.GET("/autocomplete.json", autocomplete) + protected.GET("/labelNames.json", knownLabelNames) + protected.GET("/labelValues.json", knownLabelValues) + protected.GET("/silences.json", silences) + + protected.GET("/custom.css", customCSS) + protected.GET("/custom.js", customJS) router.NoRoute(notFound) } diff --git a/cmd/karma/tests/testscript/log_full_config_env.txt b/cmd/karma/tests/testscript/log_full_config_env.txt index 91012dd53..3d705b400 100644 --- a/cmd/karma/tests/testscript/log_full_config_env.txt +++ b/cmd/karma/tests/testscript/log_full_config_env.txt @@ -69,6 +69,8 @@ cmp stderr expected.stderr -- expected.stderr -- level=info msg="Version: dev" level=info msg="Parsed configuration:" +level=info msg="authentication:" +level=info msg=" users: []" level=info msg="alertmanager:" level=info msg=" interval: 10s" level=info msg=" servers:" diff --git a/cmd/karma/tests/testscript/log_full_config_file.txt b/cmd/karma/tests/testscript/log_full_config_file.txt index 894d33f51..6f669ab11 100644 --- a/cmd/karma/tests/testscript/log_full_config_file.txt +++ b/cmd/karma/tests/testscript/log_full_config_file.txt @@ -4,6 +4,12 @@ karma.bin-should-work --config.file=custom.yaml --check-config cmp stderr expected.stderr -- custom.yaml -- +authentication: + users: + - username: number + password: 1234 + - username: string + password: '1234' alertmanager: interval: 10s servers: @@ -231,6 +237,12 @@ FLR1flnW2lx5o5csDzTpi+jgC6nu1zE0DWo1c5ZdpVO289POIpqh level=info msg="Reading configuration file custom.yaml" level=info msg="Version: dev" level=info msg="Parsed configuration:" +level=info msg="authentication:" +level=info msg=" users:" +level=info msg=" - username: number" +level=info msg=" password: '***'" +level=info msg=" - username: string" +level=info msg=" password: '***'" level=info msg="alertmanager:" level=info msg=" interval: 10s" level=info msg=" servers:" diff --git a/cmd/karma/views.go b/cmd/karma/views.go index 2075d9468..7444ff12d 100644 --- a/cmd/karma/views.go +++ b/cmd/karma/views.go @@ -143,6 +143,11 @@ func alerts(c *gin.Context) { start := time.Now() ts, _ := start.UTC().MarshalText() + var username string + if len(config.Config.Authentication.Users) > 0 { + username = c.MustGet(gin.AuthUserKey).(string) + } + // initialize response object, set fields that don't require any locking resp := models.AlertsResponse{} resp.Status = "success" @@ -175,6 +180,10 @@ func alerts(c *gin.Context) { CommentPrefix: config.Config.AlertAcknowledgement.CommentPrefix, }, } + resp.Authentication = models.AuthenticationInfo{ + Enabled: len(config.Config.Authentication.Users) > 0, + Username: username, + } if config.Config.Grid.Sorting.CustomValues.Labels != nil { resp.Settings.Sorting.ValueMapping = config.Config.Grid.Sorting.CustomValues.Labels diff --git a/cmd/karma/views_test.go b/cmd/karma/views_test.go index a38a134ec..30a8f34e0 100644 --- a/cmd/karma/views_test.go +++ b/cmd/karma/views_test.go @@ -818,3 +818,18 @@ func TestEmptySettings(t *testing.T) { t.Errorf("Wrong settings returned (-want +got):\n%s", diff) } } + +func TestBasicAuth(t *testing.T) { + config.Config.Authentication.Users = []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + } + r := ginTestEngine() + for _, path := range []string{"/", "/alerts.json", "/autocomplete.json"} { + req := httptest.NewRequest("GET", path, nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != 401 { + t.Errorf("Expected 401 from %s, got %d", path, resp.Code) + } + } +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 33caa7fb6..ded297259 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -22,6 +22,22 @@ Example with environment variables: CONFIG_FILE="docs/example.yaml" ``` +### Authentication + +`authentication` sections allows enabling authentication support in karma. +When set users will be require to authenticate to access karma. +Enabling authentication will also force silences to be created with usernames +passed from credentials. + +```YAML +authentication: + users: + - username: string + password: string +``` + +- `authentication:users` - list of users (username & password) allowed to login. + ### Alertmanagers `alertmanager` section allows setting Alertmanager servers that should be diff --git a/internal/config/config.go b/internal/config/config.go index 883094aa9..007280321 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -265,6 +265,12 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string { config.SilenceForm.Strip.Labels = []string{} } + for _, u := range config.Authentication.Users { + if u.Username == "" || u.Password == "" { + log.Fatalf("authentication.users require both username and password to be set") + } + } + if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" { _, err = regexp.Compile(config.SilenceForm.Author.PopulateFromHeader.ValueRegex) if err != nil { @@ -345,6 +351,16 @@ func (config *configSchema) LogValues() { // make a copy of our config so we can edit it cfg := configSchema(*config) + auth := []AuthenticationUser{} + for _, u := range cfg.Authentication.Users { + uu := AuthenticationUser{ + Username: u.Username, + Password: "***", + } + auth = append(auth, uu) + } + cfg.Authentication.Users = auth + // replace passwords in Alertmanager URIs with 'xxx' servers := []AlertmanagerConfig{} for _, s := range cfg.Alertmanager.Servers { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 610dd94d6..f3d1b5ae4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,7 +20,9 @@ func resetEnv() { } func testReadConfig(t *testing.T) { - expectedConfig := `alertmanager: + expectedConfig := `authentication: + users: [] +alertmanager: interval: 1s servers: - name: default diff --git a/internal/config/models.go b/internal/config/models.go index 9a3b68f52..4599071ac 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -40,7 +40,15 @@ type CustomLabelColor struct { type CustomLabelColors map[string][]CustomLabelColor +type AuthenticationUser struct { + Username string + Password string +} + type configSchema struct { + Authentication struct { + Users []AuthenticationUser + } Alertmanager struct { Interval time.Duration Servers []AlertmanagerConfig diff --git a/internal/models/api.go b/internal/models/api.go index 485223158..f6269ea96 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -296,19 +296,25 @@ type Settings struct { AlertAcknowledgement AlertAcknowledgementSettings `json:"alertAcknowledgement"` } +type AuthenticationInfo struct { + Enabled bool `json:"enabled"` + Username string `json:"username"` +} + // AlertsResponse is the structure of JSON response UI will use to get alert data type AlertsResponse struct { - Status string `json:"status"` - Timestamp string `json:"timestamp"` - Version string `json:"version"` - Upstreams AlertmanagerAPISummary `json:"upstreams"` - Silences map[string]map[string]Silence `json:"silences"` - AlertGroups []APIAlertGroup `json:"groups"` - TotalAlerts int `json:"totalAlerts"` - Colors LabelsColorMap `json:"colors"` - Filters []Filter `json:"filters"` - Counters LabelNameStatsList `json:"counters"` - Settings Settings `json:"settings"` + Status string `json:"status"` + Timestamp string `json:"timestamp"` + Version string `json:"version"` + Upstreams AlertmanagerAPISummary `json:"upstreams"` + Silences map[string]map[string]Silence `json:"silences"` + AlertGroups []APIAlertGroup `json:"groups"` + TotalAlerts int `json:"totalAlerts"` + Colors LabelsColorMap `json:"colors"` + Filters []Filter `json:"filters"` + Counters LabelNameStatsList `json:"counters"` + Settings Settings `json:"settings"` + Authentication AuthenticationInfo `json:"authentication"` } // Autocomplete is the structure of autocomplete object for filter hints From 541b1ef5199aa7df4847ab4c8ab56d54edd23d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 21 Feb 2020 17:06:59 +0000 Subject: [PATCH 2/6] feat(ui): enforce username from credentials if auth is enabled --- ui/src/Components/AlertAck/index.js | 20 +++++-- ui/src/Components/AlertAck/index.test.js | 21 +++++++ ui/src/Components/SilenceModal/AuthorInput.js | 59 ++++++++++++++++++ ui/src/Components/SilenceModal/SilenceForm.js | 60 ++++++------------- .../SilenceModal/SilenceForm.test.js | 10 ++++ ui/src/Stores/AlertStore.js | 6 +- 6 files changed, 128 insertions(+), 48 deletions(-) create mode 100644 ui/src/Components/SilenceModal/AuthorInput.js diff --git a/ui/src/Components/AlertAck/index.js b/ui/src/Components/AlertAck/index.js index 11e6d64a6..0db2fb741 100644 --- a/ui/src/Components/AlertAck/index.js +++ b/ui/src/Components/AlertAck/index.js @@ -172,6 +172,20 @@ const AlertAck = observer( return; } + let author = + alertStore.settings.values.silenceForm.author !== "" + ? alertStore.settings.values.silenceForm.author + : silenceFormStore.data.author !== "" + ? toJS(silenceFormStore.data.author) + : toJS(alertStore.settings.values.alertAcknowledgement.author); + + if (alertStore.info.authentication.enabled) { + silenceFormStore.data.author = toJS( + alertStore.info.authentication.username + ); + author = alertStore.info.authentication.username; + } + const alertmanagers = Object.entries(group.alertmanagerCount) .filter(([amName, alertCount]) => alertCount > 0) .map(([amName, _]) => amName); @@ -187,11 +201,7 @@ const AlertAck = observer( toJS(group), toJS(clusterMembers), toJS(alertStore.settings.values.alertAcknowledgement.durationSeconds), - alertStore.settings.values.silenceForm.author !== "" - ? alertStore.settings.values.silenceForm.author - : silenceFormStore.data.author !== "" - ? toJS(silenceFormStore.data.author) - : toJS(alertStore.settings.values.alertAcknowledgement.author), + author, toJS(alertStore.settings.values.alertAcknowledgement.commentPrefix) ); this.submitState.pushSilence(clusterName, pendingSilence); diff --git a/ui/src/Components/AlertAck/index.test.js b/ui/src/Components/AlertAck/index.test.js index 4a6020cfd..14f04daa7 100644 --- a/ui/src/Components/AlertAck/index.test.js +++ b/ui/src/Components/AlertAck/index.test.js @@ -189,6 +189,27 @@ describe("", () => { }); }); + it("uses author from authentication info when auth is enabled", () => { + alertStore.info.authentication.enabled = true; + alertStore.info.authentication.username = "auth@example.com"; + alertStore.settings.values.silenceForm.author = "john@example.com"; + alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; + alertStore.settings.values.alertAcknowledgement.author = "me"; + alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:"; + MountAndClick(); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ + comment: + "FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000", + createdBy: "auth@example.com", + endsAt: "2000-02-01T00:03:42.000Z", + matchers: [ + { isRegex: false, name: "alertname", value: "Fake Alert" }, + { isRegex: true, name: "foo", value: "(bar|baz)" } + ], + startsAt: "2000-02-01T00:00:00.000Z" + }); + }); + it("uses author from alertStore if present", () => { alertStore.settings.values.silenceForm.author = "john@example.com"; alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; diff --git a/ui/src/Components/SilenceModal/AuthorInput.js b/ui/src/Components/SilenceModal/AuthorInput.js new file mode 100644 index 000000000..c7a147e9e --- /dev/null +++ b/ui/src/Components/SilenceModal/AuthorInput.js @@ -0,0 +1,59 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faUser } from "@fortawesome/free-solid-svg-icons/faUser"; + +import { AlertStore } from "Stores/AlertStore"; + +const IconInput = ({ + type, + autoComplete, + icon, + placeholder, + value, + onChange, + ...extra +}) => ( +
+
+ + + +
+ +
+); +IconInput.propTypes = { + type: PropTypes.string.isRequired, + autoComplete: PropTypes.string.isRequired, + icon: FontAwesomeIcon.propTypes.icon.isRequired, + placeholder: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func +}; + +const AuthenticatedAuthorInput = ({ alertStore }) => ( + +); +AuthenticatedAuthorInput.propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired +}; + +export { IconInput, AuthenticatedAuthorInput }; diff --git a/ui/src/Components/SilenceModal/SilenceForm.js b/ui/src/Components/SilenceModal/SilenceForm.js index 511f9a8be..e6a72dfe3 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.js +++ b/ui/src/Components/SilenceModal/SilenceForm.js @@ -21,40 +21,7 @@ import { AlertManagerInput } from "./AlertManagerInput"; import { SilenceMatch } from "./SilenceMatch"; import { DateTimeSelect } from "./DateTimeSelect"; import { PayloadPreview } from "./PayloadPreview"; - -const IconInput = ({ - type, - autoComplete, - icon, - placeholder, - value, - onChange -}) => ( -
-
- - - -
- -
-); -IconInput.propTypes = { - type: PropTypes.string.isRequired, - autoComplete: PropTypes.string.isRequired, - icon: FontAwesomeIcon.propTypes.icon.isRequired, - placeholder: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired -}; +import { IconInput, AuthenticatedAuthorInput } from "./AuthorInput"; const SilenceForm = observer( class SilenceForm extends Component { @@ -111,6 +78,10 @@ const SilenceForm = observer( silenceFormStore.data.author = settingsStore.silenceFormConfig.config.author; } + + if (alertStore.info.authentication.enabled) { + silenceFormStore.data.author = alertStore.info.authentication.username; + } }); addMore = action(event => { @@ -177,14 +148,19 @@ const SilenceForm = observer( - + {alertStore.info.authentication.enabled ? ( + + ) : ( + + )} + preview", () => { }); describe(" inputs", () => { + it("author is read-only when info.authentication.enabled is true", () => { + alertStore.info.authentication.enabled = true; + alertStore.info.authentication.username = "auth@example.com"; + const tree = MountedSilenceForm(); + const input = tree.find("input[placeholder='Author']"); + expect(input.props().readOnly).toBe(true); + expect(input.props().value).toBe("auth@example.com"); + expect(silenceFormStore.data.author).toBe("auth@example.com"); + }); + it("default author value comes from Settings store", () => { settingsStore.silenceFormConfig.config.author = "foo@example.com"; const tree = MountedSilenceForm(); diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 610b6e141..9bbffedad 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -215,6 +215,10 @@ class AlertStore { info = observable( { + authentication: { + enabled: false, + username: "" + }, totalAlerts: 0, version: "unknown", upgradeNeeded: false, @@ -418,7 +422,7 @@ class AlertStore { this.info.upgradeNeeded = true; } // update extra root level keys that are stored under 'info' - for (const key of ["totalAlerts", "version"]) { + for (const key of ["totalAlerts", "version", "authentication"]) { if (this.info[key] !== result[key]) { this.info[key] = result[key]; } From fbff53c51bbd50e712e1445758ad247c23bb27e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 21 Feb 2020 22:05:11 +0000 Subject: [PATCH 3/6] feat(backend): support header based auth --- cmd/karma/main.go | 28 +++- cmd/karma/script_test.go | 7 +- .../auth_basicAuth_no_credentials.txt | 14 ++ .../testscript/auth_basicAuth_no_password.txt | 14 ++ .../testscript/auth_basicAuth_no_username.txt | 14 ++ .../testscript/auth_header_and_basicAuth.txt | 18 ++ .../tests/testscript/auth_header_no_name.txt | 13 ++ .../tests/testscript/auth_header_no_regex.txt | 13 ++ .../testscript/auth_header_regex_invalid.txt | 14 ++ .../tests/testscript/log_full_config_env.txt | 6 +- .../tests/testscript/log_full_config_file.txt | 25 +-- cmd/karma/views.go | 5 +- cmd/karma/views_test.go | 154 ++++++++++++++++-- docs/CONFIGURATION.md | 67 +++++++- internal/config/config.go | 30 +++- internal/config/config_test.go | 6 +- internal/config/models.go | 9 +- 17 files changed, 399 insertions(+), 38 deletions(-) create mode 100644 cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt create mode 100644 cmd/karma/tests/testscript/auth_basicAuth_no_password.txt create mode 100644 cmd/karma/tests/testscript/auth_basicAuth_no_username.txt create mode 100644 cmd/karma/tests/testscript/auth_header_and_basicAuth.txt create mode 100644 cmd/karma/tests/testscript/auth_header_no_name.txt create mode 100644 cmd/karma/tests/testscript/auth_header_no_regex.txt create mode 100644 cmd/karma/tests/testscript/auth_header_regex_invalid.txt diff --git a/cmd/karma/main.go b/cmd/karma/main.go index ff0dbdee1..1c9d2607a 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -67,6 +67,25 @@ func customJS(c *gin.Context) { serveFileOr404(config.Config.Custom.JS, "application/javascript", c) } +func headerAuth(name, valueRegex string) gin.HandlerFunc { + return func(c *gin.Context) { + user := c.Request.Header.Get(name) + if user == "" { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + + r := regexp.MustCompile("^" + valueRegex + "$") + matches := r.FindAllStringSubmatch(user, 1) + if len(matches) > 0 && len(matches[0]) > 1 { + c.Set(gin.AuthUserKey, matches[0][1]) + } else { + c.AbortWithStatus(http.StatusUnauthorized) + return + } + } +} + func setupRouter(router *gin.Engine) { router.Use(gzip.Gzip(gzip.DefaultCompression)) @@ -99,9 +118,14 @@ func setupRouter(router *gin.Engine) { })) var protected *gin.RouterGroup - if len(config.Config.Authentication.Users) > 0 { + if config.Config.Authentication.Header.Name != "" { + config.Config.Authentication.Enabled = true + protected = router.Group(getViewURL("/"), + headerAuth(config.Config.Authentication.Header.Name, config.Config.Authentication.Header.ValueRegex)) + } else if len(config.Config.Authentication.BasicAuth.Users) > 0 { + config.Config.Authentication.Enabled = true users := map[string]string{} - for _, u := range config.Config.Authentication.Users { + for _, u := range config.Config.Authentication.BasicAuth.Users { users[u.Username] = u.Password } protected = router.Group(getViewURL("/"), gin.BasicAuth(users)) diff --git a/cmd/karma/script_test.go b/cmd/karma/script_test.go index 573339cd2..57fb265ec 100644 --- a/cmd/karma/script_test.go +++ b/cmd/karma/script_test.go @@ -11,8 +11,13 @@ import ( ) func mainShoulFail() int { - defer func() { log.StandardLogger().ExitFunc = nil }() var wasFatal bool + defer func() { + if r := recover(); r != nil { + wasFatal = true + } + }() + defer func() { log.StandardLogger().ExitFunc = nil }() log.StandardLogger().ExitFunc = func(int) { wasFatal = true } _, err := mainSetup(pflag.ContinueOnError) diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt new file mode 100644 index 000000000..29d2e5122 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt @@ -0,0 +1,14 @@ +# Raises an error if basic auth credentials are missing +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.basicAuth.users require both username and password to be set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + basicAuth: + users: + - foo: bar diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt new file mode 100644 index 000000000..65606d776 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt @@ -0,0 +1,14 @@ +# Raises an error if basic auth password is missing +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.basicAuth.users require both username and password to be set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + basicAuth: + users: + - username: me diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt new file mode 100644 index 000000000..0a812456f --- /dev/null +++ b/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt @@ -0,0 +1,14 @@ +# Raises an error if basic auth username is missing +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.basicAuth.users require both username and password to be set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + basicAuth: + users: + - password: foo diff --git a/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt b/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt new file mode 100644 index 000000000..ee1d16c85 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt @@ -0,0 +1,18 @@ +# Raises an error if both header & basic auth authentication is enabled +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="Both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + header: + name: "foo" + value_re: ".+" + basicAuth: + users: + - username: me + password: foo diff --git a/cmd/karma/tests/testscript/auth_header_no_name.txt b/cmd/karma/tests/testscript/auth_header_no_name.txt new file mode 100644 index 000000000..b1bdd5e3b --- /dev/null +++ b/cmd/karma/tests/testscript/auth_header_no_name.txt @@ -0,0 +1,13 @@ +# Raises an error if header authentication config is missing name +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.header.name is required when authentication.header.value_re is set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + header: + value_re: ".+" diff --git a/cmd/karma/tests/testscript/auth_header_no_regex.txt b/cmd/karma/tests/testscript/auth_header_no_regex.txt new file mode 100644 index 000000000..dd7d2e737 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_header_no_regex.txt @@ -0,0 +1,13 @@ +# Raises an error if header authentication config is missing regex rule +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="authentication.header.value_re is required when authentication.header.name is set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + header: + name: "foo" diff --git a/cmd/karma/tests/testscript/auth_header_regex_invalid.txt b/cmd/karma/tests/testscript/auth_header_regex_invalid.txt new file mode 100644 index 000000000..c7f15f3eb --- /dev/null +++ b/cmd/karma/tests/testscript/auth_header_regex_invalid.txt @@ -0,0 +1,14 @@ +# Raises an error if header authentication config contains invalid regex rule +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="Invalid regex for authentication.header.value_re: error parsing regexp: invalid nested repetition operator: `\+\+`"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +authentication: + header: + name: "foo" + value_re: ".++***" diff --git a/cmd/karma/tests/testscript/log_full_config_env.txt b/cmd/karma/tests/testscript/log_full_config_env.txt index 3d705b400..7c132316b 100644 --- a/cmd/karma/tests/testscript/log_full_config_env.txt +++ b/cmd/karma/tests/testscript/log_full_config_env.txt @@ -70,7 +70,11 @@ cmp stderr expected.stderr level=info msg="Version: dev" level=info msg="Parsed configuration:" level=info msg="authentication:" -level=info msg=" users: []" +level=info msg=" header:" +level=info msg=" name: \"\"" +level=info msg=" value_re: \"\"" +level=info msg=" basicAuth:" +level=info msg=" users: []" level=info msg="alertmanager:" level=info msg=" interval: 10s" level=info msg=" servers:" diff --git a/cmd/karma/tests/testscript/log_full_config_file.txt b/cmd/karma/tests/testscript/log_full_config_file.txt index 6f669ab11..7535985ee 100644 --- a/cmd/karma/tests/testscript/log_full_config_file.txt +++ b/cmd/karma/tests/testscript/log_full_config_file.txt @@ -5,11 +5,12 @@ cmp stderr expected.stderr -- custom.yaml -- authentication: - users: - - username: number - password: 1234 - - username: string - password: '1234' + basicAuth: + users: + - username: number + password: 1234 + - username: string + password: '1234' alertmanager: interval: 10s servers: @@ -238,11 +239,15 @@ level=info msg="Reading configuration file custom.yaml" level=info msg="Version: dev" level=info msg="Parsed configuration:" level=info msg="authentication:" -level=info msg=" users:" -level=info msg=" - username: number" -level=info msg=" password: '***'" -level=info msg=" - username: string" -level=info msg=" password: '***'" +level=info msg=" header:" +level=info msg=" name: \"\"" +level=info msg=" value_re: \"\"" +level=info msg=" basicAuth:" +level=info msg=" users:" +level=info msg=" - username: number" +level=info msg=" password: '***'" +level=info msg=" - username: string" +level=info msg=" password: '***'" level=info msg="alertmanager:" level=info msg=" interval: 10s" level=info msg=" servers:" diff --git a/cmd/karma/views.go b/cmd/karma/views.go index 7444ff12d..3700cc52e 100644 --- a/cmd/karma/views.go +++ b/cmd/karma/views.go @@ -144,7 +144,7 @@ func alerts(c *gin.Context) { ts, _ := start.UTC().MarshalText() var username string - if len(config.Config.Authentication.Users) > 0 { + if config.Config.Authentication.Enabled { username = c.MustGet(gin.AuthUserKey).(string) } @@ -181,7 +181,7 @@ func alerts(c *gin.Context) { }, } resp.Authentication = models.AuthenticationInfo{ - Enabled: len(config.Config.Authentication.Users) > 0, + Enabled: config.Config.Authentication.Enabled, Username: username, } @@ -209,6 +209,7 @@ func alerts(c *gin.Context) { } newResp.Settings = resp.Settings newResp.Timestamp = string(ts) + newResp.Authentication = resp.Authentication newData, err := json.Marshal(&newResp) if err != nil { log.Error(err.Error()) diff --git a/cmd/karma/views_test.go b/cmd/karma/views_test.go index 30a8f34e0..391483796 100644 --- a/cmd/karma/views_test.go +++ b/cmd/karma/views_test.go @@ -819,17 +819,149 @@ func TestEmptySettings(t *testing.T) { } } -func TestBasicAuth(t *testing.T) { - config.Config.Authentication.Users = []config.AuthenticationUser{ - {Username: "john", Password: "foobar"}, +func TestAuthentication(t *testing.T) { + type authTest struct { + name string + headerName string + headerRe string + basicAuthUsers []config.AuthenticationUser + requestHeaders map[string]string + requestBasicAuthUser string + requestBasicAuthPassword string + responseCode int + responseUsername string } - r := ginTestEngine() - for _, path := range []string{"/", "/alerts.json", "/autocomplete.json"} { - req := httptest.NewRequest("GET", path, nil) - resp := httptest.NewRecorder() - r.ServeHTTP(resp, req) - if resp.Code != 401 { - t.Errorf("Expected 401 from %s, got %d", path, resp.Code) - } + + authTests := []authTest{ + { + name: "basic auth, request without credentials, 401", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + responseCode: 401, + }, + { + name: "basic auth, missing password, 401", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + responseCode: 401, + }, + { + name: "basic auth, missing username, 401", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthPassword: "foobar", + responseCode: 401, + }, + { + name: "basic auth, wrong password, 401", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + requestBasicAuthPassword: "foobarx", + responseCode: 401, + }, + { + name: "basic auth, correct credentials, 200", + basicAuthUsers: []config.AuthenticationUser{ + {Username: "john", Password: "foobar"}, + }, + requestBasicAuthUser: "john", + requestBasicAuthPassword: "foobar", + responseCode: 200, + responseUsername: "john", + }, + { + name: "header auth, missing header, 401", + headerName: "X-Auth", + headerRe: "(.+)", + responseCode: 401, + }, + { + name: "header auth, header value doesn't match, 401", + headerName: "X-Auth", + headerRe: "Username (.+)", + requestHeaders: map[string]string{ + "X-Auth": "xxx", + }, + responseCode: 401, + }, + { + name: "header auth, header value doesn't match #2, 401", + headerName: "X-Auth", + headerRe: "Username (.+)", + requestHeaders: map[string]string{ + "X-Auth": "xxx Username xxx", + }, + responseCode: 401, + }, + { + name: "header auth, header correct, 200", + headerName: "X-Auth", + headerRe: "(.+)", + requestHeaders: map[string]string{ + "X-Auth": "john", + }, + responseCode: 200, + responseUsername: "john", + }, + { + name: "header auth, header correct #2, 200", + headerName: "X-Auth", + headerRe: "Username (.+)", + requestHeaders: map[string]string{ + "X-Auth": "Username john", + }, + responseCode: 200, + responseUsername: "john", + }, + } + + for _, testCase := range authTests { + t.Run(testCase.name, func(t *testing.T) { + config.Config.Authentication.Header.Name = testCase.headerName + config.Config.Authentication.Header.ValueRegex = testCase.headerRe + config.Config.Authentication.BasicAuth.Users = testCase.basicAuthUsers + r := ginTestEngine() + for _, path := range []string{ + "/", + "/alerts.json", + "/autocomplete.json?term=foo", + "/labelNames.json", + "/labelValues.json?name=foo", + "/silences.json", + "/custom.css", + "/custom.js", + } { + req := httptest.NewRequest("GET", path, nil) + for k, v := range testCase.requestHeaders { + req.Header.Set(k, v) + } + req.SetBasicAuth(testCase.requestBasicAuthUser, testCase.requestBasicAuthPassword) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != testCase.responseCode { + t.Errorf("Expected %d from %s, got %d", testCase.responseCode, path, resp.Code) + } + + if resp.Code == 200 && path == "/alerts.json" { + ur := models.AlertsResponse{} + err := json.Unmarshal(resp.Body.Bytes(), &ur) + if err != nil { + t.Errorf("Failed to unmarshal response: %s", err) + } + if ur.Authentication.Enabled != true { + t.Errorf("Got Authentication.Enabled=%v", ur.Authentication.Enabled) + } + if ur.Authentication.Username != testCase.responseUsername { + t.Errorf("Got Authentication.Username=%s, expected %s", ur.Authentication.Username, testCase.responseUsername) + } + } + } + }) } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ded297259..34744fd68 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -25,18 +25,77 @@ CONFIG_FILE="docs/example.yaml" ### Authentication `authentication` sections allows enabling authentication support in karma. -When set users will be require to authenticate to access karma. +When set users will be required to authenticate when trying to access karma. +There are currently two supported authentication methods: + +- [Basic HTTP Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme). + Karma will be performing authentication using configured list of username & + password pairs. This method is only recommended for testing. +- External authentication via headers. Karma doesn't perform any authentication + itself, it is done by a frontend service (SSO or nginx reverse proxy) that + sets a header with username on every request. + +Only one method can be enabled in the config. Enabling authentication will also force silences to be created with usernames passed from credentials. ```YAML authentication: - users: - - username: string - password: string + header: + name: string + value_re: string + basicAuth: + users: + - username: string + password: string ``` +- `authentication:users:header:name` - name of the header that will contain the + username. If this header is missing from a request access will be forbidden. + When set header authentication is used. +- `authentication:users:header:value_re` - + [regex](https://golang.org/s/re2syntax) used to extract the username from the + request header value (when `authentication:users:header:name` is set). + It must include one numbered capturing group, whatever is matched by that + group will be used as the silence form author field. + This option must be set when `authentication:users:header:name` is set. - `authentication:users` - list of users (username & password) allowed to login. + Passwords are stored plain without any encryption. + When set HTTP basic authentication will be used. + +Defaults: + +```YAML +authentication: + header: + name: "" + value_re: "" + basicAuth: + users: [] +``` + +Example where HTTP Basic Authentication will be used with a list of username +and password pairs set in karma config file. + +```YAML +authentication: + basicAuth: + users: + - username: alice + password: secret + - username: bob + password: moreSecret +``` + +Example where the `X-Auth` header will be used for authentication, raw header +value will be used as username. + +```YAML +authentication: + header: + name: X-Auth + value_re: ^(.+)$ +``` ### Alertmanagers diff --git a/internal/config/config.go b/internal/config/config.go index 007280321..feead3c3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -265,10 +265,30 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string { config.SilenceForm.Strip.Labels = []string{} } - for _, u := range config.Authentication.Users { - if u.Username == "" || u.Password == "" { - log.Fatalf("authentication.users require both username and password to be set") + if config.Authentication.Header.Name != "" && len(config.Authentication.BasicAuth.Users) > 0 { + log.Fatalf("Both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled") + } + + if config.Authentication.Header.ValueRegex != "" { + _, err = regexp.Compile(config.Authentication.Header.ValueRegex) + if err != nil { + log.Fatalf("Invalid regex for authentication.header.value_re: %s", err.Error()) } + if config.Authentication.Header.Name == "" { + log.Fatalf("authentication.header.name is required when authentication.header.value_re is set") + } + } else if config.Authentication.Header.Name != "" { + log.Fatalf("authentication.header.value_re is required when authentication.header.name is set") + } + + for _, u := range config.Authentication.BasicAuth.Users { + if u.Username == "" || u.Password == "" { + log.Fatalf("authentication.basicAuth.users require both username and password to be set") + } + } + + if config.Authentication.Header.Name != "" || len(config.Authentication.BasicAuth.Users) > 0 { + config.Authentication.Enabled = true } if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" { @@ -352,14 +372,14 @@ func (config *configSchema) LogValues() { cfg := configSchema(*config) auth := []AuthenticationUser{} - for _, u := range cfg.Authentication.Users { + for _, u := range cfg.Authentication.BasicAuth.Users { uu := AuthenticationUser{ Username: u.Username, Password: "***", } auth = append(auth, uu) } - cfg.Authentication.Users = auth + cfg.Authentication.BasicAuth.Users = auth // replace passwords in Alertmanager URIs with 'xxx' servers := []AlertmanagerConfig{} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f3d1b5ae4..b4a70d185 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,7 +21,11 @@ func resetEnv() { func testReadConfig(t *testing.T) { expectedConfig := `authentication: - users: [] + header: + name: "" + value_re: "" + basicAuth: + users: [] alertmanager: interval: 1s servers: diff --git a/internal/config/models.go b/internal/config/models.go index 4599071ac..61b030ab1 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -47,7 +47,14 @@ type AuthenticationUser struct { type configSchema struct { Authentication struct { - Users []AuthenticationUser + Enabled bool `yaml:"-" koanf:"-"` + Header struct { + Name string + ValueRegex string `yaml:"value_re" koanf:"value_re"` + } + BasicAuth struct { + Users []AuthenticationUser + } `yaml:"basicAuth" koanf:"basicAuth"` } Alertmanager struct { Interval time.Duration From b4a62f10793e68b2a7196c75310324b29e268ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Sat, 22 Feb 2020 15:31:14 +0000 Subject: [PATCH 4/6] feat(ui): show username when auth is enabled --- .../Components/MainModal/MainModalContent.js | 5 +++++ .../MainModal/MainModalContent.test.js | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/ui/src/Components/MainModal/MainModalContent.js b/ui/src/Components/MainModal/MainModalContent.js index e4a211f9b..06a109862 100644 --- a/ui/src/Components/MainModal/MainModalContent.js +++ b/ui/src/Components/MainModal/MainModalContent.js @@ -81,6 +81,11 @@ const MainModalContent = observer( ) : null}
+ {alertStore.info.authentication.enabled && ( + + Username: {alertStore.info.authentication.username} + + )} Version: {alertStore.info.version} diff --git a/ui/src/Components/MainModal/MainModalContent.test.js b/ui/src/Components/MainModal/MainModalContent.test.js index 9376347e5..adaed88b0 100644 --- a/ui/src/Components/MainModal/MainModalContent.test.js +++ b/ui/src/Components/MainModal/MainModalContent.test.js @@ -96,4 +96,22 @@ describe("", () => { it("calls setTab('help') after clicking on the 'Help' tab", () => { ValidateSetTab("Help", "help"); }); + + it("shows username when alertStore.info.authentication.enabled=true", () => { + alertStore.info.authentication.enabled = true; + alertStore.info.authentication.username = "me@example.com"; + const tree = mount( + + {Wrapped( + + )} + + ); + expect(tree.text()).toMatch(/Username: me@example.com/); + }); }); From e9306d2222616758dc37f66f3b44a94d0d298718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Sat, 22 Feb 2020 15:42:35 +0000 Subject: [PATCH 5/6] fix(backend): remove silenceForm.author config section --- .../tests/testscript/log_full_config_env.txt | 4 - .../tests/testscript/log_full_config_file.txt | 8 -- ...eform_populatefromheader_invalid_regex.txt | 15 ---- ...form_populatefromheader_missing_header.txt | 14 ---- ...eform_populatefromheader_missing_regex.txt | 14 ---- cmd/karma/views.go | 17 ---- cmd/karma/views_test.go | 81 ------------------- demo/karma.yaml | 4 - docs/CONFIGURATION.md | 23 +----- internal/config/config.go | 14 ---- internal/config/config_test.go | 23 ------ internal/config/models.go | 6 -- internal/models/api.go | 3 +- 13 files changed, 2 insertions(+), 224 deletions(-) delete mode 100644 cmd/karma/tests/testscript/silenceform_populatefromheader_invalid_regex.txt delete mode 100644 cmd/karma/tests/testscript/silenceform_populatefromheader_missing_header.txt delete mode 100644 cmd/karma/tests/testscript/silenceform_populatefromheader_missing_regex.txt diff --git a/cmd/karma/tests/testscript/log_full_config_env.txt b/cmd/karma/tests/testscript/log_full_config_env.txt index 7c132316b..61c06774b 100644 --- a/cmd/karma/tests/testscript/log_full_config_env.txt +++ b/cmd/karma/tests/testscript/log_full_config_env.txt @@ -167,10 +167,6 @@ level=info msg=" comments:" level=info msg=" linkDetect:" level=info msg=" rules: []" level=info msg="silenceForm:" -level=info msg=" author:" -level=info msg=" populate_from_header:" -level=info msg=" header: CF-RAY" -level=info msg=" value_re: ^(.+)$" level=info msg=" strip:" level=info msg=" labels:" level=info msg=" - job" diff --git a/cmd/karma/tests/testscript/log_full_config_file.txt b/cmd/karma/tests/testscript/log_full_config_file.txt index 7535985ee..c8b410306 100644 --- a/cmd/karma/tests/testscript/log_full_config_file.txt +++ b/cmd/karma/tests/testscript/log_full_config_file.txt @@ -128,10 +128,6 @@ silences: - regex: "(DEVOPS-[0-9]+)" uriTemplate: https://jira.example.com/browse/$1 silenceForm: - author: - populate_from_header: - header: "CF-RAY" - value_re: "^(.+)$" strip: labels: - job @@ -406,10 +402,6 @@ level=info msg=" rules:" level=info msg=" - regex: (DEVOPS-[0-9]+)" level=info msg=" uriTemplate: https://jira.example.com/browse/$1" level=info msg="silenceForm:" -level=info msg=" author:" -level=info msg=" populate_from_header:" -level=info msg=" header: CF-RAY" -level=info msg=" value_re: ^(.+)$" level=info msg=" strip:" level=info msg=" labels:" level=info msg=" - job" diff --git a/cmd/karma/tests/testscript/silenceform_populatefromheader_invalid_regex.txt b/cmd/karma/tests/testscript/silenceform_populatefromheader_invalid_regex.txt deleted file mode 100644 index d4d4b8f15..000000000 --- a/cmd/karma/tests/testscript/silenceform_populatefromheader_invalid_regex.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Raises an error if silence form populate from header config is using invalid regex rule -karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml -! stdout . -stderr 'msg="Invalid regex for silenceform.author.populate_from_header.value_re: error parsing regexp: invalid nested repetition operator: `\+\+`"' - --- karma.yaml -- -alertmanager: - servers: - - name: default - uri: https://localhost:9093 -silenceForm: - author: - populate_from_header: - header: "CF-RAY" - value_re: ".++++" diff --git a/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_header.txt b/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_header.txt deleted file mode 100644 index 0b8c6eb76..000000000 --- a/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_header.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Raises an error if silence form populate from header config is missing header name -karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml -! stdout . -stderr 'msg="silenceform.author.populate_from_header.header is required when silenceform.author.populate_from_header.value_re is set"' - --- karma.yaml -- -alertmanager: - servers: - - name: default - uri: https://localhost:9093 -silenceForm: - author: - populate_from_header: - value_re: "^(.+)$" diff --git a/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_regex.txt b/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_regex.txt deleted file mode 100644 index e9a03e2bb..000000000 --- a/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_regex.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Raises an error if silence form populate from header config is missing regex rule -karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml -! stdout . -stderr 'msg="silenceform.author.populate_from_header.value_re is required when silenceform.author.populate_from_header.header is set"' - --- karma.yaml -- -alertmanager: - servers: - - name: default - uri: https://localhost:9093 -silenceForm: - author: - populate_from_header: - header: "CF-RAY" diff --git a/cmd/karma/views.go b/cmd/karma/views.go index 3700cc52e..3518b19c2 100644 --- a/cmd/karma/views.go +++ b/cmd/karma/views.go @@ -8,7 +8,6 @@ import ( "fmt" "io/ioutil" "net/http" - "regexp" "sort" "strings" "time" @@ -122,21 +121,6 @@ func populateAPIFilters(matchFilters []filters.FilterT) []models.Filter { return apiFilters } -func authorFromHeader(c *gin.Context, header string, valueRe string) string { - if header == "" || valueRe == "" { - return "" - } - v := c.GetHeader(header) - if v != "" { - r := regexp.MustCompile(valueRe) - matches := r.FindAllStringSubmatch(v, 1) - if len(matches) > 0 && len(matches[0]) > 1 { - return matches[0][1] - } - } - return "" -} - // alerts endpoint, json, JS will query this via AJAX call func alerts(c *gin.Context) { noCache(c) @@ -168,7 +152,6 @@ func alerts(c *gin.Context) { AnnotationsHidden: config.Config.Annotations.Hidden, AnnotationsVisible: config.Config.Annotations.Visible, SilenceForm: models.SilenceFormSettings{ - Author: authorFromHeader(c, config.Config.SilenceForm.Author.PopulateFromHeader.Header, config.Config.SilenceForm.Author.PopulateFromHeader.ValueRegex), Strip: models.SilenceFormStripSettings{ Labels: config.Config.SilenceForm.Strip.Labels, }, diff --git a/cmd/karma/views_test.go b/cmd/karma/views_test.go index 391483796..fe67d5c56 100644 --- a/cmd/karma/views_test.go +++ b/cmd/karma/views_test.go @@ -551,86 +551,6 @@ func TestGzipMiddlewareWithoutAcceptEncoding(t *testing.T) { } } -func TestValidateAuthorFromHeaders(t *testing.T) { - type testValidateAuthorFromHeaders struct { - configHeader string - configRegex string - requestHeaderName string - requestHeaderValue string - expectedAuthor string - } - - testCases := []testValidateAuthorFromHeaders{ - { - configHeader: "X-Auth", - configRegex: "^(.*)$", - requestHeaderName: "X-Auth", - requestHeaderValue: "foo", - expectedAuthor: "foo", - }, - { - configHeader: "X-Auth", - configRegex: "^foo(.*)bar$", - requestHeaderName: "X-Auth", - requestHeaderValue: "foo123bar", - expectedAuthor: "123", - }, - { - configHeader: "X-Auth", - configRegex: "^(.*)$", - requestHeaderName: "X-Auth-Not", - requestHeaderValue: "foo", - expectedAuthor: "", - }, - { - configHeader: "", - configRegex: "^(.*)$", - requestHeaderName: "X-Auth", - requestHeaderValue: "foo", - expectedAuthor: "", - }, - { - configHeader: "X-Auth", - configRegex: "", - requestHeaderName: "X-Auth", - requestHeaderValue: "foo", - expectedAuthor: "", - }, - { - configHeader: "X-Auth", - configRegex: "^.*$", - requestHeaderName: "X-Auth", - requestHeaderValue: "foo", - expectedAuthor: "", - }, - } - - mockConfig() - for _, testCase := range testCases { - config.Config.SilenceForm.Author.PopulateFromHeader.Header = testCase.configHeader - config.Config.SilenceForm.Author.PopulateFromHeader.ValueRegex = testCase.configRegex - - r := ginTestEngine() - req := httptest.NewRequest("GET", "/alerts.json", nil) - req.Header.Set(testCase.requestHeaderName, testCase.requestHeaderValue) - - resp := httptest.NewRecorder() - r.ServeHTTP(resp, req) - if resp.Code != http.StatusOK { - t.Errorf("GET /alerts.json returned status %d", resp.Code) - } - ur := models.AlertsResponse{} - body := resp.Body.Bytes() - err := json.Unmarshal(body, &ur) - if err != nil { - t.Errorf("Failed to unmarshal response: %s", err) - } - if ur.Settings.SilenceForm.Author != testCase.expectedAuthor { - t.Errorf("Expected author '%s', got '%s', test case: %+v", testCase.expectedAuthor, ur.Settings.SilenceForm.Author, testCase) - } - } -} - func TestSilences(t *testing.T) { type silenceTestCase struct { searchTerm string @@ -804,7 +724,6 @@ func TestEmptySettings(t *testing.T) { Strip: models.SilenceFormStripSettings{ Labels: []string{}, }, - Author: "", }, AlertAcknowledgement: models.AlertAcknowledgementSettings{ Enabled: false, diff --git a/demo/karma.yaml b/demo/karma.yaml index 6133589b2..2b7df5c0c 100644 --- a/demo/karma.yaml +++ b/demo/karma.yaml @@ -75,10 +75,6 @@ silences: - regex: "(DEVOPS-[0-9]+)" uriTemplate: https://jira.example.com/browse/$1 silenceForm: - author: - populate_from_header: - header: "CF-RAY" - value_re: "^(.+)$" strip: labels: - job diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 34744fd68..c074ba25a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -892,45 +892,24 @@ sentry: ## Silence form `silenceForm` section allows customizing silence form behavior. -`author:populate_from_header` subsection allows to configure fetching of author -name used on the silence form from the request header. It can be used with -setups where karma is deployed behind authentication proxy that adds some extra -headers with username for all requests received by karma. Syntax: ```YAML silenceForm: - author: - populate_from_header: - header: string - value_re: string strip: labels: list of strings ``` -- `author:populate_from_header:header` - name of the header to read the username - from -- `author:populate_from_header:value_re` - - [regex](https://golang.org/s/re2syntax) used to extract the username from the - request header. It must include one numbered capturing group, whatever is - matched by that group will be used as the silence form author field. Both - `header` and `value_re` must be set for this feature to work. - `strip:labels` - list of labels to ignore when populating silence form from individual alerts or group of alerts. This allows to create silences matching only unique labels, like `instance` or `host`, ignoring any common labels like `job`. -Example where `job` label won't be auto populated onto the silence form and -where the `X-Auth` header with value `User foobar` will set the default silence -author to `foobar`. +Example where `job` label won't be auto populated in the silence form. ```YAML silenceForm: - author: - populate_from_header: - header: X-Auth - value_re: ^User (.+)$ strip: labels: - job diff --git a/internal/config/config.go b/internal/config/config.go index feead3c3e..6dda2fb42 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -103,8 +103,6 @@ func SetupFlags(f *pflag.FlagSet) { "List of receivers to not display alerts for") f.StringSlice("silenceform.strip.labels", []string{}, "List of labels to ignore when auto-filling silence form from alerts") - f.String("silenceform.author.populate_from_header.header", "", "Header to read the default silence author from") - f.String("silenceform.author.populate_from_header.value_re", "", "Header value regex to read the default silence author") f.String("listen.address", "", "IP/Hostname to listen on") f.Int("listen.port", 8080, "HTTP port to listen on") @@ -291,18 +289,6 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string { config.Authentication.Enabled = true } - if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" { - _, err = regexp.Compile(config.SilenceForm.Author.PopulateFromHeader.ValueRegex) - if err != nil { - log.Fatalf("Invalid regex for silenceform.author.populate_from_header.value_re: %s", err.Error()) - } - if config.SilenceForm.Author.PopulateFromHeader.Header == "" { - log.Fatalf("silenceform.author.populate_from_header.header is required when silenceform.author.populate_from_header.value_re is set") - } - } else if config.SilenceForm.Author.PopulateFromHeader.Header != "" { - log.Fatalf("silenceform.author.populate_from_header.value_re is required when silenceform.author.populate_from_header.header is set") - } - if !slices.StringInSlice([]string{"omit", "include", "same-origin"}, config.Alertmanager.CORS.Credentials) { log.Fatalf("Invalid alertmanager.cors.credentials value '%s', allowed options: omit, inclue, same-origin", config.Alertmanager.CORS.Credentials) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b4a70d185..5fefc29bb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -109,10 +109,6 @@ silences: linkDetect: rules: [] silenceForm: - author: - populate_from_header: - header: "" - value_re: "" strip: labels: [] ui: @@ -255,25 +251,6 @@ func TestLogValues(t *testing.T) { Config.LogValues() } -func TestInvalidSilenceFormRegex(t *testing.T) { - resetEnv() - os.Setenv("SILENCEFORM_AUTHOR_POPULATE_FROM_HEADER_VALUE_RE", ".****") - - log.SetLevel(log.PanicLevel) - defer func() { log.StandardLogger().ExitFunc = nil }() - var wasFatal bool - log.StandardLogger().ExitFunc = func(int) { wasFatal = true } - - mockConfigRead() - - if Config.SilenceForm.Author.PopulateFromHeader.ValueRegex != ".****" { - t.Errorf("Config.SilenceForm.Author.PopulateFromHeader.ValueRegex value is %q", Config.SilenceForm.Author.PopulateFromHeader.ValueRegex) - } - if !wasFatal { - t.Error("Invalid silence form regex didn't cause log.Fatal()") - } -} - func TestInvalidGridSortingOrder(t *testing.T) { resetEnv() os.Setenv("GRID_SORTING_ORDER", "foo") diff --git a/internal/config/models.go b/internal/config/models.go index 61b030ab1..5ca16d8bf 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -139,12 +139,6 @@ type configSchema struct { } `yaml:"comments"` } `yaml:"silences"` SilenceForm struct { - Author struct { - PopulateFromHeader struct { - Header string `yaml:"header" koanf:"header"` - ValueRegex string `yaml:"value_re" koanf:"value_re"` - } `yaml:"populate_from_header" koanf:"populate_from_header"` - } `yaml:"author"` Strip struct { Labels []string } diff --git a/internal/models/api.go b/internal/models/api.go index f6269ea96..56cfe1631 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -274,8 +274,7 @@ type SilenceFormStripSettings struct { } type SilenceFormSettings struct { - Strip SilenceFormStripSettings `json:"strip"` - Author string `json:"author"` + Strip SilenceFormStripSettings `json:"strip"` } type AlertAcknowledgementSettings struct { From caded5f465c3087bcc1c0657e1e192e60b6eed29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Sat, 22 Feb 2020 15:47:50 +0000 Subject: [PATCH 6/6] fix(ui): remove silenceForm.author usage in the UI --- ui/src/Components/AlertAck/index.js | 4 +-- ui/src/Components/AlertAck/index.test.js | 26 +++---------------- ui/src/Components/SilenceModal/SilenceForm.js | 5 ---- .../SilenceModal/SilenceForm.test.js | 19 -------------- ui/src/Stores/AlertStore.js | 1 - 5 files changed, 4 insertions(+), 51 deletions(-) diff --git a/ui/src/Components/AlertAck/index.js b/ui/src/Components/AlertAck/index.js index 0db2fb741..766395d9f 100644 --- a/ui/src/Components/AlertAck/index.js +++ b/ui/src/Components/AlertAck/index.js @@ -173,9 +173,7 @@ const AlertAck = observer( } let author = - alertStore.settings.values.silenceForm.author !== "" - ? alertStore.settings.values.silenceForm.author - : silenceFormStore.data.author !== "" + silenceFormStore.data.author !== "" ? toJS(silenceFormStore.data.author) : toJS(alertStore.settings.values.alertAcknowledgement.author); diff --git a/ui/src/Components/AlertAck/index.test.js b/ui/src/Components/AlertAck/index.test.js index 14f04daa7..d0f772c79 100644 --- a/ui/src/Components/AlertAck/index.test.js +++ b/ui/src/Components/AlertAck/index.test.js @@ -192,7 +192,6 @@ describe("", () => { it("uses author from authentication info when auth is enabled", () => { alertStore.info.authentication.enabled = true; alertStore.info.authentication.username = "auth@example.com"; - alertStore.settings.values.silenceForm.author = "john@example.com"; alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; alertStore.settings.values.alertAcknowledgement.author = "me"; alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:"; @@ -210,27 +209,9 @@ describe("", () => { }); }); - it("uses author from alertStore if present", () => { - alertStore.settings.values.silenceForm.author = "john@example.com"; - alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; - alertStore.settings.values.alertAcknowledgement.author = "me"; - alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:"; - MountAndClick(); - expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ - comment: - "FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000", - createdBy: "john@example.com", - endsAt: "2000-02-01T00:03:42.000Z", - matchers: [ - { isRegex: false, name: "alertname", value: "Fake Alert" }, - { isRegex: true, name: "foo", value: "(bar|baz)" } - ], - startsAt: "2000-02-01T00:00:00.000Z" - }); - }); - - it("uses author from silenceFormStore if alertStore is empty", () => { - alertStore.settings.values.silenceForm.author = ""; + it("uses author from silenceFormStore if authentication is disabled", () => { + alertStore.info.authentication.enabled = false; + alertStore.info.authentication.username = "wrong"; alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; alertStore.settings.values.alertAcknowledgement.author = "me"; alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:"; @@ -250,7 +231,6 @@ describe("", () => { }); it("uses default author as fallback", () => { - alertStore.settings.values.silenceForm.author = ""; alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; alertStore.settings.values.alertAcknowledgement.author = "me"; alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:"; diff --git a/ui/src/Components/SilenceModal/SilenceForm.js b/ui/src/Components/SilenceModal/SilenceForm.js index e6a72dfe3..b7aaa7e05 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.js +++ b/ui/src/Components/SilenceModal/SilenceForm.js @@ -69,11 +69,6 @@ const SilenceForm = observer( populateAuthor = action(() => { const { alertStore, silenceFormStore, settingsStore } = this.props; - if (alertStore.settings.values.silenceForm.author !== "") { - settingsStore.silenceFormConfig.config.author = - alertStore.settings.values.silenceForm.author; - } - if (silenceFormStore.data.author === "") { silenceFormStore.data.author = settingsStore.silenceFormConfig.config.author; diff --git a/ui/src/Components/SilenceModal/SilenceForm.test.js b/ui/src/Components/SilenceModal/SilenceForm.test.js index 40734a056..ae8d7aa14 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.test.js +++ b/ui/src/Components/SilenceModal/SilenceForm.test.js @@ -145,25 +145,6 @@ describe(" inputs", () => { expect(silenceFormStore.data.author).toBe("foo@example.com"); }); - it("default author value comes from the API response if present", () => { - alertStore.settings.values.silenceForm.author = "bar@example.com"; - settingsStore.silenceFormConfig.config.author = "foo@example.com"; - const tree = MountedSilenceForm(); - const input = tree.find("input[placeholder='Author']"); - expect(input.props().value).toBe("bar@example.com"); - }); - - it("author value from the API response is saved to the Settings store", () => { - alertStore.settings.values.silenceForm.author = "bar@example.com"; - settingsStore.silenceFormConfig.config.author = ""; - const tree = MountedSilenceForm(); - const input = tree.find("input[placeholder='Author']"); - expect(input.props().value).toBe("bar@example.com"); - expect(settingsStore.silenceFormConfig.config.author).toBe( - "bar@example.com" - ); - }); - it("default author value is empty if nothing is stored in Settings", () => { settingsStore.silenceFormConfig.config.author = ""; const tree = MountedSilenceForm(); diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 9bbffedad..84c948377 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -258,7 +258,6 @@ class AlertStore { valueMapping: {} }, silenceForm: { - author: "", strip: { labels: [] }