Merge pull request #1461 from prymitive/auth

feat(backend): add auth support
This commit is contained in:
Łukasz Mierzwa
2020-02-22 16:30:45 +00:00
committed by GitHub
30 changed files with 613 additions and 320 deletions

View File

@@ -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)
}

View File

@@ -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)

View 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

View 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

View 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

View 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

View 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: ".+"

View 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"

View 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: ".++***"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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: ".++++"

View File

@@ -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: "^(.+)$"

View File

@@ -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"

View File

@@ -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())

View File

@@ -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)
}
}
}
})
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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:";

View File

@@ -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>

View File

@@ -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/);
});
});

View 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 };

View File

@@ -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"

View File

@@ -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();

View File

@@ -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];
}