Alerting: Update RBAC for alert rules to consider access to rule as access to group it belongs (#49033)

* update authz to exclude entire group if user does not have access to rule
* change rule update authz to not return changes because if user does not have access to any rule in group, they do not have access to the rule
* a new query that returns alerts in group by UID of alert that belongs to that group
* collect all affected groups during calculate changes
* update authorize to check access to groups
* update tests for calculateChanges to assert new fields
* add authorization tests
This commit is contained in:
Yuriy Tseretyan 2022-06-01 10:23:54 -04:00 committed by GitHub
parent 333195ce21
commit ad25e2a20c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 443 additions and 278 deletions

View File

@ -166,9 +166,6 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res
groupedRules := make(map[ngmodels.AlertRuleGroupKey][]*ngmodels.AlertRule)
for _, rule := range alertRuleQuery.Result {
if !authorizeDatasourceAccessForRule(rule, hasAccess) {
continue
}
key := rule.GetGroupKey()
rulesInGroup := groupedRules[key]
rulesInGroup = append(rulesInGroup, rule)
@ -181,6 +178,9 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res
srv.log.Warn("query returned rules that belong to folder the user does not have access to. All rules that belong to that namespace will not be added to the response", "folder_uid", groupKey.NamespaceUID)
continue
}
if !authorizeAccessToRuleGroup(rules, hasAccess) {
continue
}
ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, srv.toRuleGroup(groupKey.RuleGroup, folder, rules, labelOptions))
}
return response.JSON(http.StatusOK, ruleResponse)

View File

@ -238,7 +238,7 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *models.ReqContext) response.Resp
groupRules := make([]*ngmodels.AlertRule, 0, len(q.Result))
for _, r := range q.Result {
if !authorizeDatasourceAccessForRule(r, hasAccess) {
continue
return ErrResp(http.StatusUnauthorized, fmt.Errorf("%w to access the group because it does not have access to one or many data sources one or many rules in the group use", ErrAuthorization), "")
}
groupRules = append(groupRules, r)
}
@ -297,9 +297,6 @@ func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response
configs := make(map[ngmodels.AlertRuleGroupKey][]*ngmodels.AlertRule)
for _, r := range q.Result {
if !authorizeDatasourceAccessForRule(r, hasAccess) {
continue
}
groupKey := r.GetGroupKey()
group := configs[groupKey]
group = append(group, r)
@ -312,6 +309,9 @@ func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response
srv.log.Error("namespace not visible to the user", "user", c.SignedInUser.UserId, "namespace", groupKey.NamespaceUID)
continue
}
if !authorizeAccessToRuleGroup(rules, hasAccess) {
continue
}
namespace := folder.Title
result[namespace] = append(result[namespace], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, folder.Id, provenanceRecords))
}
@ -358,21 +358,14 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod
return nil
}
authorizedChanges := groupChanges // if RBAC is disabled the permission are limited to folder access that is done upstream
// if RBAC is disabled the permission are limited to folder access that is done upstream
if !srv.ac.IsDisabled() {
authorizedChanges, err = authorizeRuleChanges(groupChanges, func(evaluator accesscontrol.Evaluator) bool {
err = authorizeRuleChanges(groupChanges, func(evaluator accesscontrol.Evaluator) bool {
return hasAccess(accesscontrol.ReqOrgAdminOrEditor, evaluator)
})
if err != nil {
return err
}
if authorizedChanges.isEmpty() {
logger.Info("no authorized changes detected in the request. Do nothing", "not_authorized_add", len(groupChanges.New), "not_authorized_update", len(groupChanges.Update), "not_authorized_delete", len(groupChanges.Delete))
return nil
}
if len(groupChanges.Delete) > len(authorizedChanges.Delete) {
logger.Info("user is not authorized to delete one or many rules in the group. those rules will be skipped", "expected", len(groupChanges.Delete), "authorized", len(authorizedChanges.Delete))
}
}
provenances, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.OrgId, (&ngmodels.AlertRule{}).ResourceType())
@ -382,13 +375,13 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod
// New rules don't need to be checked for provenance, just copy the whole slice.
finalChanges = &changes{}
finalChanges.New = authorizedChanges.New
for _, rule := range authorizedChanges.Update {
finalChanges.New = groupChanges.New
for _, rule := range groupChanges.Update {
if provenance, exists := provenances[rule.Existing.UID]; (exists && provenance == ngmodels.ProvenanceNone) || !exists {
finalChanges.Update = append(finalChanges.Update, rule)
}
}
for _, rule := range authorizedChanges.Delete {
for _, rule := range groupChanges.Delete {
if provenance, exists := provenances[rule.UID]; (exists && provenance == ngmodels.ProvenanceNone) || !exists {
finalChanges.Delete = append(finalChanges.Delete, rule)
}
@ -396,22 +389,22 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *models.ReqContext, groupKey ngmod
if finalChanges.isEmpty() {
logger.Info("no changes detected that have 'none' provenance in the request. Do nothing",
"provenance_invalid_add", len(authorizedChanges.New),
"provenance_invalid_update", len(authorizedChanges.Update),
"provenance_invalid_delete", len(authorizedChanges.Delete))
"provenance_invalid_add", len(groupChanges.New),
"provenance_invalid_update", len(groupChanges.Update),
"provenance_invalid_delete", len(groupChanges.Delete))
return nil
}
if len(authorizedChanges.Delete) > len(finalChanges.Delete) {
if len(groupChanges.Delete) > len(finalChanges.Delete) {
logger.Info("provenance is not 'none' for one or many rules in the group that should be deleted. those rules will be skipped",
"expected", len(authorizedChanges.Delete),
"allowed", len(authorizedChanges.Delete))
"expected", len(groupChanges.Delete),
"allowed", len(groupChanges.Delete))
}
if len(authorizedChanges.Update) > len(finalChanges.Update) {
if len(groupChanges.Update) > len(finalChanges.Update) {
logger.Info("provenance is not 'none' for one or many rules in the group that should be updated. those rules will be skipped",
"expected", len(authorizedChanges.Update),
"allowed", len(authorizedChanges.Update))
"expected", len(groupChanges.Update),
"allowed", len(groupChanges.Update))
}
logger.Debug("updating database with the authorized changes", "add", len(finalChanges.New), "update", len(finalChanges.New), "delete", len(finalChanges.Delete))
@ -565,6 +558,7 @@ type ruleUpdate struct {
type changes struct {
GroupKey ngmodels.AlertRuleGroupKey
AffectedGroups map[ngmodels.AlertRuleGroupKey][]*ngmodels.AlertRule
New []*ngmodels.AlertRule
Update []ruleUpdate
Delete []*ngmodels.AlertRule
@ -577,6 +571,7 @@ func (c *changes) isEmpty() bool {
// calculateChanges calculates the difference between rules in the group in the database and the submitted rules. If a submitted rule has UID it tries to find it in the database (in other groups).
// returns a list of rules that need to be added, updated and deleted. Deleted considered rules in the database that belong to the group but do not exist in the list of submitted rules.
func calculateChanges(ctx context.Context, ruleStore store.RuleStore, groupKey ngmodels.AlertRuleGroupKey, submittedRules []*ngmodels.AlertRule) (*changes, error) {
affectedGroups := make(map[ngmodels.AlertRuleGroupKey][]*ngmodels.AlertRule)
q := &ngmodels.ListAlertRulesQuery{
OrgID: groupKey.OrgID,
NamespaceUIDs: []string{groupKey.NamespaceUID},
@ -586,6 +581,9 @@ func calculateChanges(ctx context.Context, ruleStore store.RuleStore, groupKey n
return nil, fmt.Errorf("failed to query database for rules in the group %s: %w", groupKey, err)
}
existingGroupRules := q.Result
if len(existingGroupRules) > 0 {
affectedGroups[groupKey] = existingGroupRules
}
existingGroupRulesUIDs := make(map[string]*ngmodels.AlertRule, len(existingGroupRules))
for _, r := range existingGroupRules {
@ -594,25 +592,30 @@ func calculateChanges(ctx context.Context, ruleStore store.RuleStore, groupKey n
var toAdd, toDelete []*ngmodels.AlertRule
var toUpdate []ruleUpdate
loadedRulesByUID := map[string]*ngmodels.AlertRule{} // auxiliary cache to avoid unnecessary queries if there are multiple moves from the same group
for _, r := range submittedRules {
var existing *ngmodels.AlertRule = nil
if r.UID != "" {
if existingGroupRule, ok := existingGroupRulesUIDs[r.UID]; ok {
existing = existingGroupRule
// remove the rule from existingGroupRulesUIDs
delete(existingGroupRulesUIDs, r.UID)
} else {
} else if existing, ok = loadedRulesByUID[r.UID]; !ok { // check the "cache" and if there is no hit, query the database
// Rule can be from other group or namespace
q := &ngmodels.GetAlertRuleByUIDQuery{OrgID: groupKey.OrgID, UID: r.UID}
if err := ruleStore.GetAlertRuleByUID(ctx, q); err != nil || q.Result == nil {
// if rule has UID then it is considered an update. Therefore, fail if there is no rule to update
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) || q.Result == nil && err == nil {
q := &ngmodels.GetAlertRulesGroupByRuleUIDQuery{OrgID: groupKey.OrgID, UID: r.UID}
if err := ruleStore.GetAlertRulesGroupByRuleUID(ctx, q); err != nil {
return nil, fmt.Errorf("failed to query database for a group of alert rules: %w", err)
}
for _, rule := range q.Result {
if rule.UID == r.UID {
existing = rule
}
loadedRulesByUID[rule.UID] = rule
}
if existing == nil {
return nil, fmt.Errorf("failed to update rule with UID %s because %w", r.UID, ngmodels.ErrAlertRuleNotFound)
}
return nil, fmt.Errorf("failed to query database for an alert rule with UID %s: %w", r.UID, err)
}
existing = q.Result
affectedGroups[existing.GetGroupKey()] = q.Result
}
}
@ -642,6 +645,7 @@ func calculateChanges(ctx context.Context, ruleStore store.RuleStore, groupKey n
return &changes{
GroupKey: groupKey,
AffectedGroups: affectedGroups,
New: toAdd,
Delete: toDelete,
Update: toUpdate,

View File

@ -64,6 +64,7 @@ func TestCalculateChanges(t *testing.T) {
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, make([]*models.AlertRule, 0))
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Empty(t, changes.New)
require.Empty(t, changes.Update)
require.Len(t, changes.Delete, len(inDatabaseMap))
@ -72,6 +73,8 @@ func TestCalculateChanges(t *testing.T) {
db := inDatabaseMap[toDelete.UID]
require.Equal(t, db, toDelete)
}
require.Contains(t, changes.AffectedGroups, groupKey)
require.Equal(t, inDatabase, changes.AffectedGroups[groupKey])
})
t.Run("should detect alerts that needs to be updated", func(t *testing.T) {
@ -85,6 +88,7 @@ func TestCalculateChanges(t *testing.T) {
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Len(t, changes.Update, len(inDatabase))
for _, upsert := range changes.Update {
require.NotNil(t, upsert.Existing)
@ -95,6 +99,9 @@ func TestCalculateChanges(t *testing.T) {
}
require.Empty(t, changes.Delete)
require.Empty(t, changes.New)
require.Contains(t, changes.AffectedGroups, groupKey)
require.Equal(t, inDatabase, changes.AffectedGroups[groupKey])
})
t.Run("should include only if there are changes ignoring specific fields", func(t *testing.T) {
@ -187,7 +194,8 @@ func TestCalculateChanges(t *testing.T) {
})
t.Run("should be able to find alerts by UID in other group/namespace", func(t *testing.T) {
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(10)+10, models.AlertRuleGen(withOrgID(orgId)))
sourceGroupKey := models.GenerateGroupKey(orgId)
inDatabaseMap, inDatabase := models.GenerateUniqueAlertRules(rand.Intn(10)+10, models.AlertRuleGen(withGroupKey(sourceGroupKey)))
fakeStore := store.NewFakeRuleStore(t)
fakeStore.PutRule(context.Background(), inDatabase...)
@ -206,6 +214,7 @@ func TestCalculateChanges(t *testing.T) {
changes, err := calculateChanges(context.Background(), fakeStore, groupKey, submitted)
require.NoError(t, err)
require.Equal(t, groupKey, changes.GroupKey)
require.Empty(t, changes.Delete)
require.Empty(t, changes.New)
require.Len(t, changes.Update, len(submitted))
@ -216,6 +225,11 @@ func TestCalculateChanges(t *testing.T) {
require.Equal(t, submittedMap[update.Existing.UID], update.New)
require.NotEmpty(t, update.Diff)
}
require.Contains(t, changes.AffectedGroups, sourceGroupKey)
require.NotContains(t, changes.AffectedGroups, groupKey) // because there is no such group in database yet
require.Len(t, changes.AffectedGroups[sourceGroupKey], len(inDatabase))
})
t.Run("should fail when submitted rule has UID that does not exist in db", func(t *testing.T) {
@ -251,7 +265,7 @@ func TestCalculateChanges(t *testing.T) {
expectedErr := errors.New("TEST ERROR")
fakeStore.Hook = func(cmd interface{}) error {
switch cmd.(type) {
case models.GetAlertRuleByUIDQuery:
case models.GetAlertRulesGroupByRuleUIDQuery:
return expectedErr
}
return nil
@ -261,7 +275,7 @@ func TestCalculateChanges(t *testing.T) {
submitted := models.AlertRuleGen(withOrgID(orgId), simulateSubmitted)()
_, err := calculateChanges(context.Background(), fakeStore, groupKey, []*models.AlertRule{submitted})
require.Error(t, err, expectedErr)
require.ErrorIs(t, err, expectedErr)
})
}

View File

@ -219,34 +219,43 @@ func authorizeDatasourceAccessForRule(rule *ngmodels.AlertRule, evaluator func(e
return true
}
// authorizeAccessToRuleGroup checks all rules against authorizeDatasourceAccessForRule and exits on the first negative result
func authorizeAccessToRuleGroup(rules []*ngmodels.AlertRule, evaluator func(evaluator ac.Evaluator) bool) bool {
for _, rule := range rules {
if !authorizeDatasourceAccessForRule(rule, evaluator) {
return false
}
}
return true
}
// authorizeRuleChanges analyzes changes in the rule group, and checks whether the changes are authorized.
// NOTE: if there are rules for deletion, and the user does not have access to data sources that a rule uses, the rule is removed from the list.
// If the user is not authorized to perform the changes the function returns ErrAuthorization with a description of what action is not authorized.
// Return changes that the user is authorized to perform or ErrAuthorization
func authorizeRuleChanges(change *changes, evaluator func(evaluator ac.Evaluator) bool) (*changes, error) {
var result = &changes{
GroupKey: change.GroupKey,
New: change.New,
Update: change.Update,
Delete: change.Delete,
func authorizeRuleChanges(change *changes, evaluator func(evaluator ac.Evaluator) bool) error {
namespaceScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(change.GroupKey.NamespaceUID)
rules, ok := change.AffectedGroups[change.GroupKey]
if ok { // not ok can be when user creates a new rule group or moves existing alerts to a new group
if !authorizeAccessToRuleGroup(rules, evaluator) { // if user is not authorized to do operation in the group that is being changed
return fmt.Errorf("%w to change group %s because it does not have access to one or many rules in this group", ErrAuthorization, change.GroupKey.RuleGroup)
}
} else if len(change.Delete) > 0 {
// add a safeguard in the case of inconsistency. If user hit this then there is a bug in the calculating of changes struct
return fmt.Errorf("failed to authorize changes in rule group %s. Detected %d deletes but group was not provided", change.GroupKey.RuleGroup, len(change.Delete))
}
namespaceScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(change.GroupKey.NamespaceUID)
if len(change.Delete) > 0 {
var allowedToDelete []*ngmodels.AlertRule
for _, rule := range change.Delete {
dsAllowed := authorizeDatasourceAccessForRule(rule, evaluator)
if dsAllowed {
allowedToDelete = append(allowedToDelete, rule)
}
}
if len(allowedToDelete) > 0 {
allowed := evaluator(ac.EvalPermission(ac.ActionAlertingRuleDelete, namespaceScope))
if !allowed {
return nil, fmt.Errorf("%w to delete alert rules that belong to folder %s", ErrAuthorization, change.GroupKey.NamespaceUID)
return fmt.Errorf("%w to delete alert rules that belong to folder %s", ErrAuthorization, change.GroupKey.NamespaceUID)
}
for _, rule := range change.Delete {
if !authorizeDatasourceAccessForRule(rule, evaluator) {
return fmt.Errorf("%w to delete an alert rule '%s' because the user does not have read permissions for one or many datasources the rule uses", ErrAuthorization, rule.UID)
}
}
result.Delete = allowedToDelete
}
var addAuthorized, updateAuthorized bool
@ -254,12 +263,12 @@ func authorizeRuleChanges(change *changes, evaluator func(evaluator ac.Evaluator
if len(change.New) > 0 {
addAuthorized = evaluator(ac.EvalPermission(ac.ActionAlertingRuleCreate, namespaceScope))
if !addAuthorized {
return nil, fmt.Errorf("%w to create alert rules in the folder %s", ErrAuthorization, change.GroupKey.NamespaceUID)
return fmt.Errorf("%w to create alert rules in the folder %s", ErrAuthorization, change.GroupKey.NamespaceUID)
}
for _, rule := range change.New {
dsAllowed := authorizeDatasourceAccessForRule(rule, evaluator)
if !dsAllowed {
return nil, fmt.Errorf("%w to create a new alert rule '%s' because the user does not have read permissions for one or many datasources the rule uses", ErrAuthorization, rule.Title)
return fmt.Errorf("%w to create a new alert rule '%s' because the user does not have read permissions for one or many datasources the rule uses", ErrAuthorization, rule.Title)
}
}
}
@ -267,31 +276,40 @@ func authorizeRuleChanges(change *changes, evaluator func(evaluator ac.Evaluator
for _, rule := range change.Update {
dsAllowed := authorizeDatasourceAccessForRule(rule.New, evaluator)
if !dsAllowed {
return nil, fmt.Errorf("%w to update alert rule '%s' (UID: %s) because the user does not have read permissions for one or many datasources the rule uses", ErrAuthorization, rule.Existing.Title, rule.Existing.UID)
return fmt.Errorf("%w to update alert rule '%s' (UID: %s) because the user does not have read permissions for one or many datasources the rule uses", ErrAuthorization, rule.Existing.Title, rule.Existing.UID)
}
// Check if the rule is moved from one folder to the current. If yes, then the user must have the authorization to delete rules from the source folder and add rules to the target folder.
if rule.Existing.NamespaceUID != rule.New.NamespaceUID {
allowed := evaluator(ac.EvalAll(ac.EvalPermission(ac.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(rule.Existing.NamespaceUID))))
if !allowed {
return nil, fmt.Errorf("%w to delete alert rules from folder UID %s", ErrAuthorization, rule.Existing.NamespaceUID)
return fmt.Errorf("%w to delete alert rules from folder UID %s", ErrAuthorization, rule.Existing.NamespaceUID)
}
if !addAuthorized {
addAuthorized = evaluator(ac.EvalPermission(ac.ActionAlertingRuleCreate, namespaceScope))
if !addAuthorized {
return nil, fmt.Errorf("%w to create alert rules in the folder '%s'", ErrAuthorization, change.GroupKey.NamespaceUID)
return fmt.Errorf("%w to create alert rules in the folder '%s'", ErrAuthorization, change.GroupKey.NamespaceUID)
}
}
continue
} else if !updateAuthorized { // if it is false then the authorization was not checked. If it is true then the user is authorized to update rules
updateAuthorized = evaluator(ac.EvalPermission(ac.ActionAlertingRuleUpdate, namespaceScope))
if !updateAuthorized {
return fmt.Errorf("%w to update alert rules that belong to folder %s", ErrAuthorization, change.GroupKey.NamespaceUID)
}
}
if !updateAuthorized { // if it is false then the authorization was not checked. If it is true then the user is authorized to update rules
updateAuthorized = evaluator(ac.EvalAll(ac.EvalPermission(ac.ActionAlertingRuleUpdate, namespaceScope)))
if !updateAuthorized {
return nil, fmt.Errorf("%w to update alert rules that belong to folder %s", ErrAuthorization, change.GroupKey.NamespaceUID)
if rule.Existing.NamespaceUID != rule.New.NamespaceUID || rule.Existing.RuleGroup != rule.New.RuleGroup {
key := rule.Existing.GetGroupKey()
rules, ok = change.AffectedGroups[key]
if !ok {
// add a safeguard in the case of inconsistency. If user hit this then there is a bug in the calculating of changes struct
return fmt.Errorf("failed to authorize moving an alert rule %s between groups because unable to check access to group %s from which the rule is moved", rule.Existing.UID, rule.Existing.RuleGroup)
}
if !authorizeAccessToRuleGroup(rules, evaluator) {
return fmt.Errorf("%w to move rule %s between two different groups because user does not have access to the source group %s", ErrAuthorization, rule.Existing.UID, rule.Existing.RuleGroup)
}
}
}
return result, nil
return nil
}

View File

@ -1,6 +1,7 @@
package api
import (
"math"
"math/rand"
"net/http"
"os"
@ -16,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
)
func TestAuthorize(t *testing.T) {
@ -68,6 +70,67 @@ func TestAuthorize(t *testing.T) {
})
}
func createAllCombinationsOfPermissions(permissions map[string][]string) []map[string][]string {
type actionscope struct {
action string
scope string
}
var flattenPermissions []actionscope
for action, scopes := range permissions {
for _, scope := range scopes {
flattenPermissions = append(flattenPermissions, actionscope{
action,
scope,
})
}
}
l := len(flattenPermissions)
// this is all possible combinations of the permissions
var permissionCombinations []map[string][]string
for bit := uint(0); bit < uint(math.Pow(2, float64(l))); bit++ {
var tuple []actionscope
for idx := 0; idx < l; idx++ {
if (bit>>idx)&1 == 1 {
tuple = append(tuple, flattenPermissions[idx])
}
}
combination := make(map[string][]string)
for _, perm := range tuple {
combination[perm.action] = append(combination[perm.action], perm.scope)
}
permissionCombinations = append(permissionCombinations, combination)
}
return permissionCombinations
}
func getDatasourceScopesForRules(rules []*models.AlertRule) []string {
scopesMap := map[string]struct{}{}
var result []string
for _, rule := range rules {
for _, query := range rule.Data {
scope := datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID)
if _, ok := scopesMap[scope]; ok {
continue
}
result = append(result, scope)
scopesMap[scope] = struct{}{}
}
}
return result
}
func mapUpdates(updates []ruleUpdate, mapFunc func(ruleUpdate) *models.AlertRule) []*models.AlertRule {
result := make([]*models.AlertRule, 0, len(updates))
for _, update := range updates {
result = append(result, mapFunc(update))
}
return result
}
func TestAuthorizeRuleChanges(t *testing.T) {
groupKey := models.GenerateGroupKey(rand.Int63())
namespaceIdScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(groupKey.NamespaceUID)
@ -103,34 +166,60 @@ func TestAuthorizeRuleChanges(t *testing.T) {
},
},
{
name: "if there are rules to update within the same namespace it should check update action",
name: "if there are rules to delete it should check delete action and query for datasource",
changes: func() *changes {
rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
rules2 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
return &changes{
GroupKey: groupKey,
AffectedGroups: map[models.AlertRuleGroupKey][]*models.AlertRule{
groupKey: append(rules, rules2...),
},
New: nil,
Update: nil,
Delete: rules2,
}
},
permissions: func(c *changes) map[string][]string {
return map[string][]string{
ac.ActionAlertingRuleDelete: {
namespaceIdScope,
},
datasources.ActionQuery: getDatasourceScopesForRules(c.AffectedGroups[c.GroupKey]),
}
},
},
{
name: "if there are rules to update within the same namespace it should check update action and access to datasource",
changes: func() *changes {
rules1 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
updates := make([]ruleUpdate, 0, len(rules))
for _, rule := range rules {
cp := models.CopyRule(rule)
cp.Data = []models.AlertQuery{models.GenerateAlertQuery()}
updates = append(updates, ruleUpdate{
Existing: rule,
New: models.CopyRule(rule),
New: cp,
Diff: nil,
})
}
return &changes{
GroupKey: groupKey,
AffectedGroups: map[models.AlertRuleGroupKey][]*models.AlertRule{
groupKey: append(rules, rules1...),
},
New: nil,
Update: updates,
Delete: nil,
}
},
permissions: func(c *changes) map[string][]string {
var scopes []string
for _, update := range c.Update {
for _, query := range update.New.Data {
scopes = append(scopes, datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID))
}
}
scopes := getDatasourceScopesForRules(append(c.AffectedGroups[c.GroupKey], mapUpdates(c.Update, func(update ruleUpdate) *models.AlertRule {
return update.New
})...))
return map[string][]string{
ac.ActionAlertingRuleUpdate: {
namespaceIdScope,
@ -140,43 +229,131 @@ func TestAuthorizeRuleChanges(t *testing.T) {
},
},
{
name: "if there are rules that are moved between namespaces it should check update action",
name: "if there are rules that are moved between namespaces it should check delete+add action and access to group where rules come from",
changes: func() *changes {
rules1 := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
updates := make([]ruleUpdate, 0, len(rules))
targetGroupKey := models.GenerateGroupKey(groupKey.OrgID)
updates := make([]ruleUpdate, 0, len(rules))
for _, rule := range rules {
cp := models.CopyRule(rule)
cp.NamespaceUID = rule.NamespaceUID + "other"
withGroupKey(targetGroupKey)(cp)
cp.Data = []models.AlertQuery{
models.GenerateAlertQuery(),
}
updates = append(updates, ruleUpdate{
Existing: cp,
New: rule,
Diff: nil,
Existing: rule,
New: cp,
})
}
return &changes{
GroupKey: groupKey,
GroupKey: targetGroupKey,
AffectedGroups: map[models.AlertRuleGroupKey][]*models.AlertRule{
groupKey: append(rules, rules1...),
},
New: nil,
Update: updates,
Delete: nil,
}
},
permissions: func(c *changes) map[string][]string {
var scopes []string
dsScopes := getDatasourceScopesForRules(
append(append(append(c.AffectedGroups[c.GroupKey],
mapUpdates(c.Update, func(update ruleUpdate) *models.AlertRule {
return update.New
})...,
), mapUpdates(c.Update, func(update ruleUpdate) *models.AlertRule {
return update.Existing
})...), c.AffectedGroups[groupKey]...),
)
var deleteScopes []string
for key := range c.AffectedGroups {
deleteScopes = append(deleteScopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(key.NamespaceUID))
}
return map[string][]string{
ac.ActionAlertingRuleDelete: deleteScopes,
ac.ActionAlertingRuleCreate: {
dashboards.ScopeFoldersProvider.GetResourceScopeUID(c.GroupKey.NamespaceUID),
},
datasources.ActionQuery: dsScopes,
}
},
},
{
name: "if there are rules that are moved between groups in the same namespace it should check update action and access to all groups (source+target)",
changes: func() *changes {
targetGroupKey := models.AlertRuleGroupKey{
OrgID: groupKey.OrgID,
NamespaceUID: groupKey.NamespaceUID,
RuleGroup: util.GenerateShortUID(),
}
sourceGroup := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(groupKey)))
targetGroup := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen(withGroupKey(targetGroupKey)))
updates := make([]ruleUpdate, 0, len(sourceGroup))
toCopy := len(sourceGroup)
if toCopy > 1 {
toCopy = rand.Intn(toCopy-1) + 1
}
for i := 0; i < toCopy; i++ {
rule := sourceGroup[0]
cp := models.CopyRule(rule)
withGroupKey(targetGroupKey)(cp)
cp.Data = []models.AlertQuery{
models.GenerateAlertQuery(),
}
updates = append(updates, ruleUpdate{
Existing: rule,
New: cp,
})
}
return &changes{
GroupKey: targetGroupKey,
AffectedGroups: map[models.AlertRuleGroupKey][]*models.AlertRule{
groupKey: sourceGroup,
targetGroupKey: targetGroup,
},
New: nil,
Update: updates,
Delete: nil,
}
},
permissions: func(c *changes) map[string][]string {
scopes := make(map[string]struct{})
for _, update := range c.Update {
for _, query := range update.New.Data {
scopes = append(scopes, datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID))
scopes[datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID)] = struct{}{}
}
for _, query := range update.Existing.Data {
scopes[datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID)] = struct{}{}
}
}
for _, rules := range c.AffectedGroups {
for _, rule := range rules {
for _, query := range rule.Data {
scopes[datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID)] = struct{}{}
}
}
}
dsScopes := make([]string, 0, len(scopes))
for key := range scopes {
dsScopes = append(dsScopes, key)
}
return map[string][]string{
ac.ActionAlertingRuleDelete: {
dashboards.ScopeFoldersProvider.GetResourceScopeUID(groupKey.NamespaceUID + "other"),
ac.ActionAlertingRuleUpdate: {
dashboards.ScopeFoldersProvider.GetResourceScopeUID(c.GroupKey.NamespaceUID),
},
ac.ActionAlertingRuleCreate: {
namespaceIdScope,
},
datasources.ActionQuery: scopes,
datasources.ActionQuery: dsScopes,
}
},
},
@ -184,188 +361,39 @@ func TestAuthorizeRuleChanges(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
groupChanges := testCase.changes()
permissions := testCase.permissions(groupChanges)
t.Run("should fail with insufficient permissions", func(t *testing.T) {
permissionCombinations := createAllCombinationsOfPermissions(permissions)
permissionCombinations = permissionCombinations[0 : len(permissionCombinations)-1] // exclude all permissions
for _, missing := range permissionCombinations {
executed := false
err := authorizeRuleChanges(groupChanges, func(evaluator ac.Evaluator) bool {
response := evaluator.Evaluate(missing)
executed = true
return response
})
require.Errorf(t, err, "expected error because less permissions than expected were provided. Provided: %v; Expected: %v", missing, permissions)
require.ErrorIs(t, err, ErrAuthorization)
require.Truef(t, executed, "evaluation function is expected to be called but it was not.")
}
})
executed := false
groupChanges := testCase.changes()
result, err := authorizeRuleChanges(groupChanges, func(evaluator ac.Evaluator) bool {
response := evaluator.Evaluate(make(map[string][]string))
require.False(t, response)
executed = true
return false
})
require.Nil(t, result)
require.Error(t, err)
require.Truef(t, executed, "evaluation function is expected to be called but it was not.")
permissions := testCase.permissions(groupChanges)
executed = false
result, err = authorizeRuleChanges(groupChanges, func(evaluator ac.Evaluator) bool {
err := authorizeRuleChanges(groupChanges, func(evaluator ac.Evaluator) bool {
response := evaluator.Evaluate(permissions)
require.Truef(t, response, "provided permissions [%v] is not enough for requested permissions [%s]", testCase.permissions, evaluator.GoString())
require.Truef(t, response, "provided permissions [%v] is not enough for requested permissions [%s]", permissions, evaluator.GoString())
executed = true
return true
})
require.NoError(t, err)
require.Equal(t, groupChanges, result)
require.Truef(t, executed, "evaluation function is expected to be called but it was not.")
})
}
}
func TestAuthorizeRuleDelete(t *testing.T) {
groupKey := models.GenerateGroupKey(rand.Int63())
namespaceScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(groupKey.NamespaceUID)
getScopes := func(rules []*models.AlertRule) []string {
var scopes []string
for _, rule := range rules {
for _, query := range rule.Data {
scopes = append(scopes, datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID))
}
}
return scopes
}
testCases := []struct {
name string
changes func() *changes
permissions func(c *changes) map[string][]string
assert func(t *testing.T, orig, authz *changes, err error)
}{
{
name: "should validate check access to data source and folder",
changes: func() *changes {
return &changes{
GroupKey: groupKey,
New: nil,
Update: nil,
Delete: models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))),
}
},
permissions: func(c *changes) map[string][]string {
return map[string][]string{
ac.ActionAlertingRuleDelete: {
namespaceScope,
},
datasources.ActionQuery: getScopes(c.Delete),
}
},
assert: func(t *testing.T, orig, authz *changes, err error) {
require.NoError(t, err)
require.Equal(t, orig, authz)
},
},
{
name: "should remove rules user does not have access to data source",
changes: func() *changes {
return &changes{
GroupKey: groupKey,
New: nil,
Update: nil,
Delete: models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))),
}
},
permissions: func(c *changes) map[string][]string {
return map[string][]string{
ac.ActionAlertingRuleDelete: {
namespaceScope,
},
datasources.ActionQuery: {
getScopes(c.Delete[:1])[0],
},
}
},
assert: func(t *testing.T, orig, authz *changes, err error) {
require.NoError(t, err)
require.Greater(t, len(orig.Delete), len(authz.Delete))
},
},
{
name: "should not fail if no changes other than unauthorized",
changes: func() *changes {
return &changes{
GroupKey: groupKey,
New: nil,
Update: nil,
Delete: models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))),
}
},
permissions: func(c *changes) map[string][]string {
return map[string][]string{
ac.ActionAlertingRuleDelete: {
namespaceScope,
},
}
},
assert: func(t *testing.T, orig, authz *changes, err error) {
require.NoError(t, err)
require.False(t, orig.isEmpty())
require.True(t, authz.isEmpty())
},
},
{
name: "should not fail if there are changes and no rules can be deleted",
changes: func() *changes {
return &changes{
GroupKey: groupKey,
New: models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))),
Update: nil,
Delete: models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))),
}
},
permissions: func(c *changes) map[string][]string {
return map[string][]string{
ac.ActionAlertingRuleDelete: {
namespaceScope,
},
ac.ActionAlertingRuleCreate: {
namespaceScope,
},
datasources.ActionQuery: getScopes(c.New),
}
},
assert: func(t *testing.T, _, c *changes, err error) {
require.NoError(t, err)
require.Empty(t, c.Delete)
},
},
{
name: "should fail if no access to folder",
changes: func() *changes {
return &changes{
GroupKey: groupKey,
New: nil,
Update: nil,
Delete: models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withGroupKey(groupKey))),
}
},
permissions: func(c *changes) map[string][]string {
return map[string][]string{
datasources.ActionQuery: getScopes(c.Delete),
}
},
assert: func(t *testing.T, _, c *changes, err error) {
require.ErrorIs(t, err, ErrAuthorization)
require.Nil(t, c)
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
groupChanges := testCase.changes()
permissions := testCase.permissions(groupChanges)
result, err := authorizeRuleChanges(groupChanges, func(evaluator ac.Evaluator) bool {
response := evaluator.Evaluate(permissions)
return response
})
testCase.assert(t, groupChanges, result, err)
})
}
}
func TestCheckDatasourcePermissionsForRule(t *testing.T) {
rule := models.AlertRuleGen()()
@ -420,3 +448,48 @@ func TestCheckDatasourcePermissionsForRule(t *testing.T) {
require.Equal(t, 1, executed)
})
}
func Test_authorizeAccessToRuleGroup(t *testing.T) {
t.Run("should return true if user has access to all datasources of all rules in group", func(t *testing.T) {
rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen())
var scopes []string
for _, rule := range rules {
for _, query := range rule.Data {
scopes = append(scopes, datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID))
}
}
permissions := map[string][]string{
datasources.ActionQuery: scopes,
}
result := authorizeAccessToRuleGroup(rules, func(evaluator ac.Evaluator) bool {
response := evaluator.Evaluate(permissions)
require.Truef(t, response, "provided permissions [%v] is not enough for requested permissions [%s]", permissions, evaluator.GoString())
return true
})
require.True(t, result)
})
t.Run("should return false if user does not have access to at least one rule in group", func(t *testing.T) {
rules := models.GenerateAlertRules(rand.Intn(4)+1, models.AlertRuleGen())
var scopes []string
for _, rule := range rules {
for _, query := range rule.Data {
scopes = append(scopes, datasources.ScopeProvider.GetResourceScopeUID(query.DatasourceUID))
}
}
permissions := map[string][]string{
datasources.ActionQuery: scopes,
}
rule := models.AlertRuleGen()()
rules = append(rules, rule)
result := authorizeAccessToRuleGroup(rules, func(evaluator ac.Evaluator) bool {
response := evaluator.Evaluate(permissions)
return response
})
require.False(t, result)
})
}

View File

@ -266,6 +266,14 @@ type GetAlertRuleByUIDQuery struct {
Result *AlertRule
}
// GetAlertRulesGroupByRuleUIDQuery is the query for retrieving a group of alerts by UID of a rule that belongs to that group
type GetAlertRulesGroupByRuleUIDQuery struct {
UID string
OrgID int64
Result []*AlertRule
}
// ListAlertRulesQuery is the query for listing alert rules
type ListAlertRulesQuery struct {
OrgID int64

View File

@ -37,6 +37,7 @@ type RuleStore interface {
DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error
DeleteAlertInstancesByRuleUID(ctx context.Context, orgID int64, ruleUID string) error
GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) error
GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) error
GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.GetAlertRulesForSchedulingQuery) error
ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error
// GetRuleGroups returns the unique rule groups across all organizations.
@ -109,6 +110,22 @@ func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAler
})
}
// GetAlertRulesGroupByRuleUID is a handler for retrieving a group of alert rules from that database by UID and organisation ID of one of rules that belong to that group.
func (st DBstore) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) error {
return st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
var result []*ngmodels.AlertRule
err := sess.Table("alert_rule").Alias("A").Join(
"INNER",
"alert_rule AS B", "A.org_id = B.org_id AND A.namespace_uid = B.namespace_uid AND A.rule_group = B.rule_group AND B.uid = ?", query.UID,
).Where("A.org_id = ?", query.OrgID).Select("A.*").Find(&result)
if err != nil {
return err
}
query.Result = result
return nil
})
}
// InsertAlertRules is a handler for creating/updating alert rules.
func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRule) error {
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {

View File

@ -152,6 +152,37 @@ func (f *FakeRuleStore) GetAlertRuleByUID(_ context.Context, q *models.GetAlertR
return nil
}
func (f *FakeRuleStore) GetAlertRulesGroupByRuleUID(_ context.Context, q *models.GetAlertRulesGroupByRuleUIDQuery) error {
f.mtx.Lock()
defer f.mtx.Unlock()
f.RecordedOps = append(f.RecordedOps, *q)
if err := f.Hook(*q); err != nil {
return err
}
rules, ok := f.Rules[q.OrgID]
if !ok {
return nil
}
var selected *models.AlertRule
for _, rule := range rules {
if rule.UID == q.UID {
selected = rule
break
}
}
if selected == nil {
return nil
}
for _, rule := range rules {
if rule.GetGroupKey() == selected.GetGroupKey() {
q.Result = append(q.Result, rule)
}
}
return nil
}
// For now, we're not implementing namespace filtering.
func (f *FakeRuleStore) GetAlertRulesForScheduling(_ context.Context, q *models.GetAlertRulesForSchedulingQuery) error {
f.mtx.Lock()