package provisioning import ( "context" "errors" "fmt" "time" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "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/provisioning/validation" "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 AuthorizeRuleRead(ctx context.Context, user identity.Requester, rule *models.AlertRule) 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) } var errProvenanceMismatch = errutil.NewBase(errutil.StatusConflict, "alerting.provenanceMismatch").MustTemplate( "cannot {{ .Public.Operation }} with provided provenance '{{ .Public.ProvidedProvenance }}', needs '{{ .Public.StoredProvenance }}'", errutil.WithPublic("cannot {{ .Public.Operation }} with provided provenance '{{ .Public.ProvidedProvenance }}', needs '{{ .Public.StoredProvenance }}'"), ) 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 quotas QuotaChecker xact TransactionManager log log.Logger nsValidatorProvider NotificationSettingsValidatorProvider authz ruleAccessControlService } func NewAlertRuleService(ruleStore RuleStore, provenanceStore ProvisioningStore, folderService folder.Service, 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, 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) { q := models.GetAlertRuleByUIDQuery{ UID: ruleUID, OrgID: user.GetOrgID(), } var err error rule, err := service.ruleStore.GetAlertRuleByUID(ctx, &q) if err != nil { return models.AlertRule{}, err } if err := service.authz.AuthorizeRuleRead(ctx, user, rule); err != nil { return models.AlertRule{}, err } return *rule, nil } 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 AlertRuleWithFolderFullpath struct { AlertRule models.AlertRule FolderFullpath string } // GetAlertRuleWithFolderFullpath returns a single alert rule with its folder title. func (service *AlertRuleService) GetAlertRuleWithFolderFullpath(ctx context.Context, user identity.Requester, ruleUID string) (AlertRuleWithFolderFullpath, error) { rule, err := service.getAlertRuleAuthorized(ctx, user, ruleUID) if err != nil { return AlertRuleWithFolderFullpath{}, err } fq := folder.GetFolderQuery{ OrgID: user.GetOrgID(), UID: &rule.NamespaceUID, WithFullpath: true, SignedInUser: user, } f, err := service.folderService.Get(ctx, &fq) if err != nil { return AlertRuleWithFolderFullpath{}, err } return AlertRuleWithFolderFullpath{ AlertRule: rule, FolderFullpath: f.Fullpath, }, 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 if err := service.ensureNamespace(ctx, user, rule.OrgID, rule.NamespaceUID); err != nil { return models.AlertRule{}, err } // 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 } 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, userUidOrFallback(user), []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 } // FilterOptions provides filtering for alert rule queries. // All fields are optional and will be applied as filters if provided. type FilterOptions struct { ImportedPrometheusRule *bool RuleGroups []string NamespaceUIDs []string } func (opts *FilterOptions) apply(q models.ListAlertRulesQuery) models.ListAlertRulesQuery { if opts == nil { return q } if opts.ImportedPrometheusRule != nil { q.ImportedPrometheusRule = opts.ImportedPrometheusRule } if len(opts.NamespaceUIDs) > 0 { q.NamespaceUIDs = opts.NamespaceUIDs } if len(opts.RuleGroups) > 0 { q.RuleGroups = opts.RuleGroups } return q } 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}, RuleGroups: []string{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}, RuleGroups: []string{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, userUidOrFallback(user), 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 } for _, rule := range group.Rules { if rule.UID == "" { // if empty the UID will be generated before save continue } if err := util.ValidateUID(rule.UID); err != nil { return fmt.Errorf("%w: cannot create rule with UID %q: %w", models.ErrAlertRuleFailedValidation, rule.UID, 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 { return service.DeleteRuleGroups(ctx, user, provenance, &FilterOptions{ NamespaceUIDs: []string{namespaceUID}, RuleGroups: []string{group}, }) } // DeleteRuleGroups deletes alert rule groups by the specified filter options. func (service *AlertRuleService) DeleteRuleGroups(ctx context.Context, user identity.Requester, provenance models.Provenance, filterOpts *FilterOptions) error { q := models.ListAlertRulesQuery{} q = filterOpts.apply(q) q.OrgID = user.GetOrgID() deltas, err := store.CalculateRuleGroupsDelete(ctx, service.ruleStore, user.GetOrgID(), &q) if err != nil { return err } // Perform all deletions in a transaction return service.xact.InTransaction(ctx, func(ctx context.Context) error { for _, delta := range deltas { 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 } } err = service.persistDelta(ctx, user, delta, provenance) if err != nil { return err } } return nil }) } 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}, RuleGroups: []string{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 := validation.CanUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate { return errProvenanceMismatch.Build(errutil.TemplateData{ Public: map[string]interface{}{ "ProvidedProvenance": provenance, "StoredProvenance": storedProvenance, "Operation": "delete", }, }) } } 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 := validation.CanUpdateProvenanceInRuleGroup(storedProvenance, provenance); !canUpdate { return errProvenanceMismatch.Build(errutil.TemplateData{ Public: map[string]interface{}{ "ProvidedProvenance": provenance, "StoredProvenance": storedProvenance, "Operation": "update", }, }) } updates = append(updates, models.UpdateRule{ Existing: update.Existing, New: *update.New, }) } if err := service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), 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, userUidOrFallback(user), 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 if err := service.ensureNamespace(ctx, user, rule.OrgID, rule.NamespaceUID); err != nil { return models.AlertRule{}, err } // 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 } if delta.IsEmpty() { // No changes to the rule. return rule, nil } // new rules not allowed in update for a single rule if len(delta.New) > 0 { return models.AlertRule{}, fmt.Errorf("failed to update rule with UID %s because %w", rule.UID, models.ErrAlertRuleNotFound) } 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 // Currently metadata contains only editor settings, so we can just copy it. // If we add more fields to metadata, we might need to handle them separately, // and/or merge or update their values. if rule.Metadata == (models.AlertRuleMetadata{}) { rule.Metadata = storedRule.Metadata } 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, userUidOrFallback(user), []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 errProvenanceMismatch.Build(errutil.TemplateData{ Public: map[string]interface{}{ "ProvidedProvenance": provenance, "StoredProvenance": storedProvenance, "Operation": "delete", }, }) } 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 var userID int64 if id, err := identity.UserIdentifier(user.GetID()); err == nil { userID = id } limitReached, err := service.quotas.CheckQuotaReached(ctx, models.QuotaTargetSrv, "a.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 } // GetAlertRuleGroupWithFolderFullpath returns the alert rule group with folder title. func (service *AlertRuleService) GetAlertRuleGroupWithFolderFullpath(ctx context.Context, user identity.Requester, namespaceUID, group string) (models.AlertRuleGroupWithFolderFullpath, error) { ruleList, err := service.GetRuleGroup(ctx, user, namespaceUID, group) if err != nil { return models.AlertRuleGroupWithFolderFullpath{}, err } fq := folder.GetFolderQuery{ OrgID: user.GetOrgID(), UID: &namespaceUID, WithFullpath: true, SignedInUser: user, } f, err := service.folderService.Get(ctx, &fq) if err != nil { return models.AlertRuleGroupWithFolderFullpath{}, err } res := models.NewAlertRuleGroupWithFolderFullpath(ruleList.Rules[0].GetGroupKey(), ruleList.Rules, f.Fullpath) return res, nil } // GetAlertGroupsWithFolderFullpath returns all groups that have at least one alert with the full folder path for each group. // It queries all alert rules for the user's organization, applies optional filtering specified in filterOpts, // and groups the rules by groups. The function then fetches folder details (including the full path) // for each namespace (folder UID) associated with the rule groups. If the user lacks blanket read permissions, // only the groups that the user is authorized to view are returned. func (service *AlertRuleService) GetAlertGroupsWithFolderFullpath(ctx context.Context, user identity.Requester, filterOpts *FilterOptions) ([]models.AlertRuleGroupWithFolderFullpath, error) { q := models.ListAlertRulesQuery{ OrgID: user.GetOrgID(), } q = filterOpts.apply(q) 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.AlertRuleGroupWithFolderFullpath{}, nil } fq := folder.GetFoldersQuery{ OrgID: user.GetOrgID(), UIDs: nil, WithFullpath: true, SignedInUser: user, } for uid := range namespaces { fq.UIDs = append(fq.UIDs, 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. folders, err := service.folderService.GetFolders(ctx, fq) if err != nil { return nil, err } folderUidToFullpath := make(map[string]string) for _, folder := range folders { folderUidToFullpath[folder.UID] = folder.Fullpath } result := make([]models.AlertRuleGroupWithFolderFullpath, 0) for groupKey, rules := range groups { fullpath, ok := folderUidToFullpath[groupKey.NamespaceUID] if !ok { return nil, fmt.Errorf("cannot find full path for folder with uid '%s'", groupKey.NamespaceUID) } result = append(result, models.NewAlertRuleGroupWithFolderFullpathFromRulesGroup(groupKey, rules, fullpath)) } // 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 group.Rules[i].RuleGroupIndex = i } 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 } // ensureNamespace 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) ensureNamespace(ctx context.Context, user identity.Requester, orgID int64, namespaceUID string) error { if 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: orgID, UID: &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 } func userUidOrFallback(user identity.Requester) *models.UserUID { userUID := models.NewUserUID(user) if user == nil { return &models.FileProvisioningUserUID } return userUID }