mirror of
https://github.com/grafana/grafana.git
synced 2024-11-25 18:30:41 -06:00
17ca61d7f8
* Folders: Optionally include fullpath in service responses * Alerting: Export folder fullpath instead of title * Escape separator in folder title * Add support for provisiong alret rules into subfolders * Use FolderService for creating folders during provisioning * Export WithFullpath() folder service function --------- Co-authored-by: Tania B <yalyna.ts@gmail.com> Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
860 lines
29 KiB
Go
860 lines
29 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
|
|
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)
|
|
}
|
|
|
|
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
|
|
// 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},
|
|
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, 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},
|
|
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 := 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, "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 with folder's full path 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) GetAlertGroupsWithFolderFullpath(ctx context.Context, user identity.Requester, folderUIDs []string) ([]models.AlertRuleGroupWithFolderFullpath, 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.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
|
|
}
|
|
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
|
|
}
|