Alerting: Add admission hooks for rules app (#113429)
This adds validating admission hooks to enforce the requirements on AlertRules and RecordingRules that are currently enforced through the provisioning service and storage mechanisms in preparation of a consistent validation in both legacy storage and unified storage. It also adds a mutating admission hook to the app to ensure that folder annotations and folder labels are kept in sync so we can perform label-selector lists.
This commit is contained in:
@@ -128,6 +128,10 @@ func convertToK8sResource(
|
||||
return nil, fmt.Errorf("failed to get metadata: %w", err)
|
||||
}
|
||||
meta.SetFolder(rule.NamespaceUID)
|
||||
// Keep metadata label in sync with folder annotation for downstream consumers
|
||||
if rule.NamespaceUID != "" {
|
||||
k8sRule.Labels[model.FolderLabelKey] = rule.NamespaceUID
|
||||
}
|
||||
if rule.UpdatedBy != nil {
|
||||
meta.SetUpdatedBy(string(*rule.UpdatedBy))
|
||||
k8sRule.SetUpdatedBy(string(*rule.UpdatedBy))
|
||||
|
||||
@@ -76,6 +76,10 @@ func convertToK8sResource(
|
||||
return nil, fmt.Errorf("failed to get metadata: %w", err)
|
||||
}
|
||||
meta.SetFolder(rule.NamespaceUID)
|
||||
// Keep metadata label in sync with folder annotation for downstream consumers
|
||||
if rule.NamespaceUID != "" {
|
||||
k8sRule.Labels[model.FolderLabelKey] = rule.NamespaceUID
|
||||
}
|
||||
if rule.UpdatedBy != nil {
|
||||
meta.SetUpdatedBy(string(*rule.UpdatedBy))
|
||||
k8sRule.SetUpdatedBy(string(*rule.UpdatedBy))
|
||||
|
||||
@@ -104,7 +104,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptio
|
||||
return obj, err
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) {
|
||||
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -114,6 +114,11 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, _ rest.V
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if createValidation != nil {
|
||||
if err := createValidation(ctx, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
p, ok := obj.(*model.RecordingRule)
|
||||
if !ok {
|
||||
|
||||
@@ -14,13 +14,17 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/rules/pkg/apis"
|
||||
rulesApp "github.com/grafana/grafana/apps/alerting/rules/pkg/app"
|
||||
rulesAppConfig "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules/alertrule"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules/recordingrule"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
reqns "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@@ -50,11 +54,66 @@ func RegisterAppInstaller(
|
||||
ng: ng,
|
||||
}
|
||||
|
||||
provider := simple.NewAppProvider(apis.LocalManifest(), nil, rulesApp.New)
|
||||
appSpecificConfig := rulesAppConfig.RuntimeConfig{
|
||||
// Validate folder existence using the folder service
|
||||
FolderValidator: func(ctx context.Context, folderUID string) (bool, error) {
|
||||
if folderUID == "" {
|
||||
return false, nil
|
||||
}
|
||||
orgID, err := reqns.OrgIDForList(ctx)
|
||||
user, _ := identity.GetRequester(ctx)
|
||||
if (err != nil || orgID < 1) && user != nil {
|
||||
orgID = user.GetOrgID()
|
||||
}
|
||||
if user == nil || orgID < 1 {
|
||||
// If we can't resolve identity/org in this context, don't block creation based on existence
|
||||
return true, nil
|
||||
}
|
||||
// Use the RuleStore to check namespace (folder) visibility
|
||||
_, err = ng.Api.RuleStore.GetNamespaceByUID(ctx, folderUID, orgID, user)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
BaseEvaluationInterval: ng.Cfg.UnifiedAlerting.BaseInterval,
|
||||
ReservedLabelKeys: ngmodels.LabelsUserCannotSpecify,
|
||||
// Validate that the configured notification receiver exists in the Alertmanager config
|
||||
NotificationSettingsValidator: func(ctx context.Context, receiver string) (bool, error) {
|
||||
if receiver == "" {
|
||||
return false, nil
|
||||
}
|
||||
orgID, err := reqns.OrgIDForList(ctx)
|
||||
if err != nil || orgID < 1 {
|
||||
if user, _ := identity.GetRequester(ctx); user != nil {
|
||||
orgID = user.GetOrgID()
|
||||
}
|
||||
}
|
||||
if orgID < 1 {
|
||||
// Without org context, skip validation rather than block
|
||||
return true, nil
|
||||
}
|
||||
provider := notifier.NewCachedNotificationSettingsValidationService(ng.Api.AlertingStore)
|
||||
vd, err := provider.Validator(ctx, orgID)
|
||||
if err != nil {
|
||||
log.New("alerting.rules.app").Error("failed to create notification settings validator", "error", err)
|
||||
// If we cannot build a validator, don't block admission
|
||||
return true, nil
|
||||
}
|
||||
// Only validate receiver presence; construct minimal settings
|
||||
if err := vd.Validate(ngmodels.NotificationSettings{Receiver: receiver}); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
|
||||
provider := simple.NewAppProvider(apis.LocalManifest(), appSpecificConfig, rulesApp.New)
|
||||
|
||||
appConfig := app.Config{
|
||||
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
|
||||
ManifestData: *apis.LocalManifest().ManifestData,
|
||||
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
|
||||
ManifestData: *apis.LocalManifest().ManifestData,
|
||||
SpecificConfig: appSpecificConfig,
|
||||
}
|
||||
|
||||
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{})
|
||||
@@ -81,7 +140,7 @@ func (a *AlertingRulesAppInstaller) GetAuthorizer() authorizer.Authorizer {
|
||||
}
|
||||
|
||||
func (a *AlertingRulesAppInstaller) GetLegacyStorage(gvr schema.GroupVersionResource) grafanarest.Storage {
|
||||
namespacer := request.GetNamespaceMapper(a.cfg)
|
||||
namespacer := reqns.GetNamespaceMapper(a.cfg)
|
||||
switch gvr {
|
||||
case recordingrule.ResourceInfo.GroupVersionResource():
|
||||
return recordingrule.NewStorage(*a.ng.Api.AlertRules, namespacer)
|
||||
|
||||
@@ -461,7 +461,7 @@ func TestIntegrationCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.ErrorContains(t, err, "invalid alert rule")
|
||||
require.ErrorContains(t, err, "trigger interval must be a multiple of base evaluation interval")
|
||||
require.Nil(t, created)
|
||||
})
|
||||
}
|
||||
@@ -564,3 +564,148 @@ func TestIntegrationBasicAPI(t *testing.T) {
|
||||
t.Logf("Got error: %s", err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationFolderLabelSyncAndValidation(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
client := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Prepare two folders for label sync update scenario
|
||||
common.CreateTestFolder(t, helper, "test-folder-a")
|
||||
common.CreateTestFolder(t, helper, "test-folder-b")
|
||||
|
||||
baseGen := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder-a"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
)
|
||||
|
||||
t.Run("should keep folder label in sync with folder annotation on create and update", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
v0alpha1.FolderAnnotationKey: "test-folder-a",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Expressions: v0alpha1.AlertRuleExpressionMap{
|
||||
"A": {
|
||||
QueryType: util.Pointer(rule.Data[0].QueryType),
|
||||
DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
created, err := client.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = client.Delete(ctx, created.Name, v1.DeleteOptions{}) }()
|
||||
|
||||
// On create, metadata.labels[v0alpha1.FolderLabelKey] should mirror annotation
|
||||
require.Equal(t, "test-folder-a", created.Labels[v0alpha1.FolderLabelKey])
|
||||
|
||||
// Update annotation to point to a different folder and ensure label follows
|
||||
updated := created.Copy().(*v0alpha1.AlertRule)
|
||||
if updated.Annotations == nil {
|
||||
updated.Annotations = map[string]string{}
|
||||
}
|
||||
updated.Annotations[v0alpha1.FolderAnnotationKey] = "test-folder-b"
|
||||
|
||||
after, err := client.Update(ctx, updated, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-folder-b", after.Annotations[v0alpha1.FolderAnnotationKey])
|
||||
require.Equal(t, "test-folder-b", after.Labels[v0alpha1.FolderLabelKey])
|
||||
})
|
||||
|
||||
t.Run("should fail to create rule without folder annotation", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{}, // missing grafana.app/folder
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Expressions: v0alpha1.AlertRuleExpressionMap{
|
||||
"A": {
|
||||
QueryType: util.Pointer(rule.Data[0].QueryType),
|
||||
DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration("10s"),
|
||||
},
|
||||
NoDataState: "NoData",
|
||||
ExecErrState: "Error",
|
||||
},
|
||||
}
|
||||
|
||||
created, err := client.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, created)
|
||||
})
|
||||
|
||||
t.Run("should fail to create rule with group labels preset", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
v0alpha1.FolderAnnotationKey: "test-folder-a",
|
||||
},
|
||||
Labels: map[string]string{
|
||||
v0alpha1.GroupLabelKey: "some-group",
|
||||
v0alpha1.GroupIndexLabelKey: "0",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Expressions: v0alpha1.AlertRuleExpressionMap{
|
||||
"A": {
|
||||
QueryType: util.Pointer(rule.Data[0].QueryType),
|
||||
DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{Interval: v0alpha1.AlertRulePromDuration("10s")},
|
||||
NoDataState: "NoData",
|
||||
ExecErrState: "Error",
|
||||
},
|
||||
}
|
||||
|
||||
created, err := client.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, created)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ func TestIntegrationCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.ErrorContains(t, err, "invalid alert rule")
|
||||
require.ErrorContains(t, err, "trigger interval must be a multiple of base evaluation interval")
|
||||
require.Nil(t, created)
|
||||
})
|
||||
}
|
||||
@@ -557,3 +557,139 @@ func TestIntegrationBasicAPI(t *testing.T) {
|
||||
t.Logf("Got error: %s", err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationFolderLabelSyncAndValidation(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
client := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Prepare two folders for label sync update scenario
|
||||
common.CreateTestFolder(t, helper, "test-folder-a")
|
||||
common.CreateTestFolder(t, helper, "test-folder-b")
|
||||
|
||||
baseGen := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder-a"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithAllRecordingRules(),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
)
|
||||
|
||||
t.Run("should keep folder label in sync with folder annotation on create and update", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
v0alpha1.FolderAnnotationKey: "test-folder-a",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Expressions: v0alpha1.RecordingRuleExpressionMap{
|
||||
"A": {
|
||||
QueryType: util.Pointer(rule.Data[0].QueryType),
|
||||
DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := client.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = client.Delete(ctx, created.Name, v1.DeleteOptions{}) }()
|
||||
|
||||
// On create, metadata.labels[v0alpha1.FolderLabelKey] should mirror annotation
|
||||
require.Equal(t, "test-folder-a", created.Labels[v0alpha1.FolderLabelKey])
|
||||
|
||||
updated := created.Copy().(*v0alpha1.RecordingRule)
|
||||
if updated.Annotations == nil {
|
||||
updated.Annotations = map[string]string{}
|
||||
}
|
||||
updated.Annotations[v0alpha1.FolderAnnotationKey] = "test-folder-b"
|
||||
|
||||
after, err := client.Update(ctx, updated, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-folder-b", after.Annotations[v0alpha1.FolderAnnotationKey])
|
||||
require.Equal(t, "test-folder-b", after.Labels[v0alpha1.FolderLabelKey])
|
||||
})
|
||||
|
||||
t.Run("should fail to create recording rule without folder annotation", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Expressions: v0alpha1.RecordingRuleExpressionMap{
|
||||
"A": {
|
||||
QueryType: util.Pointer(rule.Data[0].QueryType),
|
||||
DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := client.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, created)
|
||||
})
|
||||
|
||||
t.Run("should fail to create rule with group labels preset", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
v0alpha1.FolderAnnotationKey: "test-folder-a",
|
||||
},
|
||||
Labels: map[string]string{
|
||||
v0alpha1.GroupLabelKey: "some-group",
|
||||
v0alpha1.GroupIndexLabelKey: "0",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Expressions: v0alpha1.RecordingRuleExpressionMap{
|
||||
"A": {
|
||||
QueryType: util.Pointer(rule.Data[0].QueryType),
|
||||
DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := client.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, created)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user