mirror of
https://github.com/prymitive/karma
synced 2026-05-11 03:46:48 +00:00
Merge pull request #821 from prymitive/author-from-header
feat: allow extracting silence author from auth headers
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -132,6 +132,25 @@ describe("<SilenceForm /> 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();
|
||||
|
||||
@@ -180,6 +180,7 @@ class AlertStore {
|
||||
valueMapping: {}
|
||||
},
|
||||
silenceForm: {
|
||||
author: "",
|
||||
strip: {
|
||||
labels: []
|
||||
}
|
||||
|
||||
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