浏览代码

event rules: allow filtering based on group names

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 年之前
父节点
当前提交
f264b005ff

+ 3 - 2
docs/eventmanager.md

@@ -20,7 +20,7 @@ The following actions are supported:
 
 The following placeholders are supported:
 
-- `{{Name}}`. Username, folder name or admin username for provider actions.
+- `{{Name}}`. Username, folder name or admin username for provider events.
 - `{{Event}}`. Event name, for example `upload`, `download` for filesystem events or `add`, `update` for provider events.
 - `{{Status}}`. Status for `upload`, `download` and `ssh_cmd` events. 1 means no error, 2 means a generic error occurred, 3 means quota exceeded error.
 - `{{StatusString}}`. Status as string. Possible values "OK", "KO".
@@ -64,4 +64,5 @@ Some actions are not supported for some triggers, rules containing incompatible
 - `Provider events`, user quota reset, transfer quota reset, data retention check and filesystem actions can be executed only if  a user is updated. They will be executed for the affected user. Folder quota reset can be executed only for folders. Filesystem actions are not executed for `delete` user events because the actions is executed after the user deletion.
 - `IP Blocked`, user quota reset, folder quota reset, transfer quota reset, data retention check and filesystem actions cannot be executed, we only have an IP.
 - `Certificate`, user quota reset, folder quota reset, transfer quota reset, data retention check and filesystem actions cannot be executed.
-- `Email with attachments` are supported for filesystem events and provider events if a user is updated. We need a user to get the files to attach.
+- `Email with attachments` are supported for filesystem events and provider events if a user is added/updated. We need a user to get the files to attach.
+- `HTTP multipart requests with files as attachments` are supported for filesystem events and provider events if a user is added/updated. We need a user to get the files to attach.

+ 1 - 0
internal/common/actions.go

@@ -124,6 +124,7 @@ func ExecuteActionNotification(conn *BaseConnection, operation, filePath, virtua
 	if hasRules {
 		params := EventParams{
 			Name:              notification.Username,
+			Groups:            conn.User.Groups,
 			Event:             notification.Action,
 			Status:            notification.Status,
 			VirtualPath:       notification.VirtualPath,

+ 97 - 28
internal/common/eventmanager.go

@@ -35,6 +35,7 @@ import (
 
 	"github.com/robfig/cron/v3"
 	"github.com/rs/xid"
+	"github.com/sftpgo/sdk"
 	mail "github.com/xhit/go-simple-mail/v2"
 
 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
@@ -267,6 +268,9 @@ func (r *eventRulesContainer) checkFsEventMatch(conditions dataprovider.EventCon
 	if !checkEventConditionPatterns(params.Name, conditions.Options.Names) {
 		return false
 	}
+	if !checkEventGroupConditionPatters(params.Groups, conditions.Options.GroupNames) {
+		return false
+	}
 	if !checkEventConditionPatterns(params.VirtualPath, conditions.Options.FsPaths) {
 		if !checkEventConditionPatterns(params.ObjectName, conditions.Options.FsPaths) {
 			return false
@@ -411,6 +415,7 @@ func (r *eventRulesContainer) handleCertificateEvent(params EventParams) {
 // EventParams defines the supported event parameters
 type EventParams struct {
 	Name                  string
+	Groups                []sdk.GroupMapping
 	Event                 string
 	Status                int
 	VirtualPath           string
@@ -643,6 +648,21 @@ func checkEventConditionPatterns(name string, patterns []dataprovider.ConditionP
 	return false
 }
 
+func checkEventGroupConditionPatters(groups []sdk.GroupMapping, patterns []dataprovider.ConditionPattern) bool {
+	if len(patterns) == 0 {
+		return true
+	}
+	for _, group := range groups {
+		for _, p := range patterns {
+			if checkEventConditionPattern(p, group.Name) {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
 func getHTTPRuleActionEndpoint(c dataprovider.EventActionHTTPConfig, replacer *strings.Replacer) (string, error) {
 	if len(c.QueryParameters) > 0 {
 		u, err := url.Parse(c.Endpoint)
@@ -955,10 +975,17 @@ func executeDeleteFsRuleAction(deletes []string, replacer *strings.Replacer,
 	executed := 0
 	for _, user := range users {
 		// if sender is set, the conditions have already been evaluated
-		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
-			eventManagerLog(logger.LevelDebug, "skipping fs delete for user %s, name conditions don't match",
-				user.Username)
-			continue
+		if params.sender == "" {
+			if !checkEventConditionPatterns(user.Username, conditions.Names) {
+				eventManagerLog(logger.LevelDebug, "skipping fs delete for user %s, name conditions don't match",
+					user.Username)
+				continue
+			}
+			if !checkEventGroupConditionPatters(user.Groups, conditions.GroupNames) {
+				eventManagerLog(logger.LevelDebug, "skipping fs delete for user %s, group name conditions don't match",
+					user.Username)
+				continue
+			}
 		}
 		executed++
 		if err = executeDeleteFsActionForUser(deletes, replacer, user); err != nil {
@@ -1013,10 +1040,17 @@ func executeMkdirFsRuleAction(dirs []string, replacer *strings.Replacer,
 	executed := 0
 	for _, user := range users {
 		// if sender is set, the conditions have already been evaluated
-		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
-			eventManagerLog(logger.LevelDebug, "skipping fs mkdir for user %s, name conditions don't match",
-				user.Username)
-			continue
+		if params.sender == "" {
+			if !checkEventConditionPatterns(user.Username, conditions.Names) {
+				eventManagerLog(logger.LevelDebug, "skipping fs mkdir for user %s, name conditions don't match",
+					user.Username)
+				continue
+			}
+			if !checkEventGroupConditionPatters(user.Groups, conditions.GroupNames) {
+				eventManagerLog(logger.LevelDebug, "skipping fs mkdir for user %s, group name conditions don't match",
+					user.Username)
+				continue
+			}
 		}
 		executed++
 		if err = executeMkDirsFsActionForUser(dirs, replacer, user); err != nil {
@@ -1094,10 +1128,17 @@ func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *string
 	executed := 0
 	for _, user := range users {
 		// if sender is set, the conditions have already been evaluated
-		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
-			eventManagerLog(logger.LevelDebug, "skipping fs rename for user %s, name conditions don't match",
-				user.Username)
-			continue
+		if params.sender == "" {
+			if !checkEventConditionPatterns(user.Username, conditions.Names) {
+				eventManagerLog(logger.LevelDebug, "skipping fs rename for user %s, name conditions don't match",
+					user.Username)
+				continue
+			}
+			if !checkEventGroupConditionPatters(user.Groups, conditions.GroupNames) {
+				eventManagerLog(logger.LevelDebug, "skipping fs rename for user %s, group name conditions don't match",
+					user.Username)
+				continue
+			}
 		}
 		executed++
 		if err = executeRenameFsActionForUser(renames, replacer, user); err != nil {
@@ -1127,10 +1168,17 @@ func executeExistFsRuleAction(exist []string, replacer *strings.Replacer, condit
 	executed := 0
 	for _, user := range users {
 		// if sender is set, the conditions have already been evaluated
-		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
-			eventManagerLog(logger.LevelDebug, "skipping fs exist for user %s, name conditions don't match",
-				user.Username)
-			continue
+		if params.sender == "" {
+			if !checkEventConditionPatterns(user.Username, conditions.Names) {
+				eventManagerLog(logger.LevelDebug, "skipping fs exist for user %s, name conditions don't match",
+					user.Username)
+				continue
+			}
+			if !checkEventGroupConditionPatters(user.Groups, conditions.GroupNames) {
+				eventManagerLog(logger.LevelDebug, "skipping fs exist for user %s, group name conditions don't match",
+					user.Username)
+				continue
+			}
 		}
 		executed++
 		if err = executeExistFsActionForUser(exist, replacer, user); err != nil {
@@ -1203,10 +1251,17 @@ func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions,
 	executed := 0
 	for _, user := range users {
 		// if sender is set, the conditions have already been evaluated
-		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
-			eventManagerLog(logger.LevelDebug, "skipping quota reset for user %q, name conditions don't match",
-				user.Username)
-			continue
+		if params.sender == "" {
+			if !checkEventConditionPatterns(user.Username, conditions.Names) {
+				eventManagerLog(logger.LevelDebug, "skipping quota reset for user %q, name conditions don't match",
+					user.Username)
+				continue
+			}
+			if !checkEventGroupConditionPatters(user.Groups, conditions.GroupNames) {
+				eventManagerLog(logger.LevelDebug, "skipping quota reset for user %q, group name conditions don't match",
+					user.Username)
+				continue
+			}
 		}
 		executed++
 		if err = executeQuotaResetForUser(user); err != nil {
@@ -1284,10 +1339,17 @@ func executeTransferQuotaResetRuleAction(conditions dataprovider.ConditionOption
 	executed := 0
 	for _, user := range users {
 		// if sender is set, the conditions have already been evaluated
-		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
-			eventManagerLog(logger.LevelDebug, "skipping scheduled transfer quota reset for user %s, name conditions don't match",
-				user.Username)
-			continue
+		if params.sender == "" {
+			if !checkEventConditionPatterns(user.Username, conditions.Names) {
+				eventManagerLog(logger.LevelDebug, "skipping scheduled transfer quota reset for user %s, name conditions don't match",
+					user.Username)
+				continue
+			}
+			if !checkEventGroupConditionPatters(user.Groups, conditions.GroupNames) {
+				eventManagerLog(logger.LevelDebug, "skipping scheduled transfer quota reset for user %s, group name conditions don't match",
+					user.Username)
+				continue
+			}
 		}
 		executed++
 		err = dataprovider.UpdateUserTransferQuota(&user, 0, 0, true)
@@ -1339,10 +1401,17 @@ func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRete
 	executed := 0
 	for _, user := range users {
 		// if sender is set, the conditions have already been evaluated
-		if params.sender == "" && !checkEventConditionPatterns(user.Username, conditions.Names) {
-			eventManagerLog(logger.LevelDebug, "skipping scheduled retention check for user %s, name conditions don't match",
-				user.Username)
-			continue
+		if params.sender == "" {
+			if !checkEventConditionPatterns(user.Username, conditions.Names) {
+				eventManagerLog(logger.LevelDebug, "skipping scheduled retention check for user %s, name conditions don't match",
+					user.Username)
+				continue
+			}
+			if !checkEventGroupConditionPatters(user.Groups, conditions.GroupNames) {
+				eventManagerLog(logger.LevelDebug, "skipping scheduled retention check for user %s, group name conditions don't match",
+					user.Username)
+				continue
+			}
 		}
 		executed++
 		if err = executeDataRetentionCheckForUser(user, config.Folders); err != nil {

+ 100 - 0
internal/common/eventmanager_test.go

@@ -148,6 +148,50 @@ func TestEventRuleMatch(t *testing.T) {
 	}
 	res = eventManager.checkFsEventMatch(conditions, params)
 	assert.False(t, res)
+	// check fs events with group name filters
+	conditions = dataprovider.EventConditions{
+		FsEvents: []string{operationUpload, operationDownload},
+		Options: dataprovider.ConditionOptions{
+			GroupNames: []dataprovider.ConditionPattern{
+				{
+					Pattern: "group*",
+				},
+				{
+					Pattern: "testgroup*",
+				},
+			},
+		},
+	}
+	params = EventParams{
+		Name:  "user1",
+		Event: operationUpload,
+	}
+	res = eventManager.checkFsEventMatch(conditions, params)
+	assert.False(t, res)
+	params.Groups = []sdk.GroupMapping{
+		{
+			Name: "g1",
+			Type: sdk.GroupTypePrimary,
+		},
+		{
+			Name: "g2",
+			Type: sdk.GroupTypeSecondary,
+		},
+	}
+	res = eventManager.checkFsEventMatch(conditions, params)
+	assert.False(t, res)
+	params.Groups = []sdk.GroupMapping{
+		{
+			Name: "testgroup2",
+			Type: sdk.GroupTypePrimary,
+		},
+		{
+			Name: "g2",
+			Type: sdk.GroupTypeSecondary,
+		},
+	}
+	res = eventManager.checkFsEventMatch(conditions, params)
+	assert.True(t, res)
 }
 
 func TestEventManager(t *testing.T) {
@@ -902,6 +946,62 @@ func TestEventRuleActions(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestEventRuleActionsNoGroupMatching(t *testing.T) {
+	username := "test_user_action_group_matching"
+	user := dataprovider.User{
+		BaseUser: sdk.BaseUser{
+			Username: username,
+			Permissions: map[string][]string{
+				"/": {dataprovider.PermAny},
+			},
+			HomeDir: filepath.Join(os.TempDir(), username),
+		},
+	}
+	err := dataprovider.AddUser(&user, "", "")
+	assert.NoError(t, err)
+
+	conditions := dataprovider.ConditionOptions{
+		GroupNames: []dataprovider.ConditionPattern{
+			{
+				Pattern: "agroup",
+			},
+		},
+	}
+	err = executeDeleteFsRuleAction(nil, nil, conditions, &EventParams{})
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "no delete executed")
+	}
+	err = executeMkdirFsRuleAction(nil, nil, conditions, &EventParams{})
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "no mkdir executed")
+	}
+	err = executeRenameFsRuleAction(nil, nil, conditions, &EventParams{})
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "no rename executed")
+	}
+	err = executeExistFsRuleAction(nil, nil, conditions, &EventParams{})
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "no existence check executed")
+	}
+	err = executeUsersQuotaResetRuleAction(conditions, &EventParams{})
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "no user quota reset executed")
+	}
+	err = executeTransferQuotaResetRuleAction(conditions, &EventParams{})
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "no transfer quota reset executed")
+	}
+	err = executeDataRetentionCheckRuleAction(dataprovider.EventActionDataRetentionConfig{}, conditions, &EventParams{})
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "no retention check executed")
+	}
+
+	err = dataprovider.DeleteUser(username, "", "")
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestGetFileContent(t *testing.T) {
 	username := "test_user_get_file_content"
 	user := dataprovider.User{

+ 130 - 0
internal/common/protocol_test.go

@@ -3825,6 +3825,136 @@ func TestEventRuleFsActions(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestEventFsActionsGroupFilters(t *testing.T) {
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          2525,
+		From:          "notification@example.com",
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize(configDir)
+	require.NoError(t, err)
+
+	a1 := dataprovider.BaseEventAction{
+		Name: "a1",
+		Type: dataprovider.ActionTypeEmail,
+		Options: dataprovider.BaseEventActionOptions{
+			EmailConfig: dataprovider.EventActionEmailConfig{
+				Recipients: []string{"example@example.net"},
+				Subject:    `New "{{Event}}" from "{{Name}}" status {{StatusString}}`,
+				Body:       "Fs path {{FsPath}}, size: {{FileSize}}, protocol: {{Protocol}}, IP: {{IP}} {{ErrorString}}",
+			},
+		},
+	}
+	action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+	assert.NoError(t, err)
+
+	r1 := dataprovider.EventRule{
+		Name:    "rule1",
+		Trigger: dataprovider.EventTriggerFsEvent,
+		Conditions: dataprovider.EventConditions{
+			FsEvents: []string{"upload"},
+			Options: dataprovider.ConditionOptions{
+				GroupNames: []dataprovider.ConditionPattern{
+					{
+						Pattern: "group*",
+					},
+				},
+			},
+		},
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action1.Name,
+				},
+				Order: 1,
+				Options: dataprovider.EventActionOptions{
+					ExecuteSync: true,
+				},
+			},
+		},
+	}
+	rule1, _, err := httpdtest.AddEventRule(r1, http.StatusCreated)
+	assert.NoError(t, err)
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		// the user has no group, so the rule does not match
+		lastReceivedEmail.reset()
+		err = writeSFTPFile(testFileName, 32, client)
+		assert.NoError(t, err)
+		assert.Empty(t, lastReceivedEmail.get().From)
+	}
+	g1 := dataprovider.Group{
+		BaseGroup: sdk.BaseGroup{
+			Name: "agroup1",
+		},
+	}
+	group1, _, err := httpdtest.AddGroup(g1, http.StatusCreated)
+	assert.NoError(t, err)
+
+	g2 := dataprovider.Group{
+		BaseGroup: sdk.BaseGroup{
+			Name: "group2",
+		},
+	}
+	group2, _, err := httpdtest.AddGroup(g2, http.StatusCreated)
+	assert.NoError(t, err)
+	user.Groups = []sdk.GroupMapping{
+		{
+			Name: group1.Name,
+			Type: sdk.GroupTypePrimary,
+		},
+	}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	conn, client, err = getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		// the group does not match
+		lastReceivedEmail.reset()
+		err = writeSFTPFile(testFileName, 32, client)
+		assert.NoError(t, err)
+		assert.Empty(t, lastReceivedEmail.get().From)
+	}
+	user.Groups = append(user.Groups, sdk.GroupMapping{
+		Name: group2.Name,
+		Type: sdk.GroupTypeSecondary,
+	})
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	conn, client, err = getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+		// the group matches
+		lastReceivedEmail.reset()
+		err = writeSFTPFile(testFileName, 32, client)
+		assert.NoError(t, err)
+		assert.NotEmpty(t, lastReceivedEmail.get().From)
+	}
+	_, err = httpdtest.RemoveEventRule(rule1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveGroup(group1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveGroup(group2, http.StatusOK)
+	assert.NoError(t, err)
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize(configDir)
+	require.NoError(t, err)
+}
+
 func TestEventActionHTTPMultipart(t *testing.T) {
 	a1 := dataprovider.BaseEventAction{
 		Name: "action1",

+ 11 - 0
internal/dataprovider/eventrule.go

@@ -930,6 +930,8 @@ func (p *ConditionPattern) validate() error {
 type ConditionOptions struct {
 	// Usernames or folder names
 	Names []ConditionPattern `json:"names,omitempty"`
+	// Group names
+	GroupNames []ConditionPattern `json:"group_names,omitempty"`
 	// Virtual paths
 	FsPaths         []ConditionPattern `json:"fs_paths,omitempty"`
 	Protocols       []string           `json:"protocols,omitempty"`
@@ -948,6 +950,7 @@ func (f *ConditionOptions) getACopy() ConditionOptions {
 
 	return ConditionOptions{
 		Names:               cloneConditionPatterns(f.Names),
+		GroupNames:          cloneConditionPatterns(f.GroupNames),
 		FsPaths:             cloneConditionPatterns(f.FsPaths),
 		Protocols:           protocols,
 		ProviderObjects:     providerObjects,
@@ -963,6 +966,11 @@ func (f *ConditionOptions) validate() error {
 			return err
 		}
 	}
+	for _, name := range f.GroupNames {
+		if err := name.validate(); err != nil {
+			return err
+		}
+	}
 	for _, fsPath := range f.FsPaths {
 		if err := fsPath.validate(); err != nil {
 			return err
@@ -1061,6 +1069,7 @@ func (c *EventConditions) validate(trigger int) error {
 	case EventTriggerProviderEvent:
 		c.FsEvents = nil
 		c.Schedules = nil
+		c.Options.GroupNames = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
 		c.Options.MinFileSize = 0
@@ -1093,6 +1102,7 @@ func (c *EventConditions) validate(trigger int) error {
 		c.FsEvents = nil
 		c.ProviderEvents = nil
 		c.Options.Names = nil
+		c.Options.GroupNames = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
 		c.Options.MinFileSize = 0
@@ -1101,6 +1111,7 @@ func (c *EventConditions) validate(trigger int) error {
 	default:
 		c.FsEvents = nil
 		c.ProviderEvents = nil
+		c.Options.GroupNames = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
 		c.Options.MinFileSize = 0

+ 14 - 0
internal/httpd/httpd_test.go

@@ -19250,6 +19250,12 @@ func TestWebEventRule(t *testing.T) {
 						InverseMatch: true,
 					},
 				},
+				GroupNames: []dataprovider.ConditionPattern{
+					{
+						Pattern:      "g*",
+						InverseMatch: true,
+					},
+				},
 			},
 		},
 		Actions: []dataprovider.EventAction{
@@ -19279,6 +19285,8 @@ func TestWebEventRule(t *testing.T) {
 	form.Set("schedule_month0", rule.Conditions.Schedules[0].Month)
 	form.Set("name_pattern0", rule.Conditions.Options.Names[0].Pattern)
 	form.Set("type_name_pattern0", "inverse")
+	form.Set("group_name_pattern0", rule.Conditions.Options.GroupNames[0].Pattern)
+	form.Set("type_group_name_pattern0", "inverse")
 	req, err = http.NewRequest(http.MethodPost, webAdminEventRulePath, bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -19371,6 +19379,12 @@ func TestWebEventRule(t *testing.T) {
 					InverseMatch: true,
 				},
 			},
+			GroupNames: []dataprovider.ConditionPattern{
+				{
+					Pattern:      "g*",
+					InverseMatch: true,
+				},
+			},
 			FsPaths: []dataprovider.ConditionPattern{
 				{
 					Pattern: "/subdir/*.txt",

+ 16 - 3
internal/httpd/webadmin.go

@@ -109,6 +109,7 @@ const (
 	pageResetPwdTitle        = "SFTPGo Admin - Reset password"
 	pageSetupTitle           = "Create first admin user"
 	defaultQueryLimit        = 500
+	inversePatternType       = "inverse"
 )
 
 var (
@@ -2006,7 +2007,7 @@ func getEventActionFromPostFields(r *http.Request) (dataprovider.BaseEventAction
 
 func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventConditions, error) {
 	var schedules []dataprovider.Schedule
-	var names, fsPaths []dataprovider.ConditionPattern
+	var names, groupNames, fsPaths []dataprovider.ConditionPattern
 	for k := range r.Form {
 		if strings.HasPrefix(k, "schedule_hour") {
 			hour := r.Form.Get(k)
@@ -2030,7 +2031,18 @@ func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventCo
 				patternType := r.Form.Get(fmt.Sprintf("type_name_pattern%s", idx))
 				names = append(names, dataprovider.ConditionPattern{
 					Pattern:      pattern,
-					InverseMatch: patternType == "inverse",
+					InverseMatch: patternType == inversePatternType,
+				})
+			}
+		}
+		if strings.HasPrefix(k, "group_name_pattern") {
+			pattern := r.Form.Get(k)
+			if pattern != "" {
+				idx := strings.TrimPrefix(k, "group_name_pattern")
+				patternType := r.Form.Get(fmt.Sprintf("type_group_name_pattern%s", idx))
+				groupNames = append(groupNames, dataprovider.ConditionPattern{
+					Pattern:      pattern,
+					InverseMatch: patternType == inversePatternType,
 				})
 			}
 		}
@@ -2041,7 +2053,7 @@ func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventCo
 				patternType := r.Form.Get(fmt.Sprintf("type_fs_path_pattern%s", idx))
 				fsPaths = append(fsPaths, dataprovider.ConditionPattern{
 					Pattern:      pattern,
-					InverseMatch: patternType == "inverse",
+					InverseMatch: patternType == inversePatternType,
 				})
 			}
 		}
@@ -2060,6 +2072,7 @@ func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventCo
 		Schedules:      schedules,
 		Options: dataprovider.ConditionOptions{
 			Names:               names,
+			GroupNames:          groupNames,
 			FsPaths:             fsPaths,
 			Protocols:           r.Form["fs_protocols"],
 			ProviderObjects:     r.Form["provider_objects"],

+ 3 - 0
internal/httpdtest/httpdtest.go

@@ -1397,6 +1397,9 @@ func checkEventConditionOptions(expected, actual dataprovider.ConditionOptions)
 	if err := compareConditionPatternOptions(expected.Names, actual.Names); err != nil {
 		return errors.New("condition names mismatch")
 	}
+	if err := compareConditionPatternOptions(expected.GroupNames, actual.GroupNames); err != nil {
+		return errors.New("condition group names mismatch")
+	}
 	if err := compareConditionPatternOptions(expected.FsPaths, actual.FsPaths); err != nil {
 		return errors.New("condition fs_paths mismatch")
 	}

+ 4 - 0
openapi/openapi.yaml

@@ -6211,6 +6211,10 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/ConditionPattern'
+        group_names:
+          type: array
+          items:
+            $ref: '#/components/schemas/ConditionPattern'
         fs_paths:
           type: array
           items:

+ 85 - 1
templates/webadmin/eventrule.html

@@ -188,7 +188,7 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                     <b>Name filters</b>
                 </div>
                 <div class="card-body">
-                    <h6 class="card-title mb-4">Shell-like pattern filters for usernames, folder names. For example "user*"" will match names starting with "user"</h6>
+                    <h6 class="card-title mb-4">Shell-like pattern filters for usernames, folder names. For example "user*"" will match names starting with "user". For provider events, this filter is applied to the username of the admin executing the event.</h6>
                     <div class="form-group row">
                         <div class="col-md-12 form_field_names_outer">
                             {{range $idx, $val := .Rule.Conditions.Options.Names}}
@@ -237,6 +237,60 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 </div>
             </div>
 
+            <div class="card bg-light mb-3 trigger trigger-fs trigger-schedule">
+                <div class="card-header">
+                    <b>Group name filters</b>
+                </div>
+                <div class="card-body">
+                    <h6 class="card-title mb-4">Shell-like pattern filters for group names. For example "group*"" will match group names starting with "group".</h6>
+                    <div class="form-group row">
+                        <div class="col-md-12 form_field_group_names_outer">
+                            {{range $idx, $val := .Rule.Conditions.Options.GroupNames}}
+                            <div class="row form_field_group_names_outer_row">
+                                <div class="form-group col-md-8">
+                                    <input type="text" class="form-control" id="idGroupNamePattern{{$idx}}" name="group_name_pattern{{$idx}}" placeholder="" value="{{$val.Pattern}}" maxlength="255">
+                                </div>
+                                <div class="form-group col-md-3">
+                                    <select class="form-control selectpicker" id="idGroupNamePatternType{{$idx}}" name="type_group_name_pattern{{$idx}}">
+                                        <option value=""></option>
+                                        <option value="inverse" {{if $val.InverseMatch}}selected{{end}}>Inverse match</option>
+                                    </select>
+                                </div>
+                                <div class="form-group col-md-1">
+                                    <button class="btn btn-circle btn-danger remove_group_name_pattern_btn_frm_field">
+                                        <i class="fas fa-trash"></i>
+                                    </button>
+                                </div>
+                            </div>
+                            {{else}}
+                            <div class="row form_field_group_names_outer_row">
+                                <div class="form-group col-md-8">
+                                    <input type="text" class="form-control" id="idGroupNamePattern0" name="group_name_pattern0" placeholder="" value="" maxlength="255">
+                                </div>
+                                <div class="form-group col-md-3">
+                                    <select class="form-control selectpicker" id="idGroupNamePatternType0" name="type_group_name_pattern0">
+                                        <option value=""></option>
+                                        <option value="inverse">Inverse match</option>
+                                    </select>
+                                </div>
+                                <div class="form-group col-md-1">
+                                    <button class="btn btn-circle btn-danger remove_group_name_pattern_btn_frm_field">
+                                        <i class="fas fa-trash"></i>
+                                    </button>
+                                </div>
+                            </div>
+                            {{end}}
+                        </div>
+                    </div>
+
+                    <div class="row mx-1">
+                        <button type="button" class="btn btn-secondary add_new_group_name_pattern_field_btn">
+                            <i class="fas fa-plus"></i> Add new filter
+                        </button>
+                    </div>
+                </div>
+            </div>
+
             <div class="card bg-light mb-3 trigger trigger-fs">
                 <div class="card-header">
                     <b>Path filters</b>
@@ -458,6 +512,36 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
         $(this).closest(".form_field_names_outer_row").remove();
     });
 
+    $("body").on("click", ".add_new_group_name_pattern_field_btn", function () {
+        var index = $(".form_field_group_names_outer").find(".form_field_group_names_outer_row").length;
+        while (document.getElementById("idGroupNamePattern"+index) != null){
+            index++;
+        }
+        $(".form_field_group_names_outer").append(`
+            <div class="row form_field_group_names_outer_row">
+                <div class="form-group col-md-8">
+                    <input type="text" class="form-control" id="idGroupNamePattern${index}" name="group_name_pattern${index}" placeholder="" value="" maxlength="255">
+                </div>
+                <div class="form-group col-md-3">
+                    <select class="form-control" id="idGroupNamePatternType${index}" name="type_group_name_pattern${index}">
+                        <option value=""></option>
+                        <option value="inverse">Inverse match</option>
+                    </select>
+                </div>
+                <div class="form-group col-md-1">
+                    <button class="btn btn-circle btn-danger remove_group_name_pattern_btn_frm_field">
+                        <i class="fas fa-trash"></i>
+                    </button>
+                </div>
+            </div>
+        `);
+        $("#idGroupNamePatternType"+index).selectpicker();
+    });
+
+    $("body").on("click", ".remove_group_name_pattern_btn_frm_field", function () {
+        $(this).closest(".form_field_group_names_outer_row").remove();
+    });
+
     $("body").on("click", ".add_new_fs_path_pattern_field_btn", function () {
         var index = $(".form_field_fs_paths_outer").find("form_field_fs_paths_outer_row").length;
         while (document.getElementById("idFsPathPattern"+index) != null){