feat(backend): allow extracting silence author from auth headers

This commit is contained in:
Łukasz Mierzwa
2019-07-12 21:03:27 +01:00
parent 2501bbdce4
commit 1a98e01622
7 changed files with 174 additions and 3 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()")
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}
}