Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow extracting silence author from auth headers #821

Merged
merged 4 commits into from
Jul 13, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion demo/karma.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,18 @@ labels:
color: "#ff220c"
log:
config: false
level: debug
level: warning
sentry:
private: https://84a9ef37a6ed4fdb80e9ea2310d1ed26:8c6ee6f0ab02406482ff4b4e824e2c27@sentry.io/1279017
public: https://84a9ef37a6ed4fdb80e9ea2310d1ed26@sentry.io/1279017
jira:
- regex: DEVOPS-[0-9]+
uri: https://jira.example.com
silenceForm:
author:
populate_from_header:
header: "CF-RAY"
value_re: "^(.+)$"
strip:
labels:
- job
Expand Down
24 changes: 23 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ sentry:
private: secret key
public: public key
silenceForm:
author:
populate_from_header:
header: ""
value_re: ""
strip:
labels: []
`
Expand Down Expand Up @@ -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()")
}
}
6 changes: 6 additions & 0 deletions internal/config/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion internal/models/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions ui/src/Components/SilenceModal/SilenceForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions ui/src/Components/SilenceModal/SilenceForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions ui/src/Stores/AlertStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ class AlertStore {
valueMapping: {}
},
silenceForm: {
author: "",
strip: {
labels: []
}
Expand Down
33 changes: 32 additions & 1 deletion views.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"regexp"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -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)
Expand All @@ -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,
},
Expand All @@ -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
}
Expand Down
80 changes: 80 additions & 0 deletions views_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}