diff --git a/cmd/karma/main.go b/cmd/karma/main.go
index 613c4549d..1c9d2607a 100644
--- a/cmd/karma/main.go
+++ b/cmd/karma/main.go
@@ -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)
}
diff --git a/cmd/karma/script_test.go b/cmd/karma/script_test.go
index 573339cd2..57fb265ec 100644
--- a/cmd/karma/script_test.go
+++ b/cmd/karma/script_test.go
@@ -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)
diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt
new file mode 100644
index 000000000..29d2e5122
--- /dev/null
+++ b/cmd/karma/tests/testscript/auth_basicAuth_no_credentials.txt
@@ -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
diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt
new file mode 100644
index 000000000..65606d776
--- /dev/null
+++ b/cmd/karma/tests/testscript/auth_basicAuth_no_password.txt
@@ -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
diff --git a/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt b/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt
new file mode 100644
index 000000000..0a812456f
--- /dev/null
+++ b/cmd/karma/tests/testscript/auth_basicAuth_no_username.txt
@@ -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
diff --git a/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt b/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt
new file mode 100644
index 000000000..ee1d16c85
--- /dev/null
+++ b/cmd/karma/tests/testscript/auth_header_and_basicAuth.txt
@@ -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
diff --git a/cmd/karma/tests/testscript/auth_header_no_name.txt b/cmd/karma/tests/testscript/auth_header_no_name.txt
new file mode 100644
index 000000000..b1bdd5e3b
--- /dev/null
+++ b/cmd/karma/tests/testscript/auth_header_no_name.txt
@@ -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: ".+"
diff --git a/cmd/karma/tests/testscript/auth_header_no_regex.txt b/cmd/karma/tests/testscript/auth_header_no_regex.txt
new file mode 100644
index 000000000..dd7d2e737
--- /dev/null
+++ b/cmd/karma/tests/testscript/auth_header_no_regex.txt
@@ -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"
diff --git a/cmd/karma/tests/testscript/auth_header_regex_invalid.txt b/cmd/karma/tests/testscript/auth_header_regex_invalid.txt
new file mode 100644
index 000000000..c7f15f3eb
--- /dev/null
+++ b/cmd/karma/tests/testscript/auth_header_regex_invalid.txt
@@ -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: ".++***"
diff --git a/cmd/karma/tests/testscript/log_full_config_env.txt b/cmd/karma/tests/testscript/log_full_config_env.txt
index 91012dd53..61c06774b 100644
--- a/cmd/karma/tests/testscript/log_full_config_env.txt
+++ b/cmd/karma/tests/testscript/log_full_config_env.txt
@@ -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"
diff --git a/cmd/karma/tests/testscript/log_full_config_file.txt b/cmd/karma/tests/testscript/log_full_config_file.txt
index 894d33f51..c8b410306 100644
--- a/cmd/karma/tests/testscript/log_full_config_file.txt
+++ b/cmd/karma/tests/testscript/log_full_config_file.txt
@@ -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"
diff --git a/cmd/karma/tests/testscript/silenceform_populatefromheader_invalid_regex.txt b/cmd/karma/tests/testscript/silenceform_populatefromheader_invalid_regex.txt
deleted file mode 100644
index d4d4b8f15..000000000
--- a/cmd/karma/tests/testscript/silenceform_populatefromheader_invalid_regex.txt
+++ /dev/null
@@ -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: ".++++"
diff --git a/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_header.txt b/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_header.txt
deleted file mode 100644
index 0b8c6eb76..000000000
--- a/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_header.txt
+++ /dev/null
@@ -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: "^(.+)$"
diff --git a/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_regex.txt b/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_regex.txt
deleted file mode 100644
index e9a03e2bb..000000000
--- a/cmd/karma/tests/testscript/silenceform_populatefromheader_missing_regex.txt
+++ /dev/null
@@ -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"
diff --git a/cmd/karma/views.go b/cmd/karma/views.go
index 2075d9468..3518b19c2 100644
--- a/cmd/karma/views.go
+++ b/cmd/karma/views.go
@@ -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())
diff --git a/cmd/karma/views_test.go b/cmd/karma/views_test.go
index a38a134ec..fe67d5c56 100644
--- a/cmd/karma/views_test.go
+++ b/cmd/karma/views_test.go
@@ -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)
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/demo/karma.yaml b/demo/karma.yaml
index 6133589b2..2b7df5c0c 100644
--- a/demo/karma.yaml
+++ b/demo/karma.yaml
@@ -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
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 33caa7fb6..c074ba25a 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -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
diff --git a/internal/config/config.go b/internal/config/config.go
index 883094aa9..6dda2fb42 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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 {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 610dd94d6..5fefc29bb 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -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")
diff --git a/internal/config/models.go b/internal/config/models.go
index 9a3b68f52..5ca16d8bf 100644
--- a/internal/config/models.go
+++ b/internal/config/models.go
@@ -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
}
diff --git a/internal/models/api.go b/internal/models/api.go
index 485223158..56cfe1631 100644
--- a/internal/models/api.go
+++ b/internal/models/api.go
@@ -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
diff --git a/ui/src/Components/AlertAck/index.js b/ui/src/Components/AlertAck/index.js
index 11e6d64a6..766395d9f 100644
--- a/ui/src/Components/AlertAck/index.js
+++ b/ui/src/Components/AlertAck/index.js
@@ -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);
diff --git a/ui/src/Components/AlertAck/index.test.js b/ui/src/Components/AlertAck/index.test.js
index 4a6020cfd..d0f772c79 100644
--- a/ui/src/Components/AlertAck/index.test.js
+++ b/ui/src/Components/AlertAck/index.test.js
@@ -189,8 +189,9 @@ describe("