grafana/pkg/services/ngalert/provisioning/alert_rules.go
Yuri Tseretyan b9abb8cabb
Alerting: Update provisioning API to support regular permissions (#77007)
* allow users with regular actions access provisioning API paths
* update methods that read rules
skip new authorization logic if user CanReadAllRules to avoid performance impact on file-provisioning
update all methods to accept identity.Requester that contains all permissions and is required by access control.

* create deltas for single rul e 

* update modify methods
skip new authorization logic if user CanWriteAllRules to avoid performance impact on file-provisioning
update all methods to accept identity.Requester that contains all permissions and is required by access control.

* implement RuleAccessControlService in provisioning

* update file provisioning user to have all permissions to bypass authz

* update provisioning API to return errutil errors correctly

---------

Co-authored-by: Alexander Weaver <weaver.alex.d@gmail.com>
2024-03-22 15:37:10 -04:00

885 lines
30 KiB
Go

package provisioning
import (
"context"
"errors"
"fmt"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/util"
)
type ruleAccessControlService interface {
AuthorizeRuleGroupRead(ctx context.Context, user identity.Requester, rules models.RulesGroup) error
AuthorizeRuleGroupWrite(ctx context.Context, user identity.Requester, change *store.GroupDelta) error
// CanReadAllRules returns true if the user has full access to read rules via provisioning API and bypass regular checks
CanReadAllRules(ctx context.Context, user identity.Requester) (bool, error)
// CanWriteAllRules returns true if the user has full access to write rules via provisioning API and bypass regular checks
CanWriteAllRules(ctx context.Context, user identity.Requester) (bool, error)
}
type NotificationSettingsValidatorProvider interface {
Validator(ctx context.Context, orgID int64) (notifier.NotificationSettingsValidator, error)
}
type AlertRuleService struct {
defaultIntervalSeconds int64
baseIntervalSeconds int64
rulesPerRuleGroupLimit int64
ruleStore RuleStore
provenanceStore ProvisioningStore
folderService folder.Service
dashboardService dashboards.DashboardService
quotas QuotaChecker
xact TransactionManager
log log.Logger
nsValidatorProvider NotificationSettingsValidatorProvider
authz ruleAccessControlService
}
func NewAlertRuleService(ruleStore RuleStore,
provenanceStore ProvisioningStore,
folderService folder.Service,
dashboardService dashboards.DashboardService,
quotas QuotaChecker,
xact TransactionManager,
defaultIntervalSeconds int64,
baseIntervalSeconds int64,
rulesPerRuleGroupLimit int64,
log log.Logger,
ns NotificationSettingsValidatorProvider,
authz RuleAccessControlService,
) *AlertRuleService {
return &AlertRuleService{
defaultIntervalSeconds: defaultIntervalSeconds,
baseIntervalSeconds: baseIntervalSeconds,
rulesPerRuleGroupLimit: rulesPerRuleGroupLimit,
ruleStore: ruleStore,
provenanceStore: provenanceStore,
folderService: folderService,
dashboardService: dashboardService,
quotas: quotas,
xact: xact,
log: log,
nsValidatorProvider: ns,
authz: newRuleAccessControlService(authz),
}
}
func (service *AlertRuleService) GetAlertRules(ctx context.Context, user identity.Requester) ([]*models.AlertRule, map[string]models.Provenance, error) {
q := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
}
rules, err := service.ruleStore.ListAlertRules(ctx, &q)
if err != nil {
return nil, nil, err
}
provenances := make(map[string]models.Provenance)
if len(rules) > 0 {
resourceType := rules[0].ResourceType()
provenances, err = service.provenanceStore.GetProvenances(ctx, user.GetOrgID(), resourceType)
if err != nil {
return nil, nil, err
}
}
can, err := service.authz.CanReadAllRules(ctx, user)
if err != nil {
return nil, nil, err
}
if can {
return rules, provenances, nil
}
// If user does not have blanket privilege to read rules, remove all rules that are not allowed to the user.
groups := models.GroupByAlertRuleGroupKey(rules)
result := make([]*models.AlertRule, 0, len(rules))
for _, group := range groups {
if err := service.authz.AuthorizeRuleGroupRead(ctx, user, group); err != nil {
if errors.Is(err, accesscontrol.ErrAuthorizationBase) {
// remove provenances for rules that will not be added to the output
for _, rule := range group {
delete(provenances, rule.ResourceID())
}
continue
}
return nil, nil, err
}
result = append(result, group...)
}
return result, provenances, nil
}
func (service *AlertRuleService) getAlertRuleAuthorized(ctx context.Context, user identity.Requester, ruleUID string) (models.AlertRule, error) {
// check if the user can read all rules. If it cannot, pull the entire group and verify access to the entire group.
can, err := service.authz.CanReadAllRules(ctx, user)
if err != nil {
return models.AlertRule{}, err
}
// if user has blanket access to all rules, just read a single rule from database
if can {
query := &models.GetAlertRuleByUIDQuery{
OrgID: user.GetOrgID(),
UID: ruleUID,
}
rule, err := service.ruleStore.GetAlertRuleByUID(ctx, query)
if err != nil {
return models.AlertRule{}, err
}
if rule == nil {
return models.AlertRule{}, models.ErrAlertRuleNotFound
}
return *rule, nil
}
// if user does not have privilege to access all rules, check that the user can read this rule by fetching entire group and
// checking that user has access to it.
q := &models.GetAlertRulesGroupByRuleUIDQuery{
UID: ruleUID,
OrgID: user.GetOrgID(),
}
group, err := service.ruleStore.GetAlertRulesGroupByRuleUID(ctx, q)
if err != nil {
return models.AlertRule{}, err
}
if len(group) == 0 {
return models.AlertRule{}, models.ErrAlertRuleNotFound
}
if err := service.authz.AuthorizeRuleGroupRead(ctx, user, group); err != nil {
return models.AlertRule{}, err
}
for _, rule := range group {
if rule.UID == ruleUID {
return *rule, nil
}
}
return models.AlertRule{}, models.ErrAlertRuleNotFound
}
func (service *AlertRuleService) GetAlertRule(ctx context.Context, user identity.Requester, ruleUID string) (models.AlertRule, models.Provenance, error) {
rule, err := service.getAlertRuleAuthorized(ctx, user, ruleUID)
if err != nil {
return models.AlertRule{}, models.ProvenanceNone, err
}
provenance, err := service.provenanceStore.GetProvenance(ctx, &rule, user.GetOrgID())
if err != nil {
return models.AlertRule{}, models.ProvenanceNone, err
}
return rule, provenance, nil
}
type AlertRuleWithFolderTitle struct {
AlertRule models.AlertRule
FolderTitle string
}
// GetAlertRuleWithFolderTitle returns a single alert rule with its folder title.
func (service *AlertRuleService) GetAlertRuleWithFolderTitle(ctx context.Context, user identity.Requester, ruleUID string) (AlertRuleWithFolderTitle, error) {
rule, err := service.getAlertRuleAuthorized(ctx, user, ruleUID)
if err != nil {
return AlertRuleWithFolderTitle{}, err
}
dq := dashboards.GetDashboardQuery{
OrgID: user.GetOrgID(),
UID: rule.NamespaceUID,
}
dash, err := service.dashboardService.GetDashboard(ctx, &dq)
if err != nil {
return AlertRuleWithFolderTitle{}, err
}
return AlertRuleWithFolderTitle{
AlertRule: rule,
FolderTitle: dash.Title,
}, nil
}
// CreateAlertRule creates a new alert rule. This function will ignore any
// interval that is set in the rule struct and use the already existing group
// interval or the default one.
func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user identity.Requester, rule models.AlertRule, provenance models.Provenance) (models.AlertRule, error) {
if rule.UID == "" {
rule.UID = util.GenerateShortUID()
} else if err := util.ValidateUID(rule.UID); err != nil {
return models.AlertRule{}, errors.Join(models.ErrAlertRuleFailedValidation, fmt.Errorf("cannot create rule with UID '%s': %w", rule.UID, err))
}
var interval = service.defaultIntervalSeconds
// check if user can bypass fine-grained rule authorization checks. If it cannot, verfiy that the user can add rules to the group
canWriteAllRules, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return models.AlertRule{}, err
}
if canWriteAllRules {
groupInterval, err := service.ruleStore.GetRuleGroupInterval(ctx, rule.OrgID, rule.NamespaceUID, rule.RuleGroup)
// if the alert group does not exist we just use the default interval
if err == nil {
interval = groupInterval
} else if !errors.Is(err, models.ErrAlertRuleGroupNotFound) {
return models.AlertRule{}, err
}
} else {
delta, err := store.CalculateRuleCreate(ctx, service.ruleStore, &rule)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to calculate delta: %w", err)
}
if err := service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return models.AlertRule{}, err
}
existingGroup := delta.AffectedGroups[rule.GetGroupKey()]
if len(existingGroup) > 0 {
interval = existingGroup[0].IntervalSeconds
}
}
rule.IntervalSeconds = interval
err = rule.SetDashboardAndPanelFromAnnotations()
if err != nil {
return models.AlertRule{}, err
}
if err = service.ensureRuleNamespace(ctx, user, rule); err != nil {
return models.AlertRule{}, err
}
rule.Updated = time.Now()
if len(rule.NotificationSettings) > 0 {
validator, err := service.nsValidatorProvider.Validator(ctx, rule.OrgID)
if err != nil {
return models.AlertRule{}, err
}
for _, setting := range rule.NotificationSettings {
if err := validator.Validate(setting); err != nil {
return models.AlertRule{}, err
}
}
}
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
ids, err := service.ruleStore.InsertAlertRules(ctx, []models.AlertRule{
rule,
})
if err != nil {
return err
}
var fixed bool
for _, key := range ids {
if key.UID == rule.UID {
rule.ID = key.ID
fixed = true
break
}
}
if !fixed {
return errors.New("couldn't find newly created id")
}
if err = service.checkLimitsTransactionCtx(ctx, user); err != nil {
return err
}
return service.provenanceStore.SetProvenance(ctx, &rule, rule.OrgID, provenance)
})
if err != nil {
return models.AlertRule{}, err
}
return rule, nil
}
func (service *AlertRuleService) GetRuleGroup(ctx context.Context, user identity.Requester, namespaceUID, group string) (models.AlertRuleGroup, error) {
q := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
NamespaceUIDs: []string{namespaceUID},
RuleGroup: group,
}
ruleList, err := service.ruleStore.ListAlertRules(ctx, &q)
if err != nil {
return models.AlertRuleGroup{}, err
}
if len(ruleList) == 0 {
return models.AlertRuleGroup{}, models.ErrAlertRuleGroupNotFound.Errorf("")
}
can, err := service.authz.CanReadAllRules(ctx, user)
if err != nil {
return models.AlertRuleGroup{}, err
}
if !can {
if err := service.authz.AuthorizeRuleGroupRead(ctx, user, ruleList); err != nil {
return models.AlertRuleGroup{}, err
}
}
res := models.AlertRuleGroup{
Title: ruleList[0].RuleGroup,
FolderUID: ruleList[0].NamespaceUID,
Interval: ruleList[0].IntervalSeconds,
Rules: make([]models.AlertRule, 0, len(ruleList)),
}
for _, r := range ruleList {
if r != nil {
res.Rules = append(res.Rules, *r)
}
}
return res, nil
}
// UpdateRuleGroup will update the interval for all rules in the group.
func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user identity.Requester, namespaceUID string, ruleGroup string, intervalSeconds int64) error {
if err := models.ValidateRuleGroupInterval(intervalSeconds, service.baseIntervalSeconds); err != nil {
return err
}
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
query := &models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
NamespaceUIDs: []string{namespaceUID},
RuleGroup: ruleGroup,
}
ruleList, err := service.ruleStore.ListAlertRules(ctx, query)
if err != nil {
return fmt.Errorf("failed to list alert rules: %w", err)
}
updateRules := make([]models.UpdateRule, 0, len(ruleList))
for _, rule := range ruleList {
if rule.IntervalSeconds == intervalSeconds {
continue
}
newRule := *rule
newRule.IntervalSeconds = intervalSeconds
updateRules = append(updateRules, models.UpdateRule{
Existing: rule,
New: newRule,
})
}
// check if user has write access to all rules and can bypass the regular checks.
can, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
// If it cannot, check that the user is authorized to perform all the changes caused by this request
if !can {
groupKey := models.AlertRuleGroupKey{
OrgID: user.GetOrgID(),
NamespaceUID: namespaceUID,
RuleGroup: ruleGroup,
}
ruleDeltas := make([]store.RuleDelta, 0, len(ruleList))
for _, upd := range updateRules {
updNew := upd.New
ruleDeltas = append(ruleDeltas, store.RuleDelta{
Existing: upd.Existing,
New: &updNew,
})
}
delta := &store.GroupDelta{
GroupKey: groupKey,
AffectedGroups: map[models.AlertRuleGroupKey]models.RulesGroup{
groupKey: ruleList,
},
Update: ruleDeltas,
}
if err := service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return err
}
}
return service.ruleStore.UpdateAlertRules(ctx, updateRules)
})
}
func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user identity.Requester, group models.AlertRuleGroup, provenance models.Provenance) error {
if err := models.ValidateRuleGroupInterval(group.Interval, service.baseIntervalSeconds); err != nil {
return err
}
delta, err := service.calcDelta(ctx, user, group)
if err != nil {
return err
}
if delta.IsEmpty() {
return nil
}
// check if the current user has permissions to all rules and can bypass the regular authorization validation.
can, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
if !can {
if err := service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return err
}
}
newOrUpdatedNotificationSettings := delta.NewOrUpdatedNotificationSettings()
if len(newOrUpdatedNotificationSettings) > 0 {
validator, err := service.nsValidatorProvider.Validator(ctx, delta.GroupKey.OrgID)
if err != nil {
return err
}
for _, s := range newOrUpdatedNotificationSettings {
if err := validator.Validate(s); err != nil {
return errors.Join(models.ErrAlertRuleFailedValidation, err)
}
}
}
return service.persistDelta(ctx, user, delta, provenance)
}
func (service *AlertRuleService) DeleteRuleGroup(ctx context.Context, user identity.Requester, namespaceUID, group string, provenance models.Provenance) error {
delta, err := store.CalculateRuleGroupDelete(ctx, service.ruleStore, models.AlertRuleGroupKey{
OrgID: user.GetOrgID(),
NamespaceUID: namespaceUID,
RuleGroup: group,
})
if err != nil {
return err
}
// check if the current user has permissions to all rules and can bypass the regular authorization validation.
can, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
if !can {
if err := service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return err
}
}
return service.persistDelta(ctx, user, delta, provenance)
}
func (service *AlertRuleService) calcDelta(ctx context.Context, user identity.Requester, group models.AlertRuleGroup) (*store.GroupDelta, error) {
// If the provided request did not provide the rules list at all, treat it as though it does not wish to change rules.
// This is done for backwards compatibility. Requests which specify only the interval must update only the interval.
if group.Rules == nil {
listRulesQuery := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
NamespaceUIDs: []string{group.FolderUID},
RuleGroup: group.Title,
}
ruleList, err := service.ruleStore.ListAlertRules(ctx, &listRulesQuery)
if err != nil {
return nil, fmt.Errorf("failed to list alert rules: %w", err)
}
group.Rules = make([]models.AlertRule, 0, len(ruleList))
for _, r := range ruleList {
if r != nil {
group.Rules = append(group.Rules, *r)
}
}
}
if err := service.checkGroupLimits(group); err != nil {
return nil, fmt.Errorf("write rejected due to exceeded limits: %w", err)
}
key := models.AlertRuleGroupKey{
OrgID: user.GetOrgID(),
NamespaceUID: group.FolderUID,
RuleGroup: group.Title,
}
rules := make([]*models.AlertRuleWithOptionals, 0, len(group.Rules))
group = *syncGroupRuleFields(&group, user.GetOrgID())
for i := range group.Rules {
if err := group.Rules[i].SetDashboardAndPanelFromAnnotations(); err != nil {
return nil, err
}
rules = append(rules, &models.AlertRuleWithOptionals{AlertRule: group.Rules[i], HasPause: true})
}
delta, err := store.CalculateChanges(ctx, service.ruleStore, key, rules)
if err != nil {
return nil, fmt.Errorf("failed to calculate diff for alert rules: %w", err)
}
// Refresh all calculated fields across all rules.
return store.UpdateCalculatedRuleFields(delta), nil
}
func (service *AlertRuleService) persistDelta(ctx context.Context, user identity.Requester, delta *store.GroupDelta, provenance models.Provenance) error {
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
// Delete first as this could prevent future unique constraint violations.
if len(delta.Delete) > 0 {
for _, del := range delta.Delete {
// check that provenance is not changed in an invalid way
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, del, user.GetOrgID())
if err != nil {
return err
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot delete with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
}
if err := service.deleteRules(ctx, user.GetOrgID(), delta.Delete...); err != nil {
return err
}
}
if len(delta.Update) > 0 {
updates := make([]models.UpdateRule, 0, len(delta.Update))
for _, update := range delta.Update {
// check that provenance is not changed in an invalid way
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, update.New, user.GetOrgID())
if err != nil {
return err
}
if canUpdate := canUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate {
return fmt.Errorf("cannot update with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
updates = append(updates, models.UpdateRule{
Existing: update.Existing,
New: *update.New,
})
}
if err := service.ruleStore.UpdateAlertRules(ctx, updates); err != nil {
return fmt.Errorf("failed to update alert rules: %w", err)
}
for _, update := range delta.Update {
if err := service.provenanceStore.SetProvenance(ctx, update.New, user.GetOrgID(), provenance); err != nil {
return err
}
}
}
if len(delta.New) > 0 {
uids, err := service.ruleStore.InsertAlertRules(ctx, withoutNilAlertRules(delta.New))
if err != nil {
return fmt.Errorf("failed to insert alert rules: %w", err)
}
for _, key := range uids {
if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: key.UID}, user.GetOrgID(), provenance); err != nil {
return err
}
}
}
if err := service.checkLimitsTransactionCtx(ctx, user); err != nil {
return err
}
return nil
})
}
// UpdateAlertRule updates an alert rule.
func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user identity.Requester, rule models.AlertRule, provenance models.Provenance) (models.AlertRule, error) {
var storedRule *models.AlertRule
// check if the user has full access to all rules and can bypass the regular authorization validations.
// If it cannot, calculate the changes to the group caused by this update and authorize them.
canWriteAllRules, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return models.AlertRule{}, err
}
if canWriteAllRules {
query := &models.GetAlertRuleByUIDQuery{
OrgID: rule.OrgID,
UID: rule.UID,
}
existing, err := service.ruleStore.GetAlertRuleByUID(ctx, query)
if err != nil {
return models.AlertRule{}, err
}
storedRule = existing
} else {
delta, err := store.CalculateRuleUpdate(ctx, service.ruleStore, &models.AlertRuleWithOptionals{AlertRule: rule})
if err != nil {
return models.AlertRule{}, err
}
if err = service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return models.AlertRule{}, err
}
for _, d := range delta.Update {
if d.Existing.GetKey() == rule.GetKey() {
storedRule = d.Existing
}
}
if storedRule == nil { // this should not happen but we better catch it to avoid panic
return models.AlertRule{}, fmt.Errorf("cannot find rule in the delta")
}
}
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, storedRule, storedRule.OrgID)
if err != nil {
return models.AlertRule{}, err
}
if storedProvenance != provenance && storedProvenance != models.ProvenanceNone {
return models.AlertRule{}, fmt.Errorf("cannot change provenance from '%s' to '%s'", storedProvenance, provenance)
}
if len(rule.NotificationSettings) > 0 {
validator, err := service.nsValidatorProvider.Validator(ctx, rule.OrgID)
if err != nil {
return models.AlertRule{}, err
}
for _, setting := range rule.NotificationSettings {
if err := validator.Validate(setting); err != nil {
return models.AlertRule{}, err
}
}
}
rule.Updated = time.Now()
rule.ID = storedRule.ID
rule.IntervalSeconds = storedRule.IntervalSeconds
err = rule.SetDashboardAndPanelFromAnnotations()
if err != nil {
return models.AlertRule{}, err
}
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
err := service.ruleStore.UpdateAlertRules(ctx, []models.UpdateRule{
{
Existing: storedRule,
New: rule,
},
})
if err != nil {
return err
}
return service.provenanceStore.SetProvenance(ctx, &rule, rule.OrgID, provenance)
})
if err != nil {
return models.AlertRule{}, err
}
return rule, err
}
func (service *AlertRuleService) DeleteAlertRule(ctx context.Context, user identity.Requester, ruleUID string, provenance models.Provenance) error {
rule := &models.AlertRule{
OrgID: user.GetOrgID(),
UID: ruleUID,
}
// check that provenance is not changed in an invalid way
storedProvenance, err := service.provenanceStore.GetProvenance(ctx, rule, rule.OrgID)
if err != nil {
return err
}
if storedProvenance != provenance && storedProvenance != models.ProvenanceNone {
return fmt.Errorf("cannot delete with provided provenance '%s', needs '%s'", provenance, storedProvenance)
}
can, err := service.authz.CanWriteAllRules(ctx, user)
if err != nil {
return err
}
if !can {
delta, err := store.CalculateRuleDelete(ctx, service.ruleStore, rule.GetKey())
if err != nil {
return err
}
if err = service.authz.AuthorizeRuleGroupWrite(ctx, user, delta); err != nil {
return err
}
}
// The single delete is idempotent, and doesn't error when deleting a group that already doesn't exist.
// This is different from deleting groups. We delete the rules directly rather than persisting a delta here to keep the semantics the same.
// TODO: Either persist a delta here as a breaking change, or deprecate this endpoint in favor of the group endpoint.
return service.xact.InTransaction(ctx, func(ctx context.Context) error {
return service.deleteRules(ctx, user.GetOrgID(), rule)
})
}
// checkLimitsTransactionCtx checks whether the current transaction (as identified by the ctx) breaches configured alert rule limits.
func (service *AlertRuleService) checkLimitsTransactionCtx(ctx context.Context, user identity.Requester) error {
// default to 0 if there is no user
userID := int64(0)
u, err := identity.UserIdentifier(user.GetNamespacedID())
if err != nil {
return fmt.Errorf("failed to check alert rule quota: %w", err)
}
userID = u
limitReached, err := service.quotas.CheckQuotaReached(ctx, models.QuotaTargetSrv, &quota.ScopeParameters{
OrgID: user.GetOrgID(),
UserID: userID,
})
if err != nil {
return fmt.Errorf("failed to check alert rule quota: %w", err)
}
if limitReached {
return models.ErrQuotaReached
}
return nil
}
// deleteRules deletes a set of target rules and associated data, while checking for database consistency.
func (service *AlertRuleService) deleteRules(ctx context.Context, orgID int64, targets ...*models.AlertRule) error {
uids := make([]string, 0, len(targets))
for _, tgt := range targets {
if tgt != nil {
uids = append(uids, tgt.UID)
}
}
if err := service.ruleStore.DeleteAlertRulesByUID(ctx, orgID, uids...); err != nil {
return err
}
for _, uid := range uids {
if err := service.provenanceStore.DeleteProvenance(ctx, &models.AlertRule{UID: uid}, orgID); err != nil {
// We failed to clean up the record, but this doesn't break things. Log it and move on.
service.log.Warn("Failed to delete provenance record for rule: %w", err)
}
}
return nil
}
// GetAlertRuleGroupWithFolderTitle returns the alert rule group with folder title.
func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Context, user identity.Requester, namespaceUID, group string) (models.AlertRuleGroupWithFolderTitle, error) {
ruleList, err := service.GetRuleGroup(ctx, user, namespaceUID, group)
if err != nil {
return models.AlertRuleGroupWithFolderTitle{}, err
}
dq := dashboards.GetDashboardQuery{
OrgID: user.GetOrgID(),
UID: namespaceUID,
}
dash, err := service.dashboardService.GetDashboard(ctx, &dq)
if err != nil {
return models.AlertRuleGroupWithFolderTitle{}, err
}
res := models.NewAlertRuleGroupWithFolderTitle(ruleList.Rules[0].GetGroupKey(), ruleList.Rules, dash.Title)
return res, nil
}
// GetAlertGroupsWithFolderTitle returns all groups with folder title in the folders identified by folderUID that have at least one alert. If argument folderUIDs is nil or empty - returns groups in all folders.
func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Context, user identity.Requester, folderUIDs []string) ([]models.AlertRuleGroupWithFolderTitle, error) {
q := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
}
if len(folderUIDs) > 0 {
q.NamespaceUIDs = folderUIDs
}
ruleList, err := service.ruleStore.ListAlertRules(ctx, &q)
if err != nil {
return nil, err
}
groups := models.GroupByAlertRuleGroupKey(ruleList)
can, err := service.authz.CanReadAllRules(ctx, user)
if err != nil {
return nil, err
}
if !can {
// if user cannot read all rules, check read access to each group and remove groups that the user does not have access to
for key, group := range groups {
if err := service.authz.AuthorizeRuleGroupRead(ctx, user, group); err != nil {
if errors.Is(err, accesscontrol.ErrAuthorizationBase) {
delete(groups, key)
continue
}
return nil, err
}
}
}
namespaces := make(map[string][]*models.AlertRuleGroupKey)
for groupKey := range groups {
namespaces[groupKey.NamespaceUID] = append(namespaces[groupKey.NamespaceUID], util.Pointer(groupKey))
}
if len(namespaces) == 0 {
return []models.AlertRuleGroupWithFolderTitle{}, nil
}
dq := dashboards.GetDashboardsQuery{
DashboardUIDs: nil,
}
for uid := range namespaces {
dq.DashboardUIDs = append(dq.DashboardUIDs, uid)
}
// We need folder titles for the provisioning file format. We do it this way instead of using GetUserVisibleNamespaces to avoid folder:read permissions that should not apply to those with alert.provisioning:read.
dashes, err := service.dashboardService.GetDashboards(ctx, &dq)
if err != nil {
return nil, err
}
folderUidToTitle := make(map[string]string)
for _, dash := range dashes {
folderUidToTitle[dash.UID] = dash.Title
}
result := make([]models.AlertRuleGroupWithFolderTitle, 0)
for groupKey, rules := range groups {
title, ok := folderUidToTitle[groupKey.NamespaceUID]
if !ok {
return nil, fmt.Errorf("cannot find title for folder with uid '%s'", groupKey.NamespaceUID)
}
result = append(result, models.NewAlertRuleGroupWithFolderTitleFromRulesGroup(groupKey, rules, title))
}
// Return results in a stable manner.
models.SortAlertRuleGroupWithFolderTitle(result)
return result, nil
}
// syncRuleGroupFields synchronizes calculated fields across multiple rules in a group.
func syncGroupRuleFields(group *models.AlertRuleGroup, orgID int64) *models.AlertRuleGroup {
for i := range group.Rules {
group.Rules[i].IntervalSeconds = group.Interval
group.Rules[i].RuleGroup = group.Title
group.Rules[i].NamespaceUID = group.FolderUID
group.Rules[i].OrgID = orgID
}
return group
}
func withoutNilAlertRules(ptrs []*models.AlertRule) []models.AlertRule {
result := make([]models.AlertRule, 0, len(ptrs))
for _, ptr := range ptrs {
if ptr != nil {
result = append(result, *ptr)
}
}
return result
}
func (service *AlertRuleService) checkGroupLimits(group models.AlertRuleGroup) error {
if service.rulesPerRuleGroupLimit > 0 && int64(len(group.Rules)) > service.rulesPerRuleGroupLimit {
service.log.Warn("Large rule group was edited. Large groups are discouraged and may be rejected in the future.",
"limit", service.rulesPerRuleGroupLimit,
"actual", len(group.Rules),
"group", group.Title,
)
}
return nil
}
// ensureRuleNamespace ensures that the rule has a valid namespace UID.
// If the rule does not have a namespace UID or the namespace (folder) does not exist it will return an error.
func (service *AlertRuleService) ensureRuleNamespace(ctx context.Context, user identity.Requester, rule models.AlertRule) error {
if rule.NamespaceUID == "" {
return fmt.Errorf("%w: folderUID must be set", models.ErrAlertRuleFailedValidation)
}
if service.folderService == nil {
// folder service is nil when this is called during file provisioning,
// which already creates the folder if it does not exist
return nil
}
// ensure the namespace exists
_, err := service.folderService.Get(ctx, &folder.GetFolderQuery{
OrgID: rule.OrgID,
UID: &rule.NamespaceUID,
SignedInUser: user,
})
if err != nil {
if errors.Is(err, dashboards.ErrFolderNotFound) {
return fmt.Errorf("%w: folder does not exist", models.ErrAlertRuleFailedValidation)
}
return err
}
return nil
}