Browse Source

EventManager: filter action execution based on event status

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 year ago
parent
commit
eeef23139d

+ 2 - 2
go.mod

@@ -37,7 +37,7 @@ require (
 	github.com/hashicorp/go-retryablehttp v0.7.7
 	github.com/jackc/pgx/v5 v5.7.1
 	github.com/jlaffaye/ftp v0.2.0
-	github.com/klauspost/compress v1.17.9
+	github.com/klauspost/compress v1.17.10
 	github.com/lestrrat-go/jwx/v2 v2.1.1
 	github.com/lithammer/shortuuid/v4 v4.0.0
 	github.com/mattn/go-sqlite3 v1.14.23
@@ -81,7 +81,7 @@ require (
 	cloud.google.com/go v0.115.1 // indirect
 	cloud.google.com/go/auth v0.9.4 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
-	cloud.google.com/go/compute/metadata v0.5.1 // indirect
+	cloud.google.com/go/compute/metadata v0.5.2 // indirect
 	cloud.google.com/go/iam v1.2.1 // indirect
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect

+ 4 - 4
go.sum

@@ -5,8 +5,8 @@ cloud.google.com/go/auth v0.9.4 h1:DxF7imbEbiFu9+zdKC6cKBko1e8XeJnipNqIbWZ+kDI=
 cloud.google.com/go/auth v0.9.4/go.mod h1:SHia8n6//Ya940F1rLimhJCjjx7KE17t0ctFEci3HkA=
 cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
 cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
-cloud.google.com/go/compute/metadata v0.5.1 h1:NM6oZeZNlYjiwYje+sYFjEpP0Q0zCan1bmQW/KmIrGs=
-cloud.google.com/go/compute/metadata v0.5.1/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
+cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo=
+cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k=
 cloud.google.com/go/iam v1.2.1 h1:QFct02HRb7H12J/3utj0qf5tobFh9V4vR6h9eX5EBRU=
 cloud.google.com/go/iam v1.2.1/go.mod h1:3VUIJDPpwT6p/amXRC5GY8fCCh70lxPygguVtI0Z4/g=
 cloud.google.com/go/kms v1.19.0 h1:x0OVJDl6UH1BSX4THKlMfdcFWoE4ruh90ZHuilZekrU=
@@ -241,8 +241,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
 github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
-github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
-github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
+github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=

+ 5 - 0
internal/common/eventmanager.go

@@ -2597,6 +2597,11 @@ func executeUserCheckAction(c *dataprovider.EventActionIDPAccountCheck, params *
 func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams, //nolint:gocyclo
 	conditions dataprovider.ConditionOptions,
 ) error {
+	if len(conditions.EventStatuses) > 0 && !slices.Contains(conditions.EventStatuses, params.Status) {
+		eventManagerLog(logger.LevelDebug, "skipping action %s, event status %d does not match: %v",
+			action.Name, params.Status, conditions.EventStatuses)
+		return nil
+	}
 	var err error
 
 	switch action.Type {

+ 102 - 0
internal/common/protocol_test.go

@@ -3815,6 +3815,7 @@ func TestEventRule(t *testing.T) {
 		Conditions: dataprovider.EventConditions{
 			FsEvents: []string{"upload"},
 			Options: dataprovider.ConditionOptions{
+				EventStatuses: []int{1},
 				FsPaths: []dataprovider.ConditionPattern{
 					{
 						Pattern: "/subdir/*.dat",
@@ -4105,6 +4106,107 @@ func TestEventRule(t *testing.T) {
 	require.NoError(t, err)
 }
 
+func TestEventRuleStatues(t *testing.T) {
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          2525,
+		From:          "[email protected]",
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize(configDir, true)
+	require.NoError(t, err)
+
+	a1 := dataprovider.BaseEventAction{
+		Name: "a1",
+		Type: dataprovider.ActionTypeEmail,
+		Options: dataprovider.BaseEventActionOptions{
+			EmailConfig: dataprovider.EventActionEmailConfig{
+				Recipients: []string{"[email protected]"},
+				Subject:    `New "{{Event}}" error`,
+				Body:       "{{ErrorString}}",
+			},
+		},
+	}
+	action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+	assert.NoError(t, err)
+
+	r := dataprovider.EventRule{
+		Name:    "rule",
+		Status:  1,
+		Trigger: dataprovider.EventTriggerFsEvent,
+		Conditions: dataprovider.EventConditions{
+			FsEvents: []string{"upload"},
+			Options: dataprovider.ConditionOptions{
+				EventStatuses: []int{3},
+			},
+		},
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action1.Name,
+				},
+				Order: 1,
+			},
+		},
+	}
+	rule, resp, err := httpdtest.AddEventRule(r, http.StatusCreated)
+	assert.NoError(t, err, string(resp))
+
+	u := getTestUser()
+	u.UploadDataTransfer = 1
+	u.DownloadDataTransfer = 1
+	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+	conn, client, err := getSftpClient(user)
+	if assert.NoError(t, err) {
+		defer conn.Close()
+		defer client.Close()
+
+		testFileSize := int64(999999)
+		err = writeSFTPFile(testFileName, testFileSize, client)
+		assert.NoError(t, err)
+		f, err := client.Open(testFileName)
+		assert.NoError(t, err)
+		contents := make([]byte, testFileSize)
+		n, err := io.ReadFull(f, contents)
+		assert.NoError(t, err)
+		assert.Equal(t, int(testFileSize), n)
+		assert.Len(t, contents, int(testFileSize))
+		err = f.Close()
+		assert.NoError(t, err)
+
+		lastReceivedEmail.reset()
+		assert.Eventually(t, func() bool {
+			return lastReceivedEmail.get().From == ""
+		}, 600*time.Millisecond, 500*time.Millisecond)
+
+		err = writeSFTPFile(testFileName, testFileSize, client)
+		assert.Error(t, err)
+		lastReceivedEmail.reset()
+		assert.Eventually(t, func() bool {
+			return lastReceivedEmail.get().From != ""
+		}, 3000*time.Millisecond, 100*time.Millisecond)
+		email := lastReceivedEmail.get()
+		assert.Len(t, email.To, 1)
+		assert.True(t, slices.Contains(email.To, "[email protected]"))
+		assert.Contains(t, email.Data, `Subject: New "upload" error`)
+		assert.Contains(t, email.Data, common.ErrQuotaExceeded.Error())
+	}
+
+	_, err = httpdtest.RemoveEventRule(rule, 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)
+
+	smtpCfg = smtp.Config{}
+	err = smtpCfg.Initialize(configDir, true)
+	require.NoError(t, err)
+}
+
 func TestEventRuleProviderEvents(t *testing.T) {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")

+ 22 - 0
internal/dataprovider/eventrule.go

@@ -1336,6 +1336,7 @@ type ConditionOptions struct {
 	ProviderObjects []string           `json:"provider_objects,omitempty"`
 	MinFileSize     int64              `json:"min_size,omitempty"`
 	MaxFileSize     int64              `json:"max_size,omitempty"`
+	EventStatuses   []int              `json:"event_statuses,omitempty"`
 	// allow to execute scheduled tasks concurrently from multiple instances
 	ConcurrentExecution bool `json:"concurrent_execution,omitempty"`
 }
@@ -1345,6 +1346,8 @@ func (f *ConditionOptions) getACopy() ConditionOptions {
 	copy(protocols, f.Protocols)
 	providerObjects := make([]string, len(f.ProviderObjects))
 	copy(providerObjects, f.ProviderObjects)
+	statuses := make([]int, len(f.EventStatuses))
+	copy(statuses, f.EventStatuses)
 
 	return ConditionOptions{
 		Names:               cloneConditionPatterns(f.Names),
@@ -1355,10 +1358,20 @@ func (f *ConditionOptions) getACopy() ConditionOptions {
 		ProviderObjects:     providerObjects,
 		MinFileSize:         f.MinFileSize,
 		MaxFileSize:         f.MaxFileSize,
+		EventStatuses:       statuses,
 		ConcurrentExecution: f.ConcurrentExecution,
 	}
 }
 
+func (f *ConditionOptions) validateStatuses() error {
+	for _, status := range f.EventStatuses {
+		if status < 0 || status > 3 {
+			return util.NewValidationError(fmt.Sprintf("invalid event_status %d", status))
+		}
+	}
+	return nil
+}
+
 func (f *ConditionOptions) validate() error {
 	if err := validateConditionPatterns(f.Names); err != nil {
 		return err
@@ -1389,6 +1402,9 @@ func (f *ConditionOptions) validate() error {
 				util.ByteCountSI(f.MaxFileSize), util.ByteCountSI(f.MinFileSize)))
 		}
 	}
+	if err := f.validateStatuses(); err != nil {
+		return err
+	}
 	if config.IsShared == 0 {
 		f.ConcurrentExecution = false
 	}
@@ -1491,6 +1507,7 @@ func (c *EventConditions) validate(trigger int) error {
 		c.Options.GroupNames = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
+		c.Options.EventStatuses = nil
 		c.Options.MinFileSize = 0
 		c.Options.MaxFileSize = 0
 		c.IDPLoginEvent = 0
@@ -1510,6 +1527,7 @@ func (c *EventConditions) validate(trigger int) error {
 		c.ProviderEvents = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
+		c.Options.EventStatuses = nil
 		c.Options.MinFileSize = 0
 		c.Options.MaxFileSize = 0
 		c.Options.ProviderObjects = nil
@@ -1525,6 +1543,7 @@ func (c *EventConditions) validate(trigger int) error {
 		c.Options.RoleNames = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
+		c.Options.EventStatuses = nil
 		c.Options.MinFileSize = 0
 		c.Options.MaxFileSize = 0
 		c.Schedules = nil
@@ -1534,6 +1553,7 @@ func (c *EventConditions) validate(trigger int) error {
 		c.ProviderEvents = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
+		c.Options.EventStatuses = nil
 		c.Options.MinFileSize = 0
 		c.Options.MaxFileSize = 0
 		c.Options.ProviderObjects = nil
@@ -1547,6 +1567,7 @@ func (c *EventConditions) validate(trigger int) error {
 		c.Options.RoleNames = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
+		c.Options.EventStatuses = nil
 		c.Options.MinFileSize = 0
 		c.Options.MaxFileSize = 0
 		c.Schedules = nil
@@ -1560,6 +1581,7 @@ func (c *EventConditions) validate(trigger int) error {
 		c.Options.RoleNames = nil
 		c.Options.FsPaths = nil
 		c.Options.Protocols = nil
+		c.Options.EventStatuses = nil
 		c.Options.MinFileSize = 0
 		c.Options.MaxFileSize = 0
 		c.Schedules = nil

+ 17 - 1
internal/httpd/httpd_test.go

@@ -1914,7 +1914,8 @@ func TestBasicActionRulesHandling(t *testing.T) {
 		Conditions: dataprovider.EventConditions{
 			FsEvents: []string{"upload"},
 			Options: dataprovider.ConditionOptions{
-				MinFileSize: 1024 * 1024,
+				EventStatuses: []int{2, 3},
+				MinFileSize:   1024 * 1024,
 			},
 		},
 		Actions: []dataprovider.EventAction{
@@ -2708,6 +2709,21 @@ func TestEventRuleValidation(t *testing.T) {
 	_, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "sync execution is only supported for upload and pre-* events")
+
+	rule.Conditions.FsEvents = []string{"download"}
+	rule.Conditions.Options.EventStatuses = []int{3, 2, 8}
+	rule.Actions = []dataprovider.EventAction{
+		{
+			BaseEventAction: dataprovider.BaseEventAction{
+				Name: "action",
+			},
+			Order: 1,
+		},
+	}
+	_, resp, err = httpdtest.AddEventRule(rule, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "invalid event_status")
+
 	rule.Trigger = dataprovider.EventTriggerProviderEvent
 	rule.Actions = []dataprovider.EventAction{
 		{

+ 8 - 0
internal/httpd/webadmin.go

@@ -2555,6 +2555,13 @@ func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventCo
 	if err != nil {
 		return dataprovider.EventConditions{}, util.NewI18nError(fmt.Errorf("invalid max file size: %w", err), util.I18nErrorInvalidMaxSize)
 	}
+	var eventStatuses []int
+	for _, s := range r.Form["fs_statuses"] {
+		status, err := strconv.ParseInt(s, 10, 32)
+		if err == nil {
+			eventStatuses = append(eventStatuses, int(status))
+		}
+	}
 	conditions := dataprovider.EventConditions{
 		FsEvents:       r.Form["fs_events"],
 		ProviderEvents: r.Form["provider_events"],
@@ -2566,6 +2573,7 @@ func getEventRuleConditionsFromPostFields(r *http.Request) (dataprovider.EventCo
 			RoleNames:           roleNames,
 			FsPaths:             fsPaths,
 			Protocols:           r.Form["fs_protocols"],
+			EventStatuses:       eventStatuses,
 			ProviderObjects:     r.Form["provider_objects"],
 			MinFileSize:         minFileSize,
 			MaxFileSize:         maxFileSize,

+ 9 - 1
internal/httpdtest/httpdtest.go

@@ -1662,7 +1662,7 @@ func compareConditionPatternOptions(expected, actual []dataprovider.ConditionPat
 	return nil
 }
 
-func checkEventConditionOptions(expected, actual dataprovider.ConditionOptions) error {
+func checkEventConditionOptions(expected, actual dataprovider.ConditionOptions) error { //nolint:gocyclo
 	if err := compareConditionPatternOptions(expected.Names, actual.Names); err != nil {
 		return errors.New("condition names mismatch")
 	}
@@ -1683,6 +1683,14 @@ func checkEventConditionOptions(expected, actual dataprovider.ConditionOptions)
 			return errors.New("condition protocols content mismatch")
 		}
 	}
+	if len(expected.EventStatuses) != len(actual.EventStatuses) {
+		return errors.New("condition statuses mismatch")
+	}
+	for _, v := range expected.EventStatuses {
+		if !slices.Contains(actual.EventStatuses, v) {
+			return errors.New("condition statuses content mismatch")
+		}
+	}
 	if len(expected.ProviderObjects) != len(actual.ProviderObjects) {
 		return errors.New("condition provider objects mismatch")
 	}

+ 1 - 0
static/locales/en/translation.json

@@ -1090,6 +1090,7 @@
         "scheduler_help": "Hours: 0-23. Day of week: 0-6 (Sun-Sat). Day of month: 1-31. Month: 1-12. Asterisk (*) indicates a match for all the values of the field. e.g. every day of week, every day of month and so on",
         "concurrent_run": "Allow concurrent execution from multiple instances",
         "protocol_filters": "Protocol filters",
+        "status_filters": "Status filters",
         "object_filters": "Object filters",
         "name_filters": "Name filters",
         "name_filters_help": "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",

+ 1 - 0
static/locales/it/translation.json

@@ -1090,6 +1090,7 @@
         "scheduler_help": "Orari: 0-23. Giorno della settimana: 0-6 (dom-sab). Giorno del mese: 1-31. Mese: 1-12. L'asterisco (*) indica una corrispondenza per tutti i valori del campo. per esempio. ogni giorno della settimana, ogni giorno del mese e così via",
         "concurrent_run": "Consentire l'esecuzione simultanea da più istanze",
         "protocol_filters": "Filtro su protocolli",
+        "status_filters": "Filtro su stati",
         "object_filters": "Filtro su oggetti",
         "name_filters": "Filtro su nomi",
         "name_filters_help": "Filtri per nomi utente e nomi di cartelle. Ad esempio, \"user*\"\" corrisponderà per i nomi che iniziano con \"user\". Per gli eventi del provider, questo filtro viene applicato al nome utente dell'amministratore che esegue l'evento",

+ 12 - 0
templates/webadmin/eventrule.html

@@ -201,6 +201,18 @@ explicit grant from the SFTPGo Team ([email protected]).
                 </div>
             </div>
 
+            <div class="form-group row trigger trigger-fs mt-10">
+                <label for="idFsStatuses" data-i18n="rules.status_filters" class="col-md-3 col-form-label">Status filters</label>
+                <div class="col-md-9">
+                    <select id="idFsStatuses" name="fs_statuses" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple aria-describedby="idFsStatusesHelp">
+                        <option value="1" data-i18n="general.ok" {{- range $.Rule.Conditions.Options.EventStatuses }}{{- if eq . 1}}selected{{- end}}{{- end}}>OK</option>
+                        <option value="2" data-i18n="general.failed" {{- range $.Rule.Conditions.Options.EventStatuses }}{{- if eq . 2}}selected{{- end}}{{- end}}>Failed</option>
+                        <option value="3" data-i18n="events.quota_exceeded" {{- range $.Rule.Conditions.Options.EventStatuses }}{{- if eq . 3}}selected{{- end}}{{- end}}>Quota exceeded</option>
+                    </select>
+                    <div id="idFsStatusesHelp" data-i18n="rules.no_filter" class="form-text"></div>
+                </div>
+            </div>
+
             <div class="form-group row trigger trigger-provider mt-10">
                 <label for="idProviderObjects" data-i18n="rules.object_filters" class="col-md-3 col-form-label">Object filters</label>
                 <div class="col-md-9">