From 1a98e01622e8a730d06f7579ba12f6d85822f3b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 12 Jul 2019 21:03:27 +0100 Subject: [PATCH 1/4] feat(backend): allow extracting silence author from auth headers --- docs/CONFIGURATION.md | 24 +++++++++- internal/config/config.go | 11 +++++ internal/config/config_test.go | 20 +++++++++ internal/config/models.go | 6 +++ internal/models/api.go | 3 +- views.go | 33 +++++++++++++- views_test.go | 80 ++++++++++++++++++++++++++++++++++ 7 files changed, 174 insertions(+), 3 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8adddaeee..fcc6df51a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -610,23 +610,45 @@ sentry: ## Silence form `silenceForm` section allow 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: +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`. ```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 e6abcd351..40a23816c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,6 +79,8 @@ func init() { "List of receivers to not display alerts for") pflag.StringSlice("silenceform.strip.labels", []string{}, "List of labels to ignore when auto-filling silence form from alerts") + pflag.String("silenceform.author.populate_from_header.header", "", "Header to read the default silence author from") + pflag.String("silenceform.author.populate_from_header.value_re", "", "Header value regex to read the default silence author") pflag.String("listen.address", "", "IP/Hostname to listen on") pflag.Int("listen.port", 8080, "HTTP port to listen on") @@ -167,6 +169,15 @@ func (config *configSchema) Read() { config.Sentry.Private = v.GetString("sentry.private") config.Sentry.Public = v.GetString("sentry.public") config.SilenceForm.Strip.Labels = v.GetStringSlice("silenceform.strip.labels") + config.SilenceForm.Author.PopulateFromHeader.Header = v.GetString("silenceform.author.populate_from_header.header") + config.SilenceForm.Author.PopulateFromHeader.ValueRegex = v.GetString("silenceform.author.populate_from_header.value_re") + + 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()) + } + } err = v.UnmarshalKey("alertmanager.servers", &config.Alertmanager.Servers) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2daa5086f..bd8176c4e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -121,6 +121,10 @@ sentry: private: secret key public: public key silenceForm: + author: + populate_from_header: + header: "" + value_re: "" strip: labels: [] ` @@ -236,3 +240,19 @@ func TestLogValues(t *testing.T) { Config.Read() 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 } + + Config.Read() + + if !wasFatal { + t.Error("Invalid silence form regex didn't cause log.Fatal()") + } +} diff --git a/internal/config/models.go b/internal/config/models.go index 1d3abf0cd..1df0acb00 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -94,6 +94,12 @@ type configSchema struct { Public string } SilenceForm struct { + Author struct { + PopulateFromHeader struct { + Header string `yaml:"header" mapstructure:"header"` + ValueRegex string `yaml:"value_re" mapstructure:"value_re"` + } `yaml:"populate_from_header" mapstructure:"populate_from_header"` + } `yaml:"author" mapstructure:"author"` Strip struct { Labels []string } diff --git a/internal/models/api.go b/internal/models/api.go index 9284d3349..57a835fab 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -270,7 +270,8 @@ type SilenceFormStripSettings struct { } type SilenceFormSettings struct { - Strip SilenceFormStripSettings `json:"strip"` + Strip SilenceFormStripSettings `json:"strip"` + Author string `json:"author"` } // Settings is used to export karma configuration that is used by UI diff --git a/views.go b/views.go index 22ab2ba4e..becb27dc6 100644 --- a/views.go +++ b/views.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "net/http" + "regexp" "sort" "strings" "time" @@ -70,6 +71,21 @@ 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) @@ -96,6 +112,7 @@ 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, }, @@ -111,7 +128,21 @@ func alerts(c *gin.Context) { data, found := apiCache.Get(cacheKey) if found { - c.Data(http.StatusOK, gin.MIMEJSON, data.([]byte)) + // need to overwrite settings as they can have user specific data + newResp := models.AlertsResponse{} + err := json.Unmarshal(data.([]byte), &newResp) + if err != nil { + log.Error(err.Error()) + panic(err) + } + newResp.Settings = resp.Settings + newResp.Timestamp = string(ts) + newData, err := json.Marshal(&newResp) + if err != nil { + log.Error(err.Error()) + panic(err) + } + c.Data(http.StatusOK, gin.MIMEJSON, newData) logAlertsView(c, "HIT", time.Since(start)) return } diff --git a/views_test.go b/views_test.go index 26a115fc5..cac05156e 100644 --- a/views_test.go +++ b/views_test.go @@ -504,3 +504,83 @@ 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) + } + } +} From 36617b2f4942f503aaeb040ae867c531dcb9d909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 12 Jul 2019 21:12:00 +0100 Subject: [PATCH 2/4] feat(ui): prefer silence author from the API response over local storage --- ui/src/Components/SilenceModal/SilenceForm.js | 15 +++++++++++++-- .../SilenceModal/SilenceForm.test.js | 19 +++++++++++++++++++ ui/src/Stores/AlertStore.js | 1 + 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ui/src/Components/SilenceModal/SilenceForm.js b/ui/src/Components/SilenceModal/SilenceForm.js index afded9f01..3987aade8 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.js +++ b/ui/src/Components/SilenceModal/SilenceForm.js @@ -78,7 +78,7 @@ const SilenceForm = observer( ); componentDidMount() { - const { silenceFormStore, settingsStore } = this.props; + const { silenceFormStore } = this.props; // reset startsAt & endsAt on every mount, unless we're editing a silence if (silenceFormStore.data.silenceID === null) { @@ -91,11 +91,22 @@ const SilenceForm = observer( silenceFormStore.data.addEmptyMatcher(); } + this.populateAuthor(); + } + + 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; } - } + }); addMore = action(event => { const { silenceFormStore } = this.props; diff --git a/ui/src/Components/SilenceModal/SilenceForm.test.js b/ui/src/Components/SilenceModal/SilenceForm.test.js index ad66db62d..667c255ad 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.test.js +++ b/ui/src/Components/SilenceModal/SilenceForm.test.js @@ -132,6 +132,25 @@ 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 b7df92ead..185a4e3b6 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -180,6 +180,7 @@ class AlertStore { valueMapping: {} }, silenceForm: { + author: "", strip: { labels: [] } From c08a5de6f2663002c7d4a4f245b2adbab4a7c7ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 12 Jul 2019 21:12:34 +0100 Subject: [PATCH 3/4] fix(demo): revert log level in demo setup --- demo/karma.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/karma.yaml b/demo/karma.yaml index 2edca4575..1f69348a7 100644 --- a/demo/karma.yaml +++ b/demo/karma.yaml @@ -53,7 +53,7 @@ labels: color: "#ff220c" log: config: false - level: debug + level: warning sentry: private: https://84a9ef37a6ed4fdb80e9ea2310d1ed26:8c6ee6f0ab02406482ff4b4e824e2c27@sentry.io/1279017 public: https://84a9ef37a6ed4fdb80e9ea2310d1ed26@sentry.io/1279017 From 609365374f78ac909e43f0e63cbfce4bc54b9ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Fri, 12 Jul 2019 22:30:21 +0100 Subject: [PATCH 4/4] chore(demo): use CF-RAY header as default silence author --- demo/karma.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/demo/karma.yaml b/demo/karma.yaml index 1f69348a7..c8c22d11b 100644 --- a/demo/karma.yaml +++ b/demo/karma.yaml @@ -61,6 +61,10 @@ jira: - regex: DEVOPS-[0-9]+ uri: https://jira.example.com silenceForm: + author: + populate_from_header: + header: "CF-RAY" + value_re: "^(.+)$" strip: labels: - job