Alerting: Expose updated_by in rules GET APIs (#99525)

---------

Signed-off-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
This commit is contained in:
Yuri Tseretyan
2025-01-27 14:31:40 -05:00
committed by GitHub
parent 32ae292334
commit d71904cb27
9 changed files with 265 additions and 33 deletions
+3
View File
@@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -78,6 +79,7 @@ type API struct {
Historian Historian
Tracer tracing.Tracer
AppUrl *url.URL
UserService user.Service
// Hooks can be used to replace API handlers for specific paths.
Hooks *Hooks
@@ -135,6 +137,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
amConfigStore: api.AlertingStore,
amRefresher: api.MultiOrgAlertmanager,
featureManager: api.FeatureManager,
userService: api.UserService,
},
), m)
api.RegisterTestingApiEndpoints(NewTestingApi(
+35 -7
View File
@@ -27,6 +27,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -53,6 +54,7 @@ type RulerSrv struct {
cfg *setting.UnifiedAlertingSettings
conditionValidator ConditionValidator
authz RuleAccessControlService
userService user.Service
amConfigStore AMConfigStore
amRefresher AMRefresher
@@ -211,7 +213,7 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, nam
result := apimodels.NamespaceConfigResponse{}
for groupKey, rules := range ruleGroups {
result[namespace.Fullpath] = append(result[namespace.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
result[namespace.Fullpath] = append(result[namespace.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords, srv.resolveUserIdToNameFn(c.Req.Context())))
}
return response.JSON(http.StatusAccepted, result)
@@ -246,7 +248,7 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespa
result := apimodels.RuleGroupConfigResponse{
// nolint:staticcheck
GettableRuleGroupConfig: toGettableRuleGroupConfig(finalRuleGroup, rules, provenanceRecords),
GettableRuleGroupConfig: toGettableRuleGroupConfig(finalRuleGroup, rules, provenanceRecords, srv.resolveUserIdToNameFn(c.Req.Context())),
}
return response.JSON(http.StatusAccepted, result)
}
@@ -300,7 +302,7 @@ func (srv RulerSrv) RouteGetRulesConfig(c *contextmodel.ReqContext) response.Res
srv.log.Error("Namespace not visible to the user", "user", id, "userNamespace", userNamespace, "namespace", groupKey.NamespaceUID)
continue
}
result[folder.Fullpath] = append(result[folder.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
result[folder.Fullpath] = append(result[folder.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords, srv.resolveUserIdToNameFn(c.Req.Context())))
}
return response.JSON(http.StatusOK, result)
}
@@ -323,7 +325,7 @@ func (srv RulerSrv) RouteGetRuleByUID(c *contextmodel.ReqContext, ruleUID string
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule provenance", err)
}
result := toGettableExtendedRuleNode(rule, map[string]ngmodels.Provenance{rule.ResourceID(): provenance})
result := toGettableExtendedRuleNode(rule, map[string]ngmodels.Provenance{rule.ResourceID(): provenance}, srv.resolveUserIdToNameFn(ctx))
return response.JSON(http.StatusOK, result)
}
@@ -533,7 +535,7 @@ func changesToResponse(finalChanges *store.GroupDelta) response.Response {
return response.JSON(http.StatusAccepted, body)
}
func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, provenanceRecords map[string]ngmodels.Provenance) apimodels.GettableRuleGroupConfig {
func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, provenanceRecords map[string]ngmodels.Provenance, userIdToName userIDToUserInfoFn) apimodels.GettableRuleGroupConfig {
rules.SortByGroupIndex()
ruleNodes := make([]apimodels.GettableExtendedRuleNode, 0, len(rules))
var interval time.Duration
@@ -541,7 +543,7 @@ func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, prov
interval = time.Duration(rules[0].IntervalSeconds) * time.Second
}
for _, r := range rules {
ruleNodes = append(ruleNodes, toGettableExtendedRuleNode(*r, provenanceRecords))
ruleNodes = append(ruleNodes, toGettableExtendedRuleNode(*r, provenanceRecords, userIdToName))
}
return apimodels.GettableRuleGroupConfig{
Name: groupName,
@@ -550,7 +552,7 @@ func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, prov
}
}
func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[string]ngmodels.Provenance) apimodels.GettableExtendedRuleNode {
func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[string]ngmodels.Provenance, userIdToName userIDToUserInfoFn) apimodels.GettableExtendedRuleNode {
provenance := ngmodels.ProvenanceNone
if prov, exists := provenanceRecords[r.ResourceID()]; exists {
provenance = prov
@@ -564,6 +566,7 @@ func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[stri
Condition: r.Condition,
Data: ApiAlertQueriesFromAlertQueries(r.Data),
Updated: r.Updated,
UpdatedBy: userIdToName(r.UpdatedBy),
IntervalSeconds: r.IntervalSeconds,
Version: r.Version,
UID: r.UID,
@@ -724,3 +727,28 @@ func (srv RulerSrv) searchAuthorizedAlertRules(ctx context.Context, q authorized
}
return byGroupKey, totalGroups, nil
}
type userIDToUserInfoFn func(id *ngmodels.UserUID) *apimodels.UserInfo
// getIdentityName returns name of either user or service account
func (srv RulerSrv) resolveUserIdToNameFn(ctx context.Context) userIDToUserInfoFn {
return func(id *ngmodels.UserUID) *apimodels.UserInfo {
if id == nil {
return nil
}
u, err := srv.userService.GetByUID(ctx, &user.GetUserByUIDQuery{
UID: string(*id),
})
var result string
if err != nil {
srv.log.FromContext(ctx).Warn("Failed to get user by uid. Defaulting to an empty name", "uid", id, "error", err)
}
if u != nil {
result = u.NameOrFallback()
}
return &apimodels.UserInfo{
UID: string(*id),
Name: result,
}
}
}
@@ -32,7 +32,9 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/cmputil"
"github.com/grafana/grafana/pkg/web"
)
@@ -357,6 +359,72 @@ func TestRouteGetRuleByUID(t *testing.T) {
require.Equal(t, expectedRule.Title, result.GrafanaManagedAlert.Title)
require.True(t, result.GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedQueryAndExpressionsSection)
require.True(t, result.GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedNotificationsSection)
t.Run("should resolve Updated_by with user service", func(t *testing.T) {
testcases := []struct {
desc string
UpdatedBy *models.UserUID
User *user.User
UserServiceError error
Expected *apimodels.UserInfo
}{
{
desc: "nil if UpdatedBy is nil",
UpdatedBy: nil,
User: nil,
UserServiceError: nil,
Expected: nil,
},
{
desc: "just UID if user is not found",
UpdatedBy: util.Pointer(models.UserUID("test-uid")),
User: nil,
UserServiceError: nil,
Expected: &apimodels.UserInfo{
UID: "test-uid",
},
},
{
desc: "just UID if error",
UpdatedBy: util.Pointer(models.UserUID("test-uid")),
UserServiceError: errors.New("error"),
Expected: &apimodels.UserInfo{
UID: "test-uid",
},
},
{
desc: "login if it's known user",
UpdatedBy: util.Pointer(models.UserUID("test-uid")),
User: &user.User{
Login: "Test",
},
UserServiceError: nil,
Expected: &apimodels.UserInfo{
UID: "test-uid",
Name: "Test",
},
},
}
for _, tc := range testcases {
t.Run(tc.desc, func(t *testing.T) {
expectedRule.UpdatedBy = tc.UpdatedBy
svc := createService(ruleStore)
usvc := usertest.NewUserServiceFake()
usvc.ExpectedUser = tc.User
usvc.ExpectedError = tc.UserServiceError
svc.userService = usvc
response := svc.RouteGetRuleByUID(req, expectedRule.UID)
require.Equal(t, http.StatusOK, response.Status())
result := &apimodels.GettableExtendedRuleNode{}
require.NoError(t, json.Unmarshal(response.Body(), result))
require.NotNil(t, result)
require.Equal(t, tc.Expected, result.GrafanaManagedAlert.UpdatedBy)
})
}
})
})
t.Run("error when fetching rule with non-existent UID", func(t *testing.T) {
@@ -659,6 +727,7 @@ func createService(store *fakes.RuleStore) *RulerSrv {
amConfigStore: &fakeAMRefresher{},
amRefresher: &fakeAMRefresher{},
featureManager: featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRules),
userService: usertest.NewUserServiceFake(),
}
}
@@ -364,7 +364,7 @@ const (
type PostableExtendedRuleNode struct {
// note: this works with yaml v3 but not v2 (the inline tag isn't accepted on pointers in v2)
*ApiRuleNode `yaml:",inline"`
//GrafanaManagedAlert yaml.Node `yaml:"grafana_alert,omitempty"`
// GrafanaManagedAlert yaml.Node `yaml:"grafana_alert,omitempty"`
GrafanaManagedAlert *PostableGrafanaRule `yaml:"grafana_alert,omitempty" json:"grafana_alert,omitempty"`
}
@@ -401,7 +401,7 @@ func (n *PostableExtendedRuleNode) validate() error {
type GettableExtendedRuleNode struct {
// note: this works with yaml v3 but not v2 (the inline tag isn't accepted on pointers in v2)
*ApiRuleNode `yaml:",inline"`
//GrafanaManagedAlert yaml.Node `yaml:"grafana_alert,omitempty"`
// GrafanaManagedAlert yaml.Node `yaml:"grafana_alert,omitempty"`
GrafanaManagedAlert *GettableGrafanaRule `yaml:"grafana_alert,omitempty" json:"grafana_alert,omitempty"`
}
@@ -541,6 +541,7 @@ type GettableGrafanaRule struct {
Condition string `json:"condition" yaml:"condition"`
Data []AlertQuery `json:"data" yaml:"data"`
Updated time.Time `json:"updated" yaml:"updated"`
UpdatedBy *UserInfo `json:"updated_by" yaml:"updated_by"`
IntervalSeconds int64 `json:"intervalSeconds" yaml:"intervalSeconds"`
Version int64 `json:"version" yaml:"version"`
UID string `json:"uid" yaml:"uid"`
@@ -555,6 +556,12 @@ type GettableGrafanaRule struct {
Metadata *AlertRuleMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}
// UserInfo represents user-related information, including a unique identifier and a name.
type UserInfo struct {
UID string `json:"uid"`
Name string `json:"name"`
}
// AlertQuery represents a single query associated with an alert definition.
type AlertQuery struct {
// RefID is the unique identifier of the query, set by the frontend call.