mirror of
https://github.com/prymitive/karma
synced 2026-05-09 03:36:44 +00:00
feat(backend): allow extracting silence author from auth headers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
33
views.go
33
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user