mirror of
https://github.com/prymitive/karma
synced 2026-05-05 03:16:51 +00:00
Merge pull request #1461 from prymitive/auth
feat(backend): add auth support
This commit is contained in:
@@ -67,6 +67,25 @@ func customJS(c *gin.Context) {
|
||||
serveFileOr404(config.Config.Custom.JS, "application/javascript", c)
|
||||
}
|
||||
|
||||
func headerAuth(name, valueRegex string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user := c.Request.Header.Get(name)
|
||||
if user == "" {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
r := regexp.MustCompile("^" + valueRegex + "$")
|
||||
matches := r.FindAllStringSubmatch(user, 1)
|
||||
if len(matches) > 0 && len(matches[0]) > 1 {
|
||||
c.Set(gin.AuthUserKey, matches[0][1])
|
||||
} else {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupRouter(router *gin.Engine) {
|
||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
|
||||
@@ -98,16 +117,33 @@ func setupRouter(router *gin.Engine) {
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
}))
|
||||
|
||||
router.GET(getViewURL("/"), index)
|
||||
router.GET(getViewURL("/health"), pong)
|
||||
router.GET(getViewURL("/alerts.json"), alerts)
|
||||
router.GET(getViewURL("/autocomplete.json"), autocomplete)
|
||||
router.GET(getViewURL("/labelNames.json"), knownLabelNames)
|
||||
router.GET(getViewURL("/labelValues.json"), knownLabelValues)
|
||||
router.GET(getViewURL("/silences.json"), silences)
|
||||
var protected *gin.RouterGroup
|
||||
if config.Config.Authentication.Header.Name != "" {
|
||||
config.Config.Authentication.Enabled = true
|
||||
protected = router.Group(getViewURL("/"),
|
||||
headerAuth(config.Config.Authentication.Header.Name, config.Config.Authentication.Header.ValueRegex))
|
||||
} else if len(config.Config.Authentication.BasicAuth.Users) > 0 {
|
||||
config.Config.Authentication.Enabled = true
|
||||
users := map[string]string{}
|
||||
for _, u := range config.Config.Authentication.BasicAuth.Users {
|
||||
users[u.Username] = u.Password
|
||||
}
|
||||
protected = router.Group(getViewURL("/"), gin.BasicAuth(users))
|
||||
} else {
|
||||
protected = router.Group(getViewURL("/"))
|
||||
}
|
||||
|
||||
router.GET(getViewURL("/custom.css"), customCSS)
|
||||
router.GET(getViewURL("/custom.js"), customJS)
|
||||
router.GET(getViewURL("/health"), pong)
|
||||
|
||||
protected.GET("/", index)
|
||||
protected.GET("/alerts.json", alerts)
|
||||
protected.GET("/autocomplete.json", autocomplete)
|
||||
protected.GET("/labelNames.json", knownLabelNames)
|
||||
protected.GET("/labelValues.json", knownLabelValues)
|
||||
protected.GET("/silences.json", silences)
|
||||
|
||||
protected.GET("/custom.css", customCSS)
|
||||
protected.GET("/custom.js", customJS)
|
||||
|
||||
router.NoRoute(notFound)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func mainShoulFail() int {
|
||||
defer func() { log.StandardLogger().ExitFunc = nil }()
|
||||
var wasFatal bool
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
wasFatal = true
|
||||
}
|
||||
}()
|
||||
defer func() { log.StandardLogger().ExitFunc = nil }()
|
||||
log.StandardLogger().ExitFunc = func(int) { wasFatal = true }
|
||||
|
||||
_, err := mainSetup(pflag.ContinueOnError)
|
||||
|
||||
14
cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt
Normal file
14
cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Raises an error if basic auth credentials are missing
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.basicAuth.users require both username and password to be set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- foo: bar
|
||||
14
cmd/karma/tests/testscript/auth_basicAuth_no_password.txt
Normal file
14
cmd/karma/tests/testscript/auth_basicAuth_no_password.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Raises an error if basic auth password is missing
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.basicAuth.users require both username and password to be set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- username: me
|
||||
14
cmd/karma/tests/testscript/auth_basicAuth_no_username.txt
Normal file
14
cmd/karma/tests/testscript/auth_basicAuth_no_username.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Raises an error if basic auth username is missing
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.basicAuth.users require both username and password to be set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- password: foo
|
||||
18
cmd/karma/tests/testscript/auth_header_and_basicAuth.txt
Normal file
18
cmd/karma/tests/testscript/auth_header_and_basicAuth.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
# Raises an error if both header & basic auth authentication is enabled
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="Both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
header:
|
||||
name: "foo"
|
||||
value_re: ".+"
|
||||
basicAuth:
|
||||
users:
|
||||
- username: me
|
||||
password: foo
|
||||
13
cmd/karma/tests/testscript/auth_header_no_name.txt
Normal file
13
cmd/karma/tests/testscript/auth_header_no_name.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# Raises an error if header authentication config is missing name
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.header.name is required when authentication.header.value_re is set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
header:
|
||||
value_re: ".+"
|
||||
13
cmd/karma/tests/testscript/auth_header_no_regex.txt
Normal file
13
cmd/karma/tests/testscript/auth_header_no_regex.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# Raises an error if header authentication config is missing regex rule
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="authentication.header.value_re is required when authentication.header.name is set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
header:
|
||||
name: "foo"
|
||||
14
cmd/karma/tests/testscript/auth_header_regex_invalid.txt
Normal file
14
cmd/karma/tests/testscript/auth_header_regex_invalid.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# Raises an error if header authentication config contains invalid regex rule
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="Invalid regex for authentication.header.value_re: error parsing regexp: invalid nested repetition operator: `\+\+`"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
authentication:
|
||||
header:
|
||||
name: "foo"
|
||||
value_re: ".++***"
|
||||
@@ -69,6 +69,12 @@ cmp stderr expected.stderr
|
||||
-- expected.stderr --
|
||||
level=info msg="Version: dev"
|
||||
level=info msg="Parsed configuration:"
|
||||
level=info msg="authentication:"
|
||||
level=info msg=" header:"
|
||||
level=info msg=" name: \"\""
|
||||
level=info msg=" value_re: \"\""
|
||||
level=info msg=" basicAuth:"
|
||||
level=info msg=" users: []"
|
||||
level=info msg="alertmanager:"
|
||||
level=info msg=" interval: 10s"
|
||||
level=info msg=" servers:"
|
||||
@@ -161,10 +167,6 @@ level=info msg=" comments:"
|
||||
level=info msg=" linkDetect:"
|
||||
level=info msg=" rules: []"
|
||||
level=info msg="silenceForm:"
|
||||
level=info msg=" author:"
|
||||
level=info msg=" populate_from_header:"
|
||||
level=info msg=" header: CF-RAY"
|
||||
level=info msg=" value_re: ^(.+)$"
|
||||
level=info msg=" strip:"
|
||||
level=info msg=" labels:"
|
||||
level=info msg=" - job"
|
||||
|
||||
@@ -4,6 +4,13 @@ karma.bin-should-work --config.file=custom.yaml --check-config
|
||||
cmp stderr expected.stderr
|
||||
|
||||
-- custom.yaml --
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- username: number
|
||||
password: 1234
|
||||
- username: string
|
||||
password: '1234'
|
||||
alertmanager:
|
||||
interval: 10s
|
||||
servers:
|
||||
@@ -121,10 +128,6 @@ silences:
|
||||
- regex: "(DEVOPS-[0-9]+)"
|
||||
uriTemplate: https://jira.example.com/browse/$1
|
||||
silenceForm:
|
||||
author:
|
||||
populate_from_header:
|
||||
header: "CF-RAY"
|
||||
value_re: "^(.+)$"
|
||||
strip:
|
||||
labels:
|
||||
- job
|
||||
@@ -231,6 +234,16 @@ FLR1flnW2lx5o5csDzTpi+jgC6nu1zE0DWo1c5ZdpVO289POIpqh
|
||||
level=info msg="Reading configuration file custom.yaml"
|
||||
level=info msg="Version: dev"
|
||||
level=info msg="Parsed configuration:"
|
||||
level=info msg="authentication:"
|
||||
level=info msg=" header:"
|
||||
level=info msg=" name: \"\""
|
||||
level=info msg=" value_re: \"\""
|
||||
level=info msg=" basicAuth:"
|
||||
level=info msg=" users:"
|
||||
level=info msg=" - username: number"
|
||||
level=info msg=" password: '***'"
|
||||
level=info msg=" - username: string"
|
||||
level=info msg=" password: '***'"
|
||||
level=info msg="alertmanager:"
|
||||
level=info msg=" interval: 10s"
|
||||
level=info msg=" servers:"
|
||||
@@ -389,10 +402,6 @@ level=info msg=" rules:"
|
||||
level=info msg=" - regex: (DEVOPS-[0-9]+)"
|
||||
level=info msg=" uriTemplate: https://jira.example.com/browse/$1"
|
||||
level=info msg="silenceForm:"
|
||||
level=info msg=" author:"
|
||||
level=info msg=" populate_from_header:"
|
||||
level=info msg=" header: CF-RAY"
|
||||
level=info msg=" value_re: ^(.+)$"
|
||||
level=info msg=" strip:"
|
||||
level=info msg=" labels:"
|
||||
level=info msg=" - job"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Raises an error if silence form populate from header config is using invalid regex rule
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="Invalid regex for silenceform.author.populate_from_header.value_re: error parsing regexp: invalid nested repetition operator: `\+\+`"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
silenceForm:
|
||||
author:
|
||||
populate_from_header:
|
||||
header: "CF-RAY"
|
||||
value_re: ".++++"
|
||||
@@ -1,14 +0,0 @@
|
||||
# Raises an error if silence form populate from header config is missing header name
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="silenceform.author.populate_from_header.header is required when silenceform.author.populate_from_header.value_re is set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
silenceForm:
|
||||
author:
|
||||
populate_from_header:
|
||||
value_re: "^(.+)$"
|
||||
@@ -1,14 +0,0 @@
|
||||
# Raises an error if silence form populate from header config is missing regex rule
|
||||
karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml
|
||||
! stdout .
|
||||
stderr 'msg="silenceform.author.populate_from_header.value_re is required when silenceform.author.populate_from_header.header is set"'
|
||||
|
||||
-- karma.yaml --
|
||||
alertmanager:
|
||||
servers:
|
||||
- name: default
|
||||
uri: https://localhost:9093
|
||||
silenceForm:
|
||||
author:
|
||||
populate_from_header:
|
||||
header: "CF-RAY"
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -122,27 +121,17 @@ 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)
|
||||
start := time.Now()
|
||||
ts, _ := start.UTC().MarshalText()
|
||||
|
||||
var username string
|
||||
if config.Config.Authentication.Enabled {
|
||||
username = c.MustGet(gin.AuthUserKey).(string)
|
||||
}
|
||||
|
||||
// initialize response object, set fields that don't require any locking
|
||||
resp := models.AlertsResponse{}
|
||||
resp.Status = "success"
|
||||
@@ -163,7 +152,6 @@ 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,
|
||||
},
|
||||
@@ -175,6 +163,10 @@ func alerts(c *gin.Context) {
|
||||
CommentPrefix: config.Config.AlertAcknowledgement.CommentPrefix,
|
||||
},
|
||||
}
|
||||
resp.Authentication = models.AuthenticationInfo{
|
||||
Enabled: config.Config.Authentication.Enabled,
|
||||
Username: username,
|
||||
}
|
||||
|
||||
if config.Config.Grid.Sorting.CustomValues.Labels != nil {
|
||||
resp.Settings.Sorting.ValueMapping = config.Config.Grid.Sorting.CustomValues.Labels
|
||||
@@ -200,6 +192,7 @@ func alerts(c *gin.Context) {
|
||||
}
|
||||
newResp.Settings = resp.Settings
|
||||
newResp.Timestamp = string(ts)
|
||||
newResp.Authentication = resp.Authentication
|
||||
newData, err := json.Marshal(&newResp)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
|
||||
@@ -551,86 +551,6 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSilences(t *testing.T) {
|
||||
type silenceTestCase struct {
|
||||
searchTerm string
|
||||
@@ -804,7 +724,6 @@ func TestEmptySettings(t *testing.T) {
|
||||
Strip: models.SilenceFormStripSettings{
|
||||
Labels: []string{},
|
||||
},
|
||||
Author: "",
|
||||
},
|
||||
AlertAcknowledgement: models.AlertAcknowledgementSettings{
|
||||
Enabled: false,
|
||||
@@ -818,3 +737,150 @@ func TestEmptySettings(t *testing.T) {
|
||||
t.Errorf("Wrong settings returned (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthentication(t *testing.T) {
|
||||
type authTest struct {
|
||||
name string
|
||||
headerName string
|
||||
headerRe string
|
||||
basicAuthUsers []config.AuthenticationUser
|
||||
requestHeaders map[string]string
|
||||
requestBasicAuthUser string
|
||||
requestBasicAuthPassword string
|
||||
responseCode int
|
||||
responseUsername string
|
||||
}
|
||||
|
||||
authTests := []authTest{
|
||||
{
|
||||
name: "basic auth, request without credentials, 401",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "basic auth, missing password, 401",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
requestBasicAuthUser: "john",
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "basic auth, missing username, 401",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
requestBasicAuthPassword: "foobar",
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "basic auth, wrong password, 401",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
requestBasicAuthUser: "john",
|
||||
requestBasicAuthPassword: "foobarx",
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "basic auth, correct credentials, 200",
|
||||
basicAuthUsers: []config.AuthenticationUser{
|
||||
{Username: "john", Password: "foobar"},
|
||||
},
|
||||
requestBasicAuthUser: "john",
|
||||
requestBasicAuthPassword: "foobar",
|
||||
responseCode: 200,
|
||||
responseUsername: "john",
|
||||
},
|
||||
{
|
||||
name: "header auth, missing header, 401",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "(.+)",
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "header auth, header value doesn't match, 401",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "Username (.+)",
|
||||
requestHeaders: map[string]string{
|
||||
"X-Auth": "xxx",
|
||||
},
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "header auth, header value doesn't match #2, 401",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "Username (.+)",
|
||||
requestHeaders: map[string]string{
|
||||
"X-Auth": "xxx Username xxx",
|
||||
},
|
||||
responseCode: 401,
|
||||
},
|
||||
{
|
||||
name: "header auth, header correct, 200",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "(.+)",
|
||||
requestHeaders: map[string]string{
|
||||
"X-Auth": "john",
|
||||
},
|
||||
responseCode: 200,
|
||||
responseUsername: "john",
|
||||
},
|
||||
{
|
||||
name: "header auth, header correct #2, 200",
|
||||
headerName: "X-Auth",
|
||||
headerRe: "Username (.+)",
|
||||
requestHeaders: map[string]string{
|
||||
"X-Auth": "Username john",
|
||||
},
|
||||
responseCode: 200,
|
||||
responseUsername: "john",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range authTests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
config.Config.Authentication.Header.Name = testCase.headerName
|
||||
config.Config.Authentication.Header.ValueRegex = testCase.headerRe
|
||||
config.Config.Authentication.BasicAuth.Users = testCase.basicAuthUsers
|
||||
r := ginTestEngine()
|
||||
for _, path := range []string{
|
||||
"/",
|
||||
"/alerts.json",
|
||||
"/autocomplete.json?term=foo",
|
||||
"/labelNames.json",
|
||||
"/labelValues.json?name=foo",
|
||||
"/silences.json",
|
||||
"/custom.css",
|
||||
"/custom.js",
|
||||
} {
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
for k, v := range testCase.requestHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.SetBasicAuth(testCase.requestBasicAuthUser, testCase.requestBasicAuthPassword)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
if resp.Code != testCase.responseCode {
|
||||
t.Errorf("Expected %d from %s, got %d", testCase.responseCode, path, resp.Code)
|
||||
}
|
||||
|
||||
if resp.Code == 200 && path == "/alerts.json" {
|
||||
ur := models.AlertsResponse{}
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &ur)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to unmarshal response: %s", err)
|
||||
}
|
||||
if ur.Authentication.Enabled != true {
|
||||
t.Errorf("Got Authentication.Enabled=%v", ur.Authentication.Enabled)
|
||||
}
|
||||
if ur.Authentication.Username != testCase.responseUsername {
|
||||
t.Errorf("Got Authentication.Username=%s, expected %s", ur.Authentication.Username, testCase.responseUsername)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +75,6 @@ silences:
|
||||
- regex: "(DEVOPS-[0-9]+)"
|
||||
uriTemplate: https://jira.example.com/browse/$1
|
||||
silenceForm:
|
||||
author:
|
||||
populate_from_header:
|
||||
header: "CF-RAY"
|
||||
value_re: "^(.+)$"
|
||||
strip:
|
||||
labels:
|
||||
- job
|
||||
|
||||
@@ -22,6 +22,81 @@ Example with environment variables:
|
||||
CONFIG_FILE="docs/example.yaml"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
`authentication` sections allows enabling authentication support in karma.
|
||||
When set users will be required to authenticate when trying to access karma.
|
||||
There are currently two supported authentication methods:
|
||||
|
||||
- [Basic HTTP Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme).
|
||||
Karma will be performing authentication using configured list of username &
|
||||
password pairs. This method is only recommended for testing.
|
||||
- External authentication via headers. Karma doesn't perform any authentication
|
||||
itself, it is done by a frontend service (SSO or nginx reverse proxy) that
|
||||
sets a header with username on every request.
|
||||
|
||||
Only one method can be enabled in the config.
|
||||
Enabling authentication will also force silences to be created with usernames
|
||||
passed from credentials.
|
||||
|
||||
```YAML
|
||||
authentication:
|
||||
header:
|
||||
name: string
|
||||
value_re: string
|
||||
basicAuth:
|
||||
users:
|
||||
- username: string
|
||||
password: string
|
||||
```
|
||||
|
||||
- `authentication:users:header:name` - name of the header that will contain the
|
||||
username. If this header is missing from a request access will be forbidden.
|
||||
When set header authentication is used.
|
||||
- `authentication:users:header:value_re` -
|
||||
[regex](https://golang.org/s/re2syntax) used to extract the username from the
|
||||
request header value (when `authentication:users:header:name` is set).
|
||||
It must include one numbered capturing group, whatever is matched by that
|
||||
group will be used as the silence form author field.
|
||||
This option must be set when `authentication:users:header:name` is set.
|
||||
- `authentication:users` - list of users (username & password) allowed to login.
|
||||
Passwords are stored plain without any encryption.
|
||||
When set HTTP basic authentication will be used.
|
||||
|
||||
Defaults:
|
||||
|
||||
```YAML
|
||||
authentication:
|
||||
header:
|
||||
name: ""
|
||||
value_re: ""
|
||||
basicAuth:
|
||||
users: []
|
||||
```
|
||||
|
||||
Example where HTTP Basic Authentication will be used with a list of username
|
||||
and password pairs set in karma config file.
|
||||
|
||||
```YAML
|
||||
authentication:
|
||||
basicAuth:
|
||||
users:
|
||||
- username: alice
|
||||
password: secret
|
||||
- username: bob
|
||||
password: moreSecret
|
||||
```
|
||||
|
||||
Example where the `X-Auth` header will be used for authentication, raw header
|
||||
value will be used as username.
|
||||
|
||||
```YAML
|
||||
authentication:
|
||||
header:
|
||||
name: X-Auth
|
||||
value_re: ^(.+)$
|
||||
```
|
||||
|
||||
### Alertmanagers
|
||||
|
||||
`alertmanager` section allows setting Alertmanager servers that should be
|
||||
@@ -817,45 +892,24 @@ sentry:
|
||||
## Silence form
|
||||
|
||||
`silenceForm` section allows 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 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`.
|
||||
Example where `job` label won't be auto populated in the silence form.
|
||||
|
||||
```YAML
|
||||
silenceForm:
|
||||
author:
|
||||
populate_from_header:
|
||||
header: X-Auth
|
||||
value_re: ^User (.+)$
|
||||
strip:
|
||||
labels:
|
||||
- job
|
||||
|
||||
@@ -103,8 +103,6 @@ func SetupFlags(f *pflag.FlagSet) {
|
||||
"List of receivers to not display alerts for")
|
||||
|
||||
f.StringSlice("silenceform.strip.labels", []string{}, "List of labels to ignore when auto-filling silence form from alerts")
|
||||
f.String("silenceform.author.populate_from_header.header", "", "Header to read the default silence author from")
|
||||
f.String("silenceform.author.populate_from_header.value_re", "", "Header value regex to read the default silence author")
|
||||
|
||||
f.String("listen.address", "", "IP/Hostname to listen on")
|
||||
f.Int("listen.port", 8080, "HTTP port to listen on")
|
||||
@@ -265,16 +263,30 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string {
|
||||
config.SilenceForm.Strip.Labels = []string{}
|
||||
}
|
||||
|
||||
if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" {
|
||||
_, err = regexp.Compile(config.SilenceForm.Author.PopulateFromHeader.ValueRegex)
|
||||
if config.Authentication.Header.Name != "" && len(config.Authentication.BasicAuth.Users) > 0 {
|
||||
log.Fatalf("Both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled")
|
||||
}
|
||||
|
||||
if config.Authentication.Header.ValueRegex != "" {
|
||||
_, err = regexp.Compile(config.Authentication.Header.ValueRegex)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid regex for silenceform.author.populate_from_header.value_re: %s", err.Error())
|
||||
log.Fatalf("Invalid regex for authentication.header.value_re: %s", err.Error())
|
||||
}
|
||||
if config.SilenceForm.Author.PopulateFromHeader.Header == "" {
|
||||
log.Fatalf("silenceform.author.populate_from_header.header is required when silenceform.author.populate_from_header.value_re is set")
|
||||
if config.Authentication.Header.Name == "" {
|
||||
log.Fatalf("authentication.header.name is required when authentication.header.value_re is set")
|
||||
}
|
||||
} else if config.SilenceForm.Author.PopulateFromHeader.Header != "" {
|
||||
log.Fatalf("silenceform.author.populate_from_header.value_re is required when silenceform.author.populate_from_header.header is set")
|
||||
} else if config.Authentication.Header.Name != "" {
|
||||
log.Fatalf("authentication.header.value_re is required when authentication.header.name is set")
|
||||
}
|
||||
|
||||
for _, u := range config.Authentication.BasicAuth.Users {
|
||||
if u.Username == "" || u.Password == "" {
|
||||
log.Fatalf("authentication.basicAuth.users require both username and password to be set")
|
||||
}
|
||||
}
|
||||
|
||||
if config.Authentication.Header.Name != "" || len(config.Authentication.BasicAuth.Users) > 0 {
|
||||
config.Authentication.Enabled = true
|
||||
}
|
||||
|
||||
if !slices.StringInSlice([]string{"omit", "include", "same-origin"}, config.Alertmanager.CORS.Credentials) {
|
||||
@@ -345,6 +357,16 @@ func (config *configSchema) LogValues() {
|
||||
// make a copy of our config so we can edit it
|
||||
cfg := configSchema(*config)
|
||||
|
||||
auth := []AuthenticationUser{}
|
||||
for _, u := range cfg.Authentication.BasicAuth.Users {
|
||||
uu := AuthenticationUser{
|
||||
Username: u.Username,
|
||||
Password: "***",
|
||||
}
|
||||
auth = append(auth, uu)
|
||||
}
|
||||
cfg.Authentication.BasicAuth.Users = auth
|
||||
|
||||
// replace passwords in Alertmanager URIs with 'xxx'
|
||||
servers := []AlertmanagerConfig{}
|
||||
for _, s := range cfg.Alertmanager.Servers {
|
||||
|
||||
@@ -20,7 +20,13 @@ func resetEnv() {
|
||||
}
|
||||
|
||||
func testReadConfig(t *testing.T) {
|
||||
expectedConfig := `alertmanager:
|
||||
expectedConfig := `authentication:
|
||||
header:
|
||||
name: ""
|
||||
value_re: ""
|
||||
basicAuth:
|
||||
users: []
|
||||
alertmanager:
|
||||
interval: 1s
|
||||
servers:
|
||||
- name: default
|
||||
@@ -103,10 +109,6 @@ silences:
|
||||
linkDetect:
|
||||
rules: []
|
||||
silenceForm:
|
||||
author:
|
||||
populate_from_header:
|
||||
header: ""
|
||||
value_re: ""
|
||||
strip:
|
||||
labels: []
|
||||
ui:
|
||||
@@ -249,25 +251,6 @@ func TestLogValues(t *testing.T) {
|
||||
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 }
|
||||
|
||||
mockConfigRead()
|
||||
|
||||
if Config.SilenceForm.Author.PopulateFromHeader.ValueRegex != ".****" {
|
||||
t.Errorf("Config.SilenceForm.Author.PopulateFromHeader.ValueRegex value is %q", Config.SilenceForm.Author.PopulateFromHeader.ValueRegex)
|
||||
}
|
||||
if !wasFatal {
|
||||
t.Error("Invalid silence form regex didn't cause log.Fatal()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidGridSortingOrder(t *testing.T) {
|
||||
resetEnv()
|
||||
os.Setenv("GRID_SORTING_ORDER", "foo")
|
||||
|
||||
@@ -40,7 +40,22 @@ type CustomLabelColor struct {
|
||||
|
||||
type CustomLabelColors map[string][]CustomLabelColor
|
||||
|
||||
type AuthenticationUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type configSchema struct {
|
||||
Authentication struct {
|
||||
Enabled bool `yaml:"-" koanf:"-"`
|
||||
Header struct {
|
||||
Name string
|
||||
ValueRegex string `yaml:"value_re" koanf:"value_re"`
|
||||
}
|
||||
BasicAuth struct {
|
||||
Users []AuthenticationUser
|
||||
} `yaml:"basicAuth" koanf:"basicAuth"`
|
||||
}
|
||||
Alertmanager struct {
|
||||
Interval time.Duration
|
||||
Servers []AlertmanagerConfig
|
||||
@@ -124,12 +139,6 @@ type configSchema struct {
|
||||
} `yaml:"comments"`
|
||||
} `yaml:"silences"`
|
||||
SilenceForm struct {
|
||||
Author struct {
|
||||
PopulateFromHeader struct {
|
||||
Header string `yaml:"header" koanf:"header"`
|
||||
ValueRegex string `yaml:"value_re" koanf:"value_re"`
|
||||
} `yaml:"populate_from_header" koanf:"populate_from_header"`
|
||||
} `yaml:"author"`
|
||||
Strip struct {
|
||||
Labels []string
|
||||
}
|
||||
|
||||
@@ -274,8 +274,7 @@ type SilenceFormStripSettings struct {
|
||||
}
|
||||
|
||||
type SilenceFormSettings struct {
|
||||
Strip SilenceFormStripSettings `json:"strip"`
|
||||
Author string `json:"author"`
|
||||
Strip SilenceFormStripSettings `json:"strip"`
|
||||
}
|
||||
|
||||
type AlertAcknowledgementSettings struct {
|
||||
@@ -296,19 +295,25 @@ type Settings struct {
|
||||
AlertAcknowledgement AlertAcknowledgementSettings `json:"alertAcknowledgement"`
|
||||
}
|
||||
|
||||
type AuthenticationInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// AlertsResponse is the structure of JSON response UI will use to get alert data
|
||||
type AlertsResponse struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
Upstreams AlertmanagerAPISummary `json:"upstreams"`
|
||||
Silences map[string]map[string]Silence `json:"silences"`
|
||||
AlertGroups []APIAlertGroup `json:"groups"`
|
||||
TotalAlerts int `json:"totalAlerts"`
|
||||
Colors LabelsColorMap `json:"colors"`
|
||||
Filters []Filter `json:"filters"`
|
||||
Counters LabelNameStatsList `json:"counters"`
|
||||
Settings Settings `json:"settings"`
|
||||
Status string `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
Upstreams AlertmanagerAPISummary `json:"upstreams"`
|
||||
Silences map[string]map[string]Silence `json:"silences"`
|
||||
AlertGroups []APIAlertGroup `json:"groups"`
|
||||
TotalAlerts int `json:"totalAlerts"`
|
||||
Colors LabelsColorMap `json:"colors"`
|
||||
Filters []Filter `json:"filters"`
|
||||
Counters LabelNameStatsList `json:"counters"`
|
||||
Settings Settings `json:"settings"`
|
||||
Authentication AuthenticationInfo `json:"authentication"`
|
||||
}
|
||||
|
||||
// Autocomplete is the structure of autocomplete object for filter hints
|
||||
|
||||
@@ -172,6 +172,18 @@ const AlertAck = observer(
|
||||
return;
|
||||
}
|
||||
|
||||
let author =
|
||||
silenceFormStore.data.author !== ""
|
||||
? toJS(silenceFormStore.data.author)
|
||||
: toJS(alertStore.settings.values.alertAcknowledgement.author);
|
||||
|
||||
if (alertStore.info.authentication.enabled) {
|
||||
silenceFormStore.data.author = toJS(
|
||||
alertStore.info.authentication.username
|
||||
);
|
||||
author = alertStore.info.authentication.username;
|
||||
}
|
||||
|
||||
const alertmanagers = Object.entries(group.alertmanagerCount)
|
||||
.filter(([amName, alertCount]) => alertCount > 0)
|
||||
.map(([amName, _]) => amName);
|
||||
@@ -187,11 +199,7 @@ const AlertAck = observer(
|
||||
toJS(group),
|
||||
toJS(clusterMembers),
|
||||
toJS(alertStore.settings.values.alertAcknowledgement.durationSeconds),
|
||||
alertStore.settings.values.silenceForm.author !== ""
|
||||
? alertStore.settings.values.silenceForm.author
|
||||
: silenceFormStore.data.author !== ""
|
||||
? toJS(silenceFormStore.data.author)
|
||||
: toJS(alertStore.settings.values.alertAcknowledgement.author),
|
||||
author,
|
||||
toJS(alertStore.settings.values.alertAcknowledgement.commentPrefix)
|
||||
);
|
||||
this.submitState.pushSilence(clusterName, pendingSilence);
|
||||
|
||||
@@ -189,8 +189,9 @@ describe("<AlertAck />", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses author from alertStore if present", () => {
|
||||
alertStore.settings.values.silenceForm.author = "john@example.com";
|
||||
it("uses author from authentication info when auth is enabled", () => {
|
||||
alertStore.info.authentication.enabled = true;
|
||||
alertStore.info.authentication.username = "auth@example.com";
|
||||
alertStore.settings.values.alertAcknowledgement.durationSeconds = 222;
|
||||
alertStore.settings.values.alertAcknowledgement.author = "me";
|
||||
alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:";
|
||||
@@ -198,7 +199,7 @@ describe("<AlertAck />", () => {
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||
comment:
|
||||
"FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000",
|
||||
createdBy: "john@example.com",
|
||||
createdBy: "auth@example.com",
|
||||
endsAt: "2000-02-01T00:03:42.000Z",
|
||||
matchers: [
|
||||
{ isRegex: false, name: "alertname", value: "Fake Alert" },
|
||||
@@ -208,8 +209,9 @@ describe("<AlertAck />", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses author from silenceFormStore if alertStore is empty", () => {
|
||||
alertStore.settings.values.silenceForm.author = "";
|
||||
it("uses author from silenceFormStore if authentication is disabled", () => {
|
||||
alertStore.info.authentication.enabled = false;
|
||||
alertStore.info.authentication.username = "wrong";
|
||||
alertStore.settings.values.alertAcknowledgement.durationSeconds = 222;
|
||||
alertStore.settings.values.alertAcknowledgement.author = "me";
|
||||
alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:";
|
||||
@@ -229,7 +231,6 @@ describe("<AlertAck />", () => {
|
||||
});
|
||||
|
||||
it("uses default author as fallback", () => {
|
||||
alertStore.settings.values.silenceForm.author = "";
|
||||
alertStore.settings.values.alertAcknowledgement.durationSeconds = 222;
|
||||
alertStore.settings.values.alertAcknowledgement.author = "me";
|
||||
alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:";
|
||||
|
||||
@@ -81,6 +81,11 @@ const MainModalContent = observer(
|
||||
) : null}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
{alertStore.info.authentication.enabled && (
|
||||
<span className="text-muted mr-2">
|
||||
Username: {alertStore.info.authentication.username}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted">
|
||||
Version: {alertStore.info.version}
|
||||
</span>
|
||||
|
||||
@@ -96,4 +96,22 @@ describe("<MainModalContent />", () => {
|
||||
it("calls setTab('help') after clicking on the 'Help' tab", () => {
|
||||
ValidateSetTab("Help", "help");
|
||||
});
|
||||
|
||||
it("shows username when alertStore.info.authentication.enabled=true", () => {
|
||||
alertStore.info.authentication.enabled = true;
|
||||
alertStore.info.authentication.username = "me@example.com";
|
||||
const tree = mount(
|
||||
<span>
|
||||
{Wrapped(
|
||||
<MainModalContent
|
||||
alertStore={alertStore}
|
||||
settingsStore={settingsStore}
|
||||
onHide={onHide}
|
||||
expandAllOptions={true}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
expect(tree.text()).toMatch(/Username: me@example.com/);
|
||||
});
|
||||
});
|
||||
|
||||
59
ui/src/Components/SilenceModal/AuthorInput.js
Normal file
59
ui/src/Components/SilenceModal/AuthorInput.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faUser } from "@fortawesome/free-solid-svg-icons/faUser";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
|
||||
const IconInput = ({
|
||||
type,
|
||||
autoComplete,
|
||||
icon,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
...extra
|
||||
}) => (
|
||||
<div className="input-group mb-3">
|
||||
<div className="input-group-prepend">
|
||||
<span className="input-group-text">
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type={type}
|
||||
className="form-control"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
required
|
||||
autoComplete={autoComplete}
|
||||
onChange={onChange}
|
||||
{...extra}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
IconInput.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
autoComplete: PropTypes.string.isRequired,
|
||||
icon: FontAwesomeIcon.propTypes.icon.isRequired,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func
|
||||
};
|
||||
|
||||
const AuthenticatedAuthorInput = ({ alertStore }) => (
|
||||
<IconInput
|
||||
type="text"
|
||||
autoComplete="email"
|
||||
placeholder="Author"
|
||||
icon={faUser}
|
||||
value={alertStore.info.authentication.username}
|
||||
readOnly={true}
|
||||
/>
|
||||
);
|
||||
AuthenticatedAuthorInput.propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired
|
||||
};
|
||||
|
||||
export { IconInput, AuthenticatedAuthorInput };
|
||||
@@ -21,40 +21,7 @@ import { AlertManagerInput } from "./AlertManagerInput";
|
||||
import { SilenceMatch } from "./SilenceMatch";
|
||||
import { DateTimeSelect } from "./DateTimeSelect";
|
||||
import { PayloadPreview } from "./PayloadPreview";
|
||||
|
||||
const IconInput = ({
|
||||
type,
|
||||
autoComplete,
|
||||
icon,
|
||||
placeholder,
|
||||
value,
|
||||
onChange
|
||||
}) => (
|
||||
<div className="input-group mb-3">
|
||||
<div className="input-group-prepend">
|
||||
<span className="input-group-text">
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type={type}
|
||||
className="form-control"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
required
|
||||
autoComplete={autoComplete}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
IconInput.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
autoComplete: PropTypes.string.isRequired,
|
||||
icon: FontAwesomeIcon.propTypes.icon.isRequired,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
import { IconInput, AuthenticatedAuthorInput } from "./AuthorInput";
|
||||
|
||||
const SilenceForm = observer(
|
||||
class SilenceForm extends Component {
|
||||
@@ -102,15 +69,14 @@ const SilenceForm = observer(
|
||||
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;
|
||||
}
|
||||
|
||||
if (alertStore.info.authentication.enabled) {
|
||||
silenceFormStore.data.author = alertStore.info.authentication.username;
|
||||
}
|
||||
});
|
||||
|
||||
addMore = action(event => {
|
||||
@@ -177,14 +143,19 @@ const SilenceForm = observer(
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
<DateTimeSelect silenceFormStore={silenceFormStore} />
|
||||
<IconInput
|
||||
type="text"
|
||||
autoComplete="email"
|
||||
placeholder="Author"
|
||||
icon={faUser}
|
||||
value={silenceFormStore.data.author}
|
||||
onChange={this.onAuthorChange}
|
||||
/>
|
||||
{alertStore.info.authentication.enabled ? (
|
||||
<AuthenticatedAuthorInput alertStore={alertStore} />
|
||||
) : (
|
||||
<IconInput
|
||||
type="text"
|
||||
autoComplete="email"
|
||||
placeholder="Author"
|
||||
icon={faUser}
|
||||
value={silenceFormStore.data.author}
|
||||
onChange={this.onAuthorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconInput
|
||||
type="text"
|
||||
autoComplete="on"
|
||||
|
||||
@@ -127,6 +127,16 @@ describe("<SilenceForm /> preview", () => {
|
||||
});
|
||||
|
||||
describe("<SilenceForm /> inputs", () => {
|
||||
it("author is read-only when info.authentication.enabled is true", () => {
|
||||
alertStore.info.authentication.enabled = true;
|
||||
alertStore.info.authentication.username = "auth@example.com";
|
||||
const tree = MountedSilenceForm();
|
||||
const input = tree.find("input[placeholder='Author']");
|
||||
expect(input.props().readOnly).toBe(true);
|
||||
expect(input.props().value).toBe("auth@example.com");
|
||||
expect(silenceFormStore.data.author).toBe("auth@example.com");
|
||||
});
|
||||
|
||||
it("default author value comes from Settings store", () => {
|
||||
settingsStore.silenceFormConfig.config.author = "foo@example.com";
|
||||
const tree = MountedSilenceForm();
|
||||
@@ -135,25 +145,6 @@ 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();
|
||||
|
||||
@@ -215,6 +215,10 @@ class AlertStore {
|
||||
|
||||
info = observable(
|
||||
{
|
||||
authentication: {
|
||||
enabled: false,
|
||||
username: ""
|
||||
},
|
||||
totalAlerts: 0,
|
||||
version: "unknown",
|
||||
upgradeNeeded: false,
|
||||
@@ -254,7 +258,6 @@ class AlertStore {
|
||||
valueMapping: {}
|
||||
},
|
||||
silenceForm: {
|
||||
author: "",
|
||||
strip: {
|
||||
labels: []
|
||||
}
|
||||
@@ -418,7 +421,7 @@ class AlertStore {
|
||||
this.info.upgradeNeeded = true;
|
||||
}
|
||||
// update extra root level keys that are stored under 'info'
|
||||
for (const key of ["totalAlerts", "version"]) {
|
||||
for (const key of ["totalAlerts", "version", "authentication"]) {
|
||||
if (this.info[key] !== result[key]) {
|
||||
this.info[key] = result[key];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user