From 6313077150be708b44f91c9e4896317460119670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 5 Mar 2020 19:17:40 +0000 Subject: [PATCH] feat(backend): add configuration options for silence ACL rules --- README.md | 8 + cmd/karma/acl.go | 230 ++++++++ cmd/karma/auth.go | 16 + cmd/karma/main.go | 19 + cmd/karma/proxy.go | 66 ++- cmd/karma/proxy_test.go | 505 +++++++++++++++++- cmd/karma/tests/testscript/acl_example.txt | 54 ++ .../tests/testscript/acl_invalid_file.txt | 30 ++ .../tests/testscript/acl_missing_file.txt | 27 + .../testscript/acl_rule_invalid_action.txt | 32 ++ .../acl_rule_invalid_alertmanager.txt | 35 ++ .../acl_rule_invalid_filter_name_re.txt | 36 ++ .../acl_rule_invalid_filter_value_re.txt | 36 ++ .../testscript/acl_rule_invalid_group.txt | 35 ++ ...acl_rule_missing_both_name_and_name_re.txt | 37 ++ ...l_rule_missing_both_value_and_value_re.txt | 37 ++ .../acl_rule_missing_filter_name.txt | 35 ++ .../acl_rule_missing_filter_value.txt | 35 ++ .../acl_rule_missing_matcher_name.txt | 35 ++ .../acl_rule_missing_matcher_value.txt | 35 ++ .../testscript/acl_rule_missing_reason.txt | 31 ++ .../testscript/auth_group_missing_members.txt | 17 + .../testscript/auth_group_missing_name.txt | 22 + .../tests/testscript/log_full_config_env.txt | 4 + .../tests/testscript/log_full_config_file.txt | 20 +- .../tests/testscript/upper_case_keys.txt | 6 +- demo/Dockerfile | 1 + demo/acls.yaml | 7 + demo/karma.yaml | 3 + docs/ACLs.md | 293 ++++++++++ docs/CONFIGURATION.md | 53 +- internal/config/acl.go | 59 ++ internal/config/config.go | 11 + internal/config/config_test.go | 4 + internal/config/models.go | 15 +- internal/mapper/mapper.go | 1 + internal/mapper/v017/api.go | 27 + internal/mapper/v017/silences.go | 4 + 38 files changed, 1879 insertions(+), 42 deletions(-) create mode 100644 cmd/karma/acl.go create mode 100644 cmd/karma/auth.go create mode 100644 cmd/karma/tests/testscript/acl_example.txt create mode 100644 cmd/karma/tests/testscript/acl_invalid_file.txt create mode 100644 cmd/karma/tests/testscript/acl_missing_file.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_invalid_action.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_invalid_alertmanager.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_invalid_filter_name_re.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_invalid_filter_value_re.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_invalid_group.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_missing_both_name_and_name_re.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_missing_both_value_and_value_re.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_missing_filter_name.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_missing_filter_value.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_missing_matcher_name.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_missing_matcher_value.txt create mode 100644 cmd/karma/tests/testscript/acl_rule_missing_reason.txt create mode 100644 cmd/karma/tests/testscript/auth_group_missing_members.txt create mode 100644 cmd/karma/tests/testscript/auth_group_missing_name.txt create mode 100644 demo/acls.yaml create mode 100644 docs/ACLs.md create mode 100644 internal/config/acl.go diff --git a/README.md b/README.md index 8823a0f9f..fec9305e3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ requires Alertmanager `>=0.17.0`. --- +See [GitHub Releases](https://github.com/prymitive/karma/releases) for release +changelog. + ## Feature overview Alertmanager UI is useful for browsing alerts and managing silences, but it's @@ -50,6 +53,8 @@ screen space, the silence will also be moved to the footer. Silence modal allows to create new silences and manage all silences already present in Alertmanager. +Silence ACL rules can be used to control silence creation and editing, see + [ACLs](/docs/ACLs.md) docs for more details. ![Silence browser](/docs/img/silenceBrowser.png) @@ -140,6 +145,9 @@ to modify data in Alertmanager instance, then please ensure that: for all alertmanager instances, this options will disable any UI elements that could trigger updates (like silence management) +To restrict some users from creating silences or enforce some matcher rules use +[silence ACL rules](/docs/ACLs.md). This feature requires `proxy` to be enabled. + ## Metrics karma process metrics are accessible under `/metrics` path by default. diff --git a/cmd/karma/acl.go b/cmd/karma/acl.go new file mode 100644 index 000000000..10ddb2454 --- /dev/null +++ b/cmd/karma/acl.go @@ -0,0 +1,230 @@ +package main + +import ( + "fmt" + "regexp" + + "github.com/prymitive/karma/internal/alertmanager" + "github.com/prymitive/karma/internal/config" + "github.com/prymitive/karma/internal/models" + "github.com/prymitive/karma/internal/slices" +) + +const ( + aclActionRequireMatcher = "requireMatcher" + aclActionBlock = "block" + aclActionAllow = "allow" +) + +var ( + allACLActions = []string{aclActionAllow, aclActionBlock, aclActionRequireMatcher} +) + +type silenceFilter struct { + Name string + NameRegex *regexp.Regexp + Value string + ValueRegex *regexp.Regexp + IsRegex bool +} + +func (sf *silenceFilter) isMatch(silence *models.Silence) bool { + for _, m := range silence.Matchers { + var nameMatch bool + if sf.Name != "" && sf.Name == m.Name { + nameMatch = true + } else if sf.NameRegex != nil && sf.NameRegex.MatchString(m.Name) { + nameMatch = true + } + + var valueMatch bool + if sf.Value != "" && sf.Value == m.Value { + valueMatch = true + } else if sf.ValueRegex != nil && sf.ValueRegex.MatchString(m.Value) { + valueMatch = true + } + + if nameMatch && valueMatch && sf.IsRegex == m.IsRegex { + return true + } + } + + return false +} + +type silenceMatcher struct { + Name string + Value string + IsRegex bool +} + +type aclMatchers struct { + Required []silenceMatcher +} + +type silenceACLScope struct { + Groups []string + Alertmanagers []string + Filters []silenceFilter +} + +type silenceACL struct { + Action string + Reason string + Scope silenceACLScope + Matchers aclMatchers +} + +func (acl *silenceACL) isAllowed(amName string, silence *models.Silence, username string) (bool, error) { + groups := userGroups(username) + + groupMatch := len(acl.Scope.Groups) == 0 + for _, aclGroup := range acl.Scope.Groups { + if slices.StringInSlice(groups, aclGroup) { + groupMatch = true + break + } + } + + amMatch := len(acl.Scope.Alertmanagers) == 0 + for _, aclAM := range acl.Scope.Alertmanagers { + if amName == aclAM { + amMatch = true + break + } + } + + filterMatch := len(acl.Scope.Filters) == 0 + for _, aclFilter := range acl.Scope.Filters { + filterMatch = aclFilter.isMatch(silence) + } + + if groupMatch && amMatch && filterMatch { + switch acl.Action { + case aclActionAllow: + return true, nil + case aclActionBlock: + return false, fmt.Errorf("silence blocked by ACL rule: %s", acl.Reason) + case aclActionRequireMatcher: + for _, aclM := range acl.Matchers.Required { + var wasFound bool + for _, m := range silence.Matchers { + if m.Name == aclM.Name && m.Value == aclM.Value && m.IsRegex == aclM.IsRegex { + wasFound = true + break + } + } + if !wasFound { + return false, fmt.Errorf("silence blocked by ACL rule: %s", acl.Reason) + } + } + } + } + + return false, nil +} + +func newSilenceACLFromConfig(cfg config.SilenceACLRule) (*silenceACL, error) { + acl := silenceACL{ + Action: cfg.Action, + Reason: cfg.Reason, + Scope: silenceACLScope{ + Groups: []string{}, + Alertmanagers: []string{}, + Filters: []silenceFilter{}, + }, + Matchers: aclMatchers{}, + } + + if !slices.StringInSlice(allACLActions, acl.Action) { + return nil, fmt.Errorf("silence ACL rule requires 'action' to be one of %v, got %q", allACLActions, acl.Action) + } + + if acl.Reason == "" { + return nil, fmt.Errorf("silence ACL rule requires 'reason' to be set") + } + + for _, groupName := range cfg.Scope.Groups { + var wasFound bool + for _, authGroup := range config.Config.Authorization.Groups { + if authGroup.Name == groupName { + wasFound = true + break + } + } + if !wasFound { + return nil, fmt.Errorf("invalid silence ACL rule, no group with name %q found in authorization.groups configuration", groupName) + } + acl.Scope.Groups = append(acl.Scope.Groups, groupName) + } + + for _, amName := range cfg.Scope.Alertmanagers { + am := alertmanager.GetAlertmanagerByName(amName) + if am == nil { + return nil, fmt.Errorf("invalid ACL rule, no alertmanager with name %q found", amName) + } + acl.Scope.Alertmanagers = append(acl.Scope.Alertmanagers, am.Name) + } + + for _, filter := range cfg.Scope.Filters { + if filter.Name == "" && filter.NameRegex == "" { + return nil, fmt.Errorf("silence ACL rule filter requires 'name' or 'name_re' to be set") + } + if filter.Name != "" && filter.NameRegex != "" { + return nil, fmt.Errorf("silence ACL rule filter can only have 'name' or 'name_re' set, not both") + } + + if filter.Value == "" && filter.ValueRegex == "" { + return nil, fmt.Errorf("silence ACL rule filter requires 'value' or 'value_re' to be set") + } + if filter.Value != "" && filter.ValueRegex != "" { + return nil, fmt.Errorf("silence ACL rule filter can only have 'value' or 'value_re' set, not both") + } + + f := silenceFilter{ + Name: filter.Name, + Value: filter.Value, + IsRegex: filter.IsRegex, + } + + if filter.NameRegex != "" { + re, err := regexp.Compile(filter.NameRegex) + if err != nil { + return nil, fmt.Errorf("invalid ACL rule, failed to parse name_re %q: %s", filter.NameRegex, err) + } + f.NameRegex = re + } + + if filter.ValueRegex != "" { + re, err := regexp.Compile(filter.ValueRegex) + if err != nil { + return nil, fmt.Errorf("invalid ACL rule, failed to parse value_re %q: %s", filter.ValueRegex, err) + } + f.ValueRegex = re + } + + acl.Scope.Filters = append(acl.Scope.Filters, f) + } + + if acl.Action == aclActionRequireMatcher { + for _, matcherConfig := range cfg.Matchers.Required { + if matcherConfig.Name == "" { + return nil, fmt.Errorf("silence ACL rule matcher requires 'name' to be set") + } + + if matcherConfig.Value == "" { + return nil, fmt.Errorf("silence ACL rule matcher requires 'value' to be set") + } + + m := silenceMatcher{ + Name: matcherConfig.Name, + Value: matcherConfig.Value, + IsRegex: matcherConfig.IsRegex, + } + + acl.Matchers.Required = append(acl.Matchers.Required, m) + } + } + + return &acl, nil +} diff --git a/cmd/karma/auth.go b/cmd/karma/auth.go new file mode 100644 index 000000000..6041b1a92 --- /dev/null +++ b/cmd/karma/auth.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/prymitive/karma/internal/config" + "github.com/prymitive/karma/internal/slices" +) + +func userGroups(username string) []string { + groups := []string{} + for _, authGroup := range config.Config.Authorization.Groups { + if slices.StringInSlice(authGroup.Members, username) { + groups = append(groups, authGroup.Name) + } + } + return groups +} diff --git a/cmd/karma/main.go b/cmd/karma/main.go index 4732aae52..124261c1c 100644 --- a/cmd/karma/main.go +++ b/cmd/karma/main.go @@ -50,6 +50,8 @@ var ( staticSrcFileSystem = newBinaryFileSystem("ui/src") protectedEndpoints *gin.RouterGroup + + silenceACLs = []*silenceACL{} ) func getViewURL(sub string) string { @@ -291,6 +293,23 @@ func mainSetup(errorHandling pflag.ErrorHandling) (*gin.Engine, error) { return nil, fmt.Errorf("No valid Alertmanager URIs defined") } + if config.Config.Authorization.ACL.Silences != "" { + log.Infof("Reading silence ACL config file %s", config.Config.Authorization.ACL.Silences) + aclConfig, err := config.ReadSilenceACLConfig(config.Config.Authorization.ACL.Silences) + if err != nil { + return nil, err + } + + for i, cfg := range aclConfig.Rules { + acl, err := newSilenceACLFromConfig(cfg) + if err != nil { + return nil, fmt.Errorf("Invalid silence ACL rule at position %d: %s", i, err) + } + silenceACLs = append(silenceACLs, acl) + } + log.Infof("Parsed %d ACL rule(s)", len(silenceACLs)) + } + if *validateConfig { log.Info("Configuration is valid") return nil, nil diff --git a/cmd/karma/proxy.go b/cmd/karma/proxy.go index e00a91b51..f7588f5b7 100644 --- a/cmd/karma/proxy.go +++ b/cmd/karma/proxy.go @@ -75,38 +75,62 @@ func NewAlertmanagerProxy(alertmanager *alertmanager.Alertmanager) (*httputil.Re func handlePostRequest(alertmanager *alertmanager.Alertmanager, h http.Handler) gin.HandlerFunc { return func(c *gin.Context) { log.Debugf("[%s] Proxy request %s", alertmanager.Name, c.Request.RequestURI) + + body, err := ioutil.ReadAll(c.Request.Body) + c.Request.Body.Close() + if err != nil { + log.Errorf("[%s] proxy request '%s %s' body close failed: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + ver := alertmanager.Version() + if ver == "" { + ver = "999.0" + } + + m, err := mapper.GetSilenceMapper(ver) + if err != nil { + log.Errorf("[%s] proxy request '%s %s' error: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + silence, err := m.Unmarshal(body) + if err != nil { + log.Errorf("[%s] proxy request '%s %s' failed to unmarshal silence body: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + for i, acl := range silenceACLs { + username := c.GetString(gin.AuthUserKey) + isAllowed, err := acl.isAllowed(alertmanager.Name, silence, username) + log.Debugf("ACL %d: isAllowed=%v err=%v", i, isAllowed, err) + if err != nil { + log.Warningf("[%s] proxy request '%s %s' was blocked by ACL rule: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) + c.String(http.StatusBadRequest, err.Error()) + return + } + if isAllowed { + break + } + } + if config.Config.Authentication.Enabled { - body, err := ioutil.ReadAll(c.Request.Body) - c.Request.Body.Close() - if err != nil { - log.Errorf("[%s] proxy request '%s %s' body close failed: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - ver := alertmanager.Version() - if ver == "" { - ver = "999.0" - } - username := c.MustGet(gin.AuthUserKey).(string) - m, err := mapper.GetSilenceMapper(ver) - if err != nil { - log.Errorf("[%s] proxy request '%s %s' error: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - newBody, err := m.RewriteUsername(body, username) if err != nil { log.Errorf("[%s] proxy request '%s %s' silence body rewrite error: %s", alertmanager.Name, c.Request.Method, c.Request.RequestURI, err) - c.AbortWithStatus(http.StatusInternalServerError) + c.String(http.StatusInternalServerError, err.Error()) return } c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(newBody)) c.Request.ContentLength = int64(len(newBody)) c.Request.Header.Set("Content-Length", fmt.Sprintf("%d", c.Request.ContentLength)) + } else { + c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) } h.ServeHTTP(c.Writer, c.Request) diff --git a/cmd/karma/proxy_test.go b/cmd/karma/proxy_test.go index 9005b736f..9310c5b43 100644 --- a/cmd/karma/proxy_test.go +++ b/cmd/karma/proxy_test.go @@ -6,6 +6,8 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "regexp" + "strings" "testing" "time" @@ -93,6 +95,14 @@ var proxyTests = []proxyTest{ } func TestProxy(t *testing.T) { + dummySilence := `{ + "comment": "comment", + "createdBy": "username", + "startsAt": "2000-02-01T00:00:00.000Z", + "endsAt": "2000-02-01T00:02:03.000Z", + "matchers": [{ "isRegex": false, "name": "alertname", "value": "Fake Alert" }] +}` + r := ginTestEngine() am, err := alertmanager.NewAlertmanager( "dummy", @@ -116,7 +126,7 @@ func TestProxy(t *testing.T) { if testCase.upstreamURI != "" { httpmock.RegisterResponder(testCase.method, testCase.upstreamURI, httpmock.NewStringResponder(testCase.code, testCase.response)) } - req := httptest.NewRequest(testCase.method, testCase.localPath, nil) + req := httptest.NewRequest(testCase.method, testCase.localPath, strings.NewReader(dummySilence)) resp := newCloseNotifyingRecorder() r.ServeHTTP(resp, req) if resp.Code != testCase.code { @@ -182,6 +192,14 @@ var proxyHeaderTests = []proxyHeaderTest{ } func TestProxyHeaders(t *testing.T) { + dummySilence := `{ + "comment": "comment", + "createdBy": "username", + "startsAt": "2000-02-01T00:00:00.000Z", + "endsAt": "2000-02-01T00:02:03.000Z", + "matchers": [{ "isRegex": false, "name": "alertname", "value": "Fake Alert" }] + }` + httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -224,7 +242,7 @@ func TestProxyHeaders(t *testing.T) { return httpmock.NewStringResponse(testCase.code, "ok"), nil }) - req := httptest.NewRequest(testCase.method, testCase.localPath, nil) + req := httptest.NewRequest(testCase.method, testCase.localPath, strings.NewReader(dummySilence)) resp := newCloseNotifyingRecorder() r.ServeHTTP(resp, req) if resp.Code != testCase.code { @@ -277,6 +295,14 @@ func TestProxyToSubURIAlertmanager(t *testing.T) { }, } + dummySilence := `{ + "comment": "comment", + "createdBy": "username", + "startsAt": "2000-02-01T00:00:00.000Z", + "endsAt": "2000-02-01T00:02:03.000Z", + "matchers": [{ "isRegex": false, "name": "alertname", "value": "Fake Alert" }] +}` + for _, testCase := range proxyTests { t.Run(fmt.Sprintf("prefix=%s|uri=%s", testCase.listenPrefix, testCase.alertmanagerURI), func(t *testing.T) { httpmock.Reset() @@ -301,7 +327,7 @@ func TestProxyToSubURIAlertmanager(t *testing.T) { return httpmock.NewStringResponse(200, "ok"), nil }) - req := httptest.NewRequest("POST", testCase.requestURI, nil) + req := httptest.NewRequest("POST", testCase.requestURI, strings.NewReader(dummySilence)) resp := newCloseNotifyingRecorder() r.ServeHTTP(resp, req) if resp.Code != 200 { @@ -536,3 +562,476 @@ func TestProxyUserRewrite(t *testing.T) { }) } } + +func TestProxySilenceACL(t *testing.T) { + type proxyTest struct { + name string + + authGroups map[string][]string + silenceACLs []*silenceACL + + requestUsername string + + frontednRequestBody string + responseCode int + } + + defaultBody := `{ +"comment": "comment", +"createdBy": "alice", +"startsAt": "2000-02-01T00:00:00.000Z", +"endsAt": "2000-02-01T00:02:03.000Z", +"matchers": [ +{ "isRegex": false, "name": "alertname", "value": "Fake Alert" }, +{ "isRegex": true, "name": "foo", "value": "(bar|baz)" } +]}` + + proxyTests := []proxyTest{ + { + name: "no config, allowed", + authGroups: map[string][]string{}, + silenceACLs: []*silenceACL{}, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 200, + }, + { + name: "matcher required, no match, allowed", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require cluster=dev", + Scope: silenceACLScope{ + Groups: []string{"foo"}, + Alertmanagers: []string{"proxyACL"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "cluster", + Value: "dev", + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 200, + }, + { + name: "matcher required, alertmanager mismatch, allowed", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require cluster=dev", + Scope: silenceACLScope{ + Groups: []string{"admins"}, + Alertmanagers: []string{"proxyFoo"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "cluster", + Value: "dev", + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 200, + }, + { + name: "matcher required, filter mismatch, allowed", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require cluster=dev", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {Name: "foo", Value: "bar"}, + }, + Groups: []string{"admins"}, + Alertmanagers: []string{"proxyACL"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "cluster", + Value: "dev", + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 200, + }, + { + name: "matcher required, match, blocked", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require cluster=dev", + Scope: silenceACLScope{ + Groups: []string{"admins"}, + Alertmanagers: []string{"proxyACL"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "cluster", + Value: "dev", + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 400, + }, + { + name: "matcher required, all groups, match, blocked", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require cluster=dev", + Scope: silenceACLScope{ + Groups: []string{}, + Alertmanagers: []string{"foo", "proxyACL"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "cluster", + Value: "dev", + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 400, + }, + { + name: "matcher required, filter match, blocked", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require cluster=dev", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {Name: "alertname", Value: "Fake Alert"}, + }, + Groups: []string{"admins"}, + Alertmanagers: []string{"proxyACL"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "cluster", + Value: "dev", + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 400, + }, + { + name: "matcher required, filter name regex match, blocked", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require foo=bar", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {NameRegex: regexp.MustCompile(".*"), ValueRegex: regexp.MustCompile(".*")}, + }, + Groups: []string{}, + Alertmanagers: []string{"proxyACL"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "foo", + Value: "bar", + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 400, + }, + { + name: "matcher required, filter value regex match, blocked", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require foo=bar", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {Name: "alertname", ValueRegex: regexp.MustCompile(".*")}, + }, + Groups: []string{}, + Alertmanagers: []string{"proxyACL"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "foo", + Value: "bar", + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 400, + }, + { + name: "matcher required, match, allowed", + authGroups: map[string][]string{ + "admins": {"bob"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "requireMatcher", + Reason: "require foo=~(bar|baz)", + Scope: silenceACLScope{ + Groups: []string{"admins"}, + Alertmanagers: []string{"proxyACL"}, + }, + Matchers: aclMatchers{ + Required: []silenceMatcher{ + { + Name: "alertname", + Value: "Fake Alert", + }, + { + Name: "foo", + Value: "(bar|baz)", + IsRegex: true, + }, + }, + }, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 200, + }, + { + name: "block all regex silences", + silenceACLs: []*silenceACL{ + { + Action: "block", + Reason: "block all regex silences", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {NameRegex: regexp.MustCompile(".*"), ValueRegex: regexp.MustCompile(".*"), IsRegex: true}, + }, + Groups: []string{}, + Alertmanagers: []string{}, + }, + Matchers: aclMatchers{}, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 400, + }, + { + name: "block all regex silences except for admins, admin user, allowed", + authGroups: map[string][]string{ + "admins": {"bob"}, + "users": {"alice"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "allow", + Reason: "block all regex silences", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {NameRegex: regexp.MustCompile(".*"), ValueRegex: regexp.MustCompile(".*"), IsRegex: true}, + }, + Groups: []string{"admins"}, + Alertmanagers: []string{}, + }, + Matchers: aclMatchers{}, + }, + { + Action: "block", + Reason: "block all regex silences", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {NameRegex: regexp.MustCompile(".*"), ValueRegex: regexp.MustCompile(".*"), IsRegex: true}, + }, + Groups: []string{}, + Alertmanagers: []string{}, + }, + Matchers: aclMatchers{}, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 200, + }, + { + name: "block all regex silences on alertname=Block, allowed", + authGroups: map[string][]string{ + "admins": {"bob"}, + "users": {"alice"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "block", + Reason: "block alertname=Block", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {Name: "alertname", Value: "Block"}, + }, + Groups: []string{}, + Alertmanagers: []string{}, + }, + Matchers: aclMatchers{}, + }, + }, + requestUsername: "bob", + frontednRequestBody: defaultBody, + responseCode: 200, + }, + { + name: "block all regex silences except for admins, non-admin user, blocked", + authGroups: map[string][]string{ + "admins": {"bob"}, + "users": {"alice"}, + }, + silenceACLs: []*silenceACL{ + { + Action: "allow", + Reason: "block all regex silences", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {NameRegex: regexp.MustCompile(".*"), ValueRegex: regexp.MustCompile(".*"), IsRegex: true}, + }, + Groups: []string{"admins"}, + Alertmanagers: []string{}, + }, + Matchers: aclMatchers{}, + }, + { + Action: "block", + Reason: "block all regex silences", + Scope: silenceACLScope{ + Filters: []silenceFilter{ + {NameRegex: regexp.MustCompile(".*"), ValueRegex: regexp.MustCompile(".*"), IsRegex: true}, + }, + Groups: []string{}, + Alertmanagers: []string{}, + }, + Matchers: aclMatchers{}, + }, + }, + requestUsername: "alice", + frontednRequestBody: defaultBody, + responseCode: 400, + }, + } + + for _, testCase := range proxyTests { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + log.SetLevel(log.FatalLevel) + t.Run(testCase.name, func(t *testing.T) { + for _, version := range mock.ListAllMocks() { + t.Logf("Testing alerts using mock files from Alertmanager %s", version) + + config.Config.Listen.Prefix = "/" + config.Config.Authentication.Header.Name = "X-User" + config.Config.Authentication.Header.ValueRegex = "(.+)" + + config.Config.Authorization.Groups = []config.AuthorizationGroup{} + for groupName, members := range testCase.authGroups { + g := config.AuthorizationGroup{Name: groupName, Members: members} + config.Config.Authorization.Groups = append(config.Config.Authorization.Groups, g) + } + + silenceACLs = testCase.silenceACLs + + r := ginTestEngine() + + am, err := alertmanager.NewAlertmanager( + "proxyACL", + "http://localhost", + alertmanager.WithRequestTimeout(time.Second*5), + alertmanager.WithProxy(true), + ) + if err != nil { + t.Error(err) + } + err = setupRouterProxyHandlers(r, am) + if err != nil { + t.Errorf("Failed to setup proxy for Alertmanager %s: %s", am.Name, err) + } + + apiCache = cache.New(cache.NoExpiration, 10*time.Second) + httpmock.Reset() + mock.RegisterURL("http://localhost/metrics", version, "metrics") + mock.RegisterURL("http://localhost/api/v2/status", version, "api/v2/status") + mock.RegisterURL("http://localhost/api/v2/silences", version, "api/v2/silences") + mock.RegisterURL("http://localhost/api/v2/alerts/groups", version, "api/v2/alerts/groups") + _ = am.Pull() + + httpmock.RegisterResponder("POST", "http://localhost/api/v2/silences", func(req *http.Request) (*http.Response, error) { + body, _ := ioutil.ReadAll(req.Body) + return httpmock.NewBytesResponse(200, body), nil + }) + + req := httptest.NewRequest("POST", "/proxy/alertmanager/proxyACL/api/v2/silences", ioutil.NopCloser(bytes.NewBufferString(testCase.frontednRequestBody))) + req.Header.Set("X-User", testCase.requestUsername) + + resp := newCloseNotifyingRecorder() + r.ServeHTTP(resp, req) + if resp.Code != testCase.responseCode { + t.Errorf("Got response code %d instead of %d", resp.Code, testCase.responseCode) + } + } + }) + } +} diff --git a/cmd/karma/tests/testscript/acl_example.txt b/cmd/karma/tests/testscript/acl_example.txt new file mode 100644 index 000000000..e9569feff --- /dev/null +++ b/cmd/karma/tests/testscript/acl_example.txt @@ -0,0 +1,54 @@ +# Config is valid with example silence ACL rules +karma.bin-should-work --log.format=text --log.config=false --check-config +! stdout . +stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +! stderr 'level=error' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + + +-- acl.yaml -- +rules: + - action: requireMatcher + reason: require cluster=~dev|prod for admins + scope: + filters: + - name: cluster + value_re: .+ + alertmanagers: + - default + groups: + - admins + matchers: + required: + - name: cluster + value: dev|prod + isRegex: true + - action: block + reason: block cluster=prod for users + scope: + filters: + - name: cluster + value: prod + groups: + - users diff --git a/cmd/karma/tests/testscript/acl_invalid_file.txt b/cmd/karma/tests/testscript/acl_invalid_file.txt new file mode 100644 index 000000000..15ba3a0fa --- /dev/null +++ b/cmd/karma/tests/testscript/acl_invalid_file.txt @@ -0,0 +1,30 @@ +# Raises an error if --authorization.acl points to a file that cannot be parsed +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Failed to parse silence ACL configuration file \\"acl.yaml\\": yaml: unmarshal errors:.*"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +This Is Not yaml diff --git a/cmd/karma/tests/testscript/acl_missing_file.txt b/cmd/karma/tests/testscript/acl_missing_file.txt new file mode 100644 index 000000000..f8c4b090f --- /dev/null +++ b/cmd/karma/tests/testscript/acl_missing_file.txt @@ -0,0 +1,27 @@ +# Raises an error if --authorization.acl points to a missing file +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Failed to load silence ACL configuration file \\"acl.yaml\\": open acl.yaml: no such file or directory"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 diff --git a/cmd/karma/tests/testscript/acl_rule_invalid_action.txt b/cmd/karma/tests/testscript/acl_rule_invalid_action.txt new file mode 100644 index 000000000..96aa8926b --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_invalid_action.txt @@ -0,0 +1,32 @@ +# Raises an error if silence ACL rule uses invalid 'action' value +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: silence ACL rule requires ''action'' to be one of \[allow block requireMatcher\], got \\"foo\\""' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: foo + reason: invalid action diff --git a/cmd/karma/tests/testscript/acl_rule_invalid_alertmanager.txt b/cmd/karma/tests/testscript/acl_rule_invalid_alertmanager.txt new file mode 100644 index 000000000..44db749e6 --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_invalid_alertmanager.txt @@ -0,0 +1,35 @@ +# Raises an error if silence ACL rule uses invalid 'alertmanagers' value +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: invalid ACL rule, no alertmanager with name \\"unknown\\" found"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block + reason: invalid group + scope: + alertmanagers: + - unknown diff --git a/cmd/karma/tests/testscript/acl_rule_invalid_filter_name_re.txt b/cmd/karma/tests/testscript/acl_rule_invalid_filter_name_re.txt new file mode 100644 index 000000000..8105f7eca --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_invalid_filter_name_re.txt @@ -0,0 +1,36 @@ +# Raises an error if silence ACL rule uses filter with invalid name_re +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: invalid ACL rule, failed to parse name_re \\"cluster\*\*\*\\": error parsing regexp: invalid nested repetition operator: `\*\*`"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block + reason: missing name + scope: + filters: + - name_re: cluster*** + value: prod diff --git a/cmd/karma/tests/testscript/acl_rule_invalid_filter_value_re.txt b/cmd/karma/tests/testscript/acl_rule_invalid_filter_value_re.txt new file mode 100644 index 000000000..ba8725d88 --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_invalid_filter_value_re.txt @@ -0,0 +1,36 @@ +# Raises an error if silence ACL rule uses filter with invalid value_re +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: invalid ACL rule, failed to parse value_re \\"prod\*\*\*\\": error parsing regexp: invalid nested repetition operator: `\*\*`"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block + reason: missing name + scope: + filters: + - name_re: cluster[0-9]* + value_re: prod*** diff --git a/cmd/karma/tests/testscript/acl_rule_invalid_group.txt b/cmd/karma/tests/testscript/acl_rule_invalid_group.txt new file mode 100644 index 000000000..906b9a84b --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_invalid_group.txt @@ -0,0 +1,35 @@ +# Raises an error if silence ACL rule uses invalid 'groups' value +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: invalid silence ACL rule, no group with name \\"unknown\\" found in authorization.groups configuration"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block + reason: invalid group + scope: + groups: + - unknown diff --git a/cmd/karma/tests/testscript/acl_rule_missing_both_name_and_name_re.txt b/cmd/karma/tests/testscript/acl_rule_missing_both_name_and_name_re.txt new file mode 100644 index 000000000..1bd67757a --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_missing_both_name_and_name_re.txt @@ -0,0 +1,37 @@ +# Raises an error if silence ACL rule uses filter with both name and name_re +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: silence ACL rule filter can only have ''name'' or ''name_re'' set, not both"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block + reason: missing name + scope: + filters: + - name: cluster + name_re: cluster + value: prod diff --git a/cmd/karma/tests/testscript/acl_rule_missing_both_value_and_value_re.txt b/cmd/karma/tests/testscript/acl_rule_missing_both_value_and_value_re.txt new file mode 100644 index 000000000..c13363ab5 --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_missing_both_value_and_value_re.txt @@ -0,0 +1,37 @@ +# Raises an error if silence ACL rule uses filter with both value and value_re +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: silence ACL rule filter can only have ''value'' or ''value_re'' set, not both"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block + reason: missing name + scope: + filters: + - name: cluster + value: prod + value_re: prod diff --git a/cmd/karma/tests/testscript/acl_rule_missing_filter_name.txt b/cmd/karma/tests/testscript/acl_rule_missing_filter_name.txt new file mode 100644 index 000000000..40f30dd4e --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_missing_filter_name.txt @@ -0,0 +1,35 @@ +# Raises an error if silence ACL rule uses filter with missing name or name_re +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: silence ACL rule filter requires ''name'' or ''name_re'' to be set"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block + reason: missing name + scope: + filters: + - value: prod diff --git a/cmd/karma/tests/testscript/acl_rule_missing_filter_value.txt b/cmd/karma/tests/testscript/acl_rule_missing_filter_value.txt new file mode 100644 index 000000000..d101c3a32 --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_missing_filter_value.txt @@ -0,0 +1,35 @@ +# Raises an error if silence ACL rule uses filter with missing value or value_re +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: silence ACL rule filter requires ''value'' or ''value_re'' to be set"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block + reason: missing value + scope: + filters: + - name: cluster diff --git a/cmd/karma/tests/testscript/acl_rule_missing_matcher_name.txt b/cmd/karma/tests/testscript/acl_rule_missing_matcher_name.txt new file mode 100644 index 000000000..ad6a21585 --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_missing_matcher_name.txt @@ -0,0 +1,35 @@ +# Raises an error if silence ACL rule uses matcher without name +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: silence ACL rule matcher requires ''name'' to be set"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: requireMatcher + reason: missing name + matchers: + required: + - value: prod diff --git a/cmd/karma/tests/testscript/acl_rule_missing_matcher_value.txt b/cmd/karma/tests/testscript/acl_rule_missing_matcher_value.txt new file mode 100644 index 000000000..637883ee4 --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_missing_matcher_value.txt @@ -0,0 +1,35 @@ +# Raises an error if silence ACL rule uses matcher without value +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: silence ACL rule matcher requires ''value'' to be set"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: requireMatcher + reason: missing value + matchers: + required: + - name: cluster diff --git a/cmd/karma/tests/testscript/acl_rule_missing_reason.txt b/cmd/karma/tests/testscript/acl_rule_missing_reason.txt new file mode 100644 index 000000000..ebcd15ee7 --- /dev/null +++ b/cmd/karma/tests/testscript/acl_rule_missing_reason.txt @@ -0,0 +1,31 @@ +# Raises an error if silence ACL rule is missing 'reason' +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +! stderr 'msg="Configuration is valid"' +stderr 'msg="Reading silence ACL config file acl.yaml"' +stderr 'msg="Invalid silence ACL rule at position 0: silence ACL rule requires ''reason'' to be set"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john + acl: + silences: acl.yaml +alertmanager: + servers: + - name: default + uri: https://localhost:9093 + +-- acl.yaml -- +rules: + - action: block diff --git a/cmd/karma/tests/testscript/auth_group_missing_members.txt b/cmd/karma/tests/testscript/auth_group_missing_members.txt new file mode 100644 index 000000000..d43a0a78c --- /dev/null +++ b/cmd/karma/tests/testscript/auth_group_missing_members.txt @@ -0,0 +1,17 @@ +# Raises an error if authorization group is missing name +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +stderr 'msg="''members'' is required for every authorization group"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins +alertmanager: + servers: + - name: default + uri: https://localhost:9093 diff --git a/cmd/karma/tests/testscript/auth_group_missing_name.txt b/cmd/karma/tests/testscript/auth_group_missing_name.txt new file mode 100644 index 000000000..b6c341415 --- /dev/null +++ b/cmd/karma/tests/testscript/auth_group_missing_name.txt @@ -0,0 +1,22 @@ +# Raises an error if authorization group is missing name +karma.bin-should-fail --log.format=text --log.config=false --check-config +! stdout . +stderr 'msg="''name'' is required for every authorization group"' + +-- karma.yaml -- +authentication: + header: + name: "X-User" + value_re: "(.+)" +authorization: + groups: + - name: admins + members: + - alice + - bob + - members: + - john +alertmanager: + servers: + - name: default + uri: https://localhost:9093 diff --git a/cmd/karma/tests/testscript/log_full_config_env.txt b/cmd/karma/tests/testscript/log_full_config_env.txt index 61c06774b..9ee52d970 100644 --- a/cmd/karma/tests/testscript/log_full_config_env.txt +++ b/cmd/karma/tests/testscript/log_full_config_env.txt @@ -75,6 +75,10 @@ level=info msg=" name: \"\"" level=info msg=" value_re: \"\"" level=info msg=" basicAuth:" level=info msg=" users: []" +level=info msg="authorization:" +level=info msg=" groups: []" +level=info msg=" acl:" +level=info msg=" silences: \"\"" level=info msg="alertmanager:" level=info msg=" interval: 10s" level=info msg=" servers:" diff --git a/cmd/karma/tests/testscript/log_full_config_file.txt b/cmd/karma/tests/testscript/log_full_config_file.txt index c8b410306..2f12edd02 100644 --- a/cmd/karma/tests/testscript/log_full_config_file.txt +++ b/cmd/karma/tests/testscript/log_full_config_file.txt @@ -11,6 +11,12 @@ authentication: password: 1234 - username: string password: '1234' +authorization: + groups: + - name: admins + members: + - alice + - bob alertmanager: interval: 10s servers: @@ -244,6 +250,14 @@ level=info msg=" - username: number" level=info msg=" password: '***'" level=info msg=" - username: string" level=info msg=" password: '***'" +level=info msg="authorization:" +level=info msg=" groups:" +level=info msg=" - name: admins" +level=info msg=" members:" +level=info msg=" - alice" +level=info msg=" - bob" +level=info msg=" acl:" +level=info msg=" silences: \"\"" level=info msg="alertmanager:" level=info msg=" interval: 10s" level=info msg=" servers:" @@ -357,18 +371,14 @@ level=info msg=" - strip2" level=info msg=" color:" level=info msg=" custom:" level=info msg=" region:" -level=info msg=" - value: \"\"" -level=info msg=" value_re: .*" +level=info msg=" - value_re: .*" level=info msg=" color: '#736598'" level=info msg=" severity:" level=info msg=" - value: info" -level=info msg=" value_re: \"\"" level=info msg=" color: '#87c4e0'" level=info msg=" - value: warning" -level=info msg=" value_re: \"\"" level=info msg=" color: '#ffae42'" level=info msg=" - value: critical" -level=info msg=" value_re: \"\"" level=info msg=" color: '#ff220c'" level=info msg=" static:" level=info msg=" - job" diff --git a/cmd/karma/tests/testscript/upper_case_keys.txt b/cmd/karma/tests/testscript/upper_case_keys.txt index ef08ced07..09f6dfbdd 100644 --- a/cmd/karma/tests/testscript/upper_case_keys.txt +++ b/cmd/karma/tests/testscript/upper_case_keys.txt @@ -7,18 +7,14 @@ stderr 'msg=" strip: \[\]"' stderr 'msg=" color:"' stderr 'msg=" custom:"' stderr 'msg=" region:"' -stderr 'msg=" - value: \\"\\""' -stderr 'msg=" value_re: .*"' +stderr 'msg=" - value_re: .*"' stderr 'msg=" color: ''#736598''"' stderr 'msg=" severity:"' stderr 'msg=" - value: P3"' -stderr 'msg=" value_re: \\"\\""' stderr 'msg=" color: ''#87c4e0''"' stderr 'msg=" - value: P2"' -stderr 'msg=" value_re: \\"\\""' stderr 'msg=" color: ''#ffae42''"' stderr 'msg=" - value: P1"' -stderr 'msg=" value_re: \\"\\""' stderr 'msg=" color: ''#ff220c''"' ! stderr 'level=error' diff --git a/demo/Dockerfile b/demo/Dockerfile index d677ae735..567e97986 100644 --- a/demo/Dockerfile +++ b/demo/Dockerfile @@ -29,6 +29,7 @@ COPY demo/alertmanager.yaml /etc/alertmanager.yaml COPY demo/generator.py /generator.py COPY --from=go-builder /src/karma /karma COPY demo/karma.yaml /etc/karma.yaml +COPY demo/acls.yaml /etc/acls.yaml COPY demo/custom.js /custom.js RUN adduser -D karma USER karma diff --git a/demo/acls.yaml b/demo/acls.yaml new file mode 100644 index 000000000..1950cd14f --- /dev/null +++ b/demo/acls.yaml @@ -0,0 +1,7 @@ +rules: + - action: block + reason: dev cluster cannot be silenced + scope: + filters: + - name: cluster + value: dev diff --git a/demo/karma.yaml b/demo/karma.yaml index 2b7df5c0c..da6dbe550 100644 --- a/demo/karma.yaml +++ b/demo/karma.yaml @@ -1,3 +1,6 @@ +authorization: + acl: + silences: /etc/acls.yaml alertmanager: interval: 10s servers: diff --git a/docs/ACLs.md b/docs/ACLs.md new file mode 100644 index 000000000..5bbd2d412 --- /dev/null +++ b/docs/ACLs.md @@ -0,0 +1,293 @@ +# Silence Access Control Lists + +## Intro + +Karma provides ability to setup ACLs for silences created by users. This can be +used to limit what kind of silences each user is allowed to create, which can +help to avoid, for example, `Team A` accidentally silencing alerts for `Team B`, +or blocking engineering team from creating any silence at all, leaving that +ability only to the sys admin / SRE team. + +Example Alertmanager silence: + +```YAML +{ + "matchers": [ + { + "name": "alertname", + "value": "Test Alert", + "isRegex": false + }, + { + "name": "cluster", + "value": "prod", + "isRegex": false + }, + { + "name": "instance", + "value": "server1", + "isRegex": false + } + ], + "startsAt": "2020-03-09T20:11:00.000Z", + "endsAt": "2020-03-09T21:11:00.000Z", + "createdBy": "me@example.com", + "comment": "Silence Test Alert on server1" +} +``` + +It would be applied to all alerts with name `Test Alert` and where label +`cluster` is equal to `prod`. +An ACL rule could be used to restrict silence creation based on matched labels, +so for example only selected users would be allowed to silence this specific +alert. + +## Requirements + +For ACLs to work a few configuring options are required: + +- `authorization:acl:silences` is set with acl config file path, there's no + support for configuring ACLs via environment variables +- `proxy` must be enabled in karma configuration for each Alertmanager server + where ACLs will be applied. `proxy: true` tells karma UI to proxy all silence + operation requests (creating, editing & deleting silences) via karma backend. + Since ACLs are applied in the proxy code it needs to be enabled to take + effect. It is recommended to block ability for users to connect directly to + Alertmanager servers to avoid bypassing ACL rules (alertmanager accepts all + silences). + +Optional configuration: + +- `authentication` if configured user based matching of ACLs can be used, + Header authentication with a frontend authentication proxy that passes + usernames via header is recommended. This can be done with nginx configured + as an authentication reverse proxy or proxy services like Cloudflare Access. +- `authorization:groups` must be configured if group polices will be used. + This configuration maps users into groups, allowing to use those groups in + ACL rules. + +## Regex silences + +Alertmanager silences allow to use regex rules which can make it tricky to apply +ACLs to those silences. + +Silence example using regex: + +```YAML +{ + "matchers": [ + { + "name": "alertname", + "value": "Test Alert", + "isRegex": false + }, + { + "name": "cluster", + "value": "staging|prod", + "isRegex": true + } + ], + "startsAt": "2020-03-09T20:11:00.000Z", + "endsAt": "2020-03-09T21:11:00.000Z", + "createdBy": "me@example.com", + "comment": "Silence Test Alert in staging & prod cluster" +} +``` + +The difference compared to the previous example is that the `cluster` label +is now matched using `staging|prod` regex, so any alert with `cluster` label +equal to `staging` or `prod` will be matched. +This is a simple example, regexes allow to create very complex matching +rules. + +The effect on ACL rules can be illustrated with this example: let's say we have +a group that should never be allowed to create any silence for `prod` cluster, +so a silence like the one below should be blocked: + +```YAML +{ + "matchers": [ + { + "name": "alertname", + "value": "Test Alert", + "isRegex": false + }, + { + "name": "cluster", + "value": "prod", + "isRegex": false + } + ], + "startsAt": "2020-03-09T20:11:00.000Z", + "endsAt": "2020-03-09T21:11:00.000Z", + "createdBy": "me@example.com", + "comment": "Silence Test Alert in prod cluster" +} +``` + +But if we would create an ACL rule that simply blocks silences with matcher: + +```YAML +{ + "name": "cluster", + "value": "prod", + "isRegex": false +} +``` + +then any user could bypass that with a regex matcher like: + +```YAML +{ + "name": "cluster", + "value": "pro[d]", + "isRegex": true +} +``` + +Because of that it is *highly recommended* to block regex silences, which can +be done with an ACL rule. Since rules are evaluated in the order they are +listed in the config file it is best to set this as the very first rule. +See examples below to learn how to block regex silences. + +## Configuration syntax + +- `rules` - list of silence ACL rules, rules are evaluated in the order they + appear in this list + +Rule syntax: + +```YAML +action: string +reason: string +scope: + groups: list of strings + alertmanagers: list of strings + filters: list of filters +matchers: + required: list of silence matchers +``` + +- `action` - this is the name of the action to take if given ACL matches all + the conditions. + Valid actions are: + - `allow` - skip all other ACLs and allow silence to be created + - `block` - skip all other ACLs and block silences from being created + - `requireMatcher` - block silence if it doesn't have all of matchers + specified in `matchers:required` +- `reason` - message that will be returned to the user if this ACL blocks any + silence +- `scope` - this section contains all conditions required to apply given ACL + rule to specific silence, if it's skipped then ACL rule will be applied to + all users and every silence +- `scope:groups` - list of group names from `authorization:groups`, if no group + is specified here then this ACL will be applied to all users +- `scope:alertmanagers` - list of alertmanager names as specified in + `alertmanager:servers`, if no name is specified here then this ACL will be + applied to silences for all alertmanager servers +- `scope:filters` - list of matcher filters evaluated when checking if this ACL + should be applied to given silence. Those filters can be used to enforce + ACL rules only to some silences and are compared against silence matchers. + Syntax: + + ```YAML + name: string + name_re: regex + value: string + value_re: regex + isRegex: bool + ``` + + Every rule must have `name` or `name_re` AND `value` or `value_re`, default + value for `isRegex` is `false`. + Filter works by comparing `name` and `name_re` with silence matcher `name`, + `value` and `value_re` with silence matcher `value` and `isRegex` on the + filter with `isRegex` on silence matcher. See examples below. +- `matchers:required` - list of additional matchers that must be part of the + silence if it matches groups, alertmanagers and filters. This is only used + if `action` is set to `requireMatcher`. + Syntax for each matcher: + + ```YAML + name: string + value: string or regex + isRegex: bool + ``` + +## Examples + +### Block silences using regex matchers + +This rule will match all silences with any matcher using regexes +(`isRegex: true` on the matcher) and block it. + +```YAML +rules: + - action: block + reason: all regex silences are blocked, use only concrete label names and values + scope: + filters: + - name_re: .+ + value_re: .+ + isRegex: true +``` + +### Allow group to create any silence + +```YAML +rules: + - action: allow + reason: admins are allowed + scope: + groups: + - admins +``` + +### Allow only admins group to create silences with cluster=prod + +First allow all members of the `admins` group to create any silence, then block +silences with `cluster=prod`. Since ACL rules are evaluated in the order +specified and first `allow` or `block` rule stops other rule processing this +will allow `admins` to create `cluster=prod` silences while everyone else is +blocked from it. Disabling regex rules as first steps prevents users from +bypassing those ACLs with regex silences. + +```YAML +rules: + - action: block + reason: all regex silences are blocked, use only concrete label names and values + scope: + filters: + - name_re: .+ + value_re: .+ + isRegex: true + - action: allow + reason: admins are allowed + scope: + groups: + - admins + - action: block + reason: only admins can create silences with cluster=prod + scope: + filters: + - name: cluster + value: prod +``` + +### Require postgresAdmins group to always specify db=postgres in silences + +Block `postgresAdmins` members from creating silences unless they add +`db=postgres` to the list of matchers. + +```YAML +rules: + - action: requireMatcher + reason: postgres admins must add db=postgres to all silences + scope: + groups: + - postgresAdmins + matchers: + required: + - name: db + value: postgres +``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c074ba25a..334407645 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -38,6 +38,7 @@ There are currently two supported authentication methods: Only one method can be enabled in the config. Enabling authentication will also force silences to be created with usernames passed from credentials. +Syntax: ```YAML authentication: @@ -97,6 +98,52 @@ authentication: value_re: ^(.+)$ ``` +### Authorization + +`authorization` section allows to configure authorization groups used in +silence ACL rules. +Syntax: + +```YAML +authorization: + acl: + silences: string + groups: + - name: string + members: list of strings +``` + +- `acl:silences` - path to silence ACL configuration file, see + [ACLs](/docs/ACLs.md) for details +- `groups` - list of group definitons, each group must have a `name` and + `members` list. `name` will be used in silence ACL rules, `members` list + should contain list of user names as passed from authentication layer. + +Example with two groups using basic auth users and silences ACL config: + +```YAML +authentication: + basicAuth: + users: + - username: alice + password: secret + - username: bob + password: secret + - username: john + password: secret +authorization: + acls: + silences: /etc/karma/acls.yaml + groups: + - name: admins + members: + - alice + - bob + - name: users + members: + - john +``` + ### Alertmanagers `alertmanager` section allows setting Alertmanager servers that should be @@ -824,12 +871,6 @@ silences: uriTemplate: https://jira.example.com/browse/$1 ``` -Defaults: - -```YAML -jira: [] -``` - ### Receivers `receivers` section allows configuring how alerts from different receivers are diff --git a/internal/config/acl.go b/internal/config/acl.go new file mode 100644 index 000000000..e823d0d91 --- /dev/null +++ b/internal/config/acl.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "io/ioutil" + + yaml "gopkg.in/yaml.v2" +) + +type SilenceMatcher struct { + Name string `yaml:"name"` + Value string `yaml:"value"` + IsRegex bool `yaml:"isRegex"` +} + +type SilenceACLMatchersConfig struct { + Required []SilenceMatcher `yaml:"required"` +} + +type SilenceFilters struct { + Name string `yaml:"name,omitempty"` + NameRegex string `yaml:"name_re,omitempty"` + Value string `yaml:"value,omitempty"` + ValueRegex string `yaml:"value_re,omitempty"` + IsRegex bool `yaml:"isRegex"` +} + +type SilenceACLRuleScope struct { + Groups []string + Alertmanagers []string + Filters []SilenceFilters +} + +type SilenceACLRule struct { + Action string + Reason string + Scope SilenceACLRuleScope + Matchers SilenceACLMatchersConfig +} + +type silencesACLSchema struct { + Rules []SilenceACLRule +} + +func ReadSilenceACLConfig(path string) (*silencesACLSchema, error) { + cfg := silencesACLSchema{} + + f, err := ioutil.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("Failed to load silence ACL configuration file %q: %v", path, err) + } + + err = yaml.Unmarshal(f, &cfg) + if err != nil { + return nil, fmt.Errorf("Failed to parse silence ACL configuration file %q: %v", path, err) + } + + return &cfg, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index bf21b7c2b..b8873ff46 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,6 +58,8 @@ func SetupFlags(f *pflag.FlagSet) { f.String("alertAcknowledgement.author", "karma", "Default silence author when acknowledging alerts with short lived silences") f.String("alertAcknowledgement.commentPrefix", "ACK!", "Comment prefix used when acknowledging alerts with short lived silences") + f.String("authorization.acl.silences", "", "Path to silence ACL config file") + f.Bool( "annotations.default.hidden", false, "Hide all annotations by default unless explicitly listed in the 'visible' list") @@ -313,6 +315,15 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string { } } + for _, authGroup := range config.Authorization.Groups { + if authGroup.Name == "" { + log.Fatalf("'name' is required for every authorization group") + } + if len(authGroup.Members) == 0 { + log.Fatalf("'members' is required for every authorization group") + } + } + for labelName, customColors := range config.Labels.Color.Custom { for i, customColor := range customColors { if customColor.Value == "" && customColor.ValueRegex == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5fefc29bb..aa8fb4d99 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,6 +26,10 @@ func testReadConfig(t *testing.T) { value_re: "" basicAuth: users: [] +authorization: + groups: [] + acl: + silences: "" alertmanager: interval: 1s servers: diff --git a/internal/config/models.go b/internal/config/models.go index 5ca16d8bf..9f23988a7 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -32,8 +32,8 @@ type LinkDetectRules struct { } type CustomLabelColor struct { - Value string `yaml:"value"` - ValueRegex string `yaml:"value_re" koanf:"value_re"` + Value string `yaml:"value,omitempty"` + ValueRegex string `yaml:"value_re,omitempty" koanf:"value_re"` CompiledRegex *regexp.Regexp `yaml:"-"` Color string `yaml:"color"` } @@ -45,6 +45,11 @@ type AuthenticationUser struct { Password string } +type AuthorizationGroup struct { + Name string + Members []string +} + type configSchema struct { Authentication struct { Enabled bool `yaml:"-" koanf:"-"` @@ -56,6 +61,12 @@ type configSchema struct { Users []AuthenticationUser } `yaml:"basicAuth" koanf:"basicAuth"` } + Authorization struct { + Groups []AuthorizationGroup + ACL struct { + Silences string + } `yaml:"acl" koanf:"acl"` + } Alertmanager struct { Interval time.Duration Servers []AlertmanagerConfig diff --git a/internal/mapper/mapper.go b/internal/mapper/mapper.go index a6933d6ff..3cc6ec9ec 100644 --- a/internal/mapper/mapper.go +++ b/internal/mapper/mapper.go @@ -31,6 +31,7 @@ type SilenceMapper interface { Mapper Collect(string, map[string]string, time.Duration, http.RoundTripper) ([]models.Silence, error) RewriteUsername([]byte, string) ([]byte, error) + Unmarshal([]byte) (*models.Silence, error) } // StatusMapper handles mapping Alertmanager status information containing cluster config diff --git a/internal/mapper/v017/api.go b/internal/mapper/v017/api.go index 5174bce5d..a6e358969 100644 --- a/internal/mapper/v017/api.go +++ b/internal/mapper/v017/api.go @@ -130,3 +130,30 @@ func rewriteSilenceUsername(body []byte, username string) ([]byte, error) { s.CreatedBy = &username return s.MarshalBinary() } + +func unmarshal(body []byte) (*models.Silence, error) { + s := ammodels.PostableSilence{} + + err := s.UnmarshalBinary(body) + if err != nil { + return nil, err + } + + us := models.Silence{ + ID: s.ID, + StartsAt: time.Time(*s.StartsAt), + EndsAt: time.Time(*s.EndsAt), + CreatedBy: *s.CreatedBy, + Comment: *s.Comment, + } + for _, m := range s.Matchers { + sm := models.SilenceMatcher{ + Name: *m.Name, + Value: *m.Value, + IsRegex: *m.IsRegex, + } + us.Matchers = append(us.Matchers, sm) + } + + return &us, nil +} diff --git a/internal/mapper/v017/silences.go b/internal/mapper/v017/silences.go index 1ffba7640..8c9741824 100644 --- a/internal/mapper/v017/silences.go +++ b/internal/mapper/v017/silences.go @@ -31,3 +31,7 @@ func (m SilenceMapper) Collect(uri string, headers map[string]string, timeout ti func (m SilenceMapper) RewriteUsername(body []byte, username string) ([]byte, error) { return rewriteSilenceUsername(body, username) } + +func (m SilenceMapper) Unmarshal(body []byte) (*models.Silence, error) { + return unmarshal(body) +}