EventManager: filter action execution based on event status
Some checks failed
Docker / Build (distroless, false, ubuntu-latest) (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Docker / Build (alpine, false, ubuntu-latest) (push) Has been cancelled
Docker / Build (alpine, true, ubuntu-latest) (push) Has been cancelled
Docker / Build (debian, false, ubuntu-latest) (push) Has been cancelled
Docker / Build (debian, true, ubuntu-latest) (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Has been cancelled
CI / Test and deploy (1.22, macos-latest, true) (push) Has been cancelled
CI / Test and deploy (1.22, ubuntu-latest, true) (push) Has been cancelled
CI / Test and deploy (1.22, windows-latest, false) (push) Has been cancelled
CI / Test build flags (push) Has been cancelled
CI / Test with PgSQL/MySQL/Cockroach (push) Has been cancelled
CI / Build Linux packages (aarch64, ubuntu18.04, go1.22.7, arm64) (push) Has been cancelled
CI / Build Linux packages (amd64, ubuntu:18.04, go1.22.7, amd64) (push) Has been cancelled
CI / Build Linux packages (armv7, ubuntu18.04, go1.22.7, arm7) (push) Has been cancelled
CI / Build Linux packages (ppc64le, ubuntu18.04, go1.22.7, ppc64le) (push) Has been cancelled
Docker / Build (debian-plugins, true, ubuntu-latest) (push) Has been cancelled

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-09-23 19:55:03 +02:00
parent 433d45ed87
commit eeef23139d
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
11 changed files with 183 additions and 8 deletions

4
go.mod
View file

@ -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

8
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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: "notification@example.com",
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{"test6@example.com"},
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, "test6@example.com"))
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")

View file

@ -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

View file

@ -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{
{

View file

@ -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,

View file

@ -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")
}

View file

@ -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",

View file

@ -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",

View file

@ -201,6 +201,18 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</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">