From 4d357a6a57531561e4f2b8bbdbded554eaf0620b Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Mon, 4 Mar 2024 19:48:10 +0100 Subject: [PATCH] EventManager: allow to check for inactive users Signed-off-by: Nicola Murino --- docs/eventmanager.md | 1 + go.mod | 12 +- go.sum | 23 ++-- internal/common/eventmanager.go | 61 ++++++++++ internal/common/eventmanager_test.go | 173 +++++++++++++++++++++++++++ internal/common/protocol_test.go | 103 ++++++++++++++++ internal/dataprovider/eventrule.go | 73 +++++++++-- internal/dataprovider/user.go | 16 +++ internal/httpd/httpd_test.go | 51 ++++++++ internal/httpd/webadmin.go | 11 ++ internal/httpdtest/httpdtest.go | 6 + internal/util/i18n.go | 3 + openapi/openapi.yaml | 14 ++- static/locales/en/translation.json | 7 ++ static/locales/it/translation.json | 7 ++ templates/webadmin/eventaction.html | 19 +++ templates/webadmin/eventactions.html | 2 + 17 files changed, 556 insertions(+), 26 deletions(-) diff --git a/docs/eventmanager.md b/docs/eventmanager.md index d32c69fc..5432cdcc 100644 --- a/docs/eventmanager.md +++ b/docs/eventmanager.md @@ -14,6 +14,7 @@ The following actions are supported: - `Data retention check`. You can define per-folder retention policies. - `Password expiration check`. You can send an email notification to users whose password is about to expire. - `User expiration check`. You can receive notifications with expired users. +- `User inactivity check`. Allow to disable or delete inactive users. - `Identity Provider account check`. You can create/update accounts for users/admins logging in using an Identity Provider. - `Filesystem`. For these actions, the required permissions are automatically granted. This is the same as executing the actions from an SFTP client and the same restrictions applies. Supported actions: - `Rename`. You can rename one or more files or directories. diff --git a/go.mod b/go.mod index 287a565a..31f1b5f3 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/hashicorp/go-hclog v1.6.2 github.com/hashicorp/go-plugin v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.5 - github.com/jackc/pgx/v5 v5.5.3 + github.com/jackc/pgx/v5 v5.5.4 github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 github.com/klauspost/compress v1.17.7 github.com/lestrrat-go/jwx/v2 v2.0.20 @@ -71,7 +71,7 @@ require ( golang.org/x/crypto v0.19.0 golang.org/x/net v0.21.0 golang.org/x/oauth2 v0.17.0 - golang.org/x/sys v0.17.0 + golang.org/x/sys v0.18.0 golang.org/x/term v0.17.0 golang.org/x/time v0.5.0 google.golang.org/api v0.167.0 @@ -167,15 +167,15 @@ require ( go.opentelemetry.io/otel/trace v1.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/mod v0.15.0 // indirect + golang.org/x/mod v0.16.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.18.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto v0.0.0-20240228224816-df926f6c8641 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240228224816-df926f6c8641 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240228224816-df926f6c8641 // indirect + google.golang.org/genproto v0.0.0-20240304161311-37d4d3c04a78 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240304161311-37d4d3c04a78 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240304161311-37d4d3c04a78 // indirect google.golang.org/grpc v1.62.0 // indirect google.golang.org/protobuf v1.32.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 5c140b2b..7d17c985 100644 --- a/go.sum +++ b/go.sum @@ -238,8 +238,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s= -github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= @@ -438,8 +438,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -487,8 +487,9 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -531,12 +532,12 @@ google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20240228224816-df926f6c8641 h1:GihpvzHjeZHw+/mzsWpdxwr1LaG6E3ff/gyeZlVHbyc= -google.golang.org/genproto v0.0.0-20240228224816-df926f6c8641/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= -google.golang.org/genproto/googleapis/api v0.0.0-20240228224816-df926f6c8641 h1:SO1wX9btGFrwj9EzH3ocqfwiPVOxfv4ggAJajzlHA5s= -google.golang.org/genproto/googleapis/api v0.0.0-20240228224816-df926f6c8641/go.mod h1:wLupoVsUfYPgOMwjzhYFbaVklw/INms+dqTp0tc1fv8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240228224816-df926f6c8641 h1:DKU1r6Tj5s1vlU/moGhuGz7E3xRfwjdAfDzbsaQJtEY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240228224816-df926f6c8641/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= +google.golang.org/genproto v0.0.0-20240304161311-37d4d3c04a78 h1:52X7fVGDxXCpRYt6LsKB08TzuA3jOyPHIW2h8QlvR5U= +google.golang.org/genproto v0.0.0-20240304161311-37d4d3c04a78/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI= +google.golang.org/genproto/googleapis/api v0.0.0-20240304161311-37d4d3c04a78 h1:SzXBGiWM1LNVYLCRP3e0/Gsze804l4jGoJ5lYysEO5I= +google.golang.org/genproto/googleapis/api v0.0.0-20240304161311-37d4d3c04a78/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240304161311-37d4d3c04a78 h1:Xs9lu+tLXxLIfuci70nG4cpwaRC+mRQPUL7LoIeDJC4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240304161311-37d4d3c04a78/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index 137b6ffa..900adcb6 100644 --- a/internal/common/eventmanager.go +++ b/internal/common/eventmanager.go @@ -2354,6 +2354,65 @@ func executeUserExpirationCheckRuleAction(conditions dataprovider.ConditionOptio return nil } +func executeInactivityCheckForUser(user *dataprovider.User, config dataprovider.EventActionUserInactivity, when time.Time) error { + if config.DeleteThreshold > 0 && (user.Status == 0 || config.DisableThreshold == 0) { + if inactivityDays := user.InactivityDays(when); inactivityDays > config.DeleteThreshold { + err := dataprovider.DeleteUser(user.Username, dataprovider.ActionExecutorSystem, "", "") + eventManagerLog(logger.LevelInfo, "deleting inactive user %q, days of inactivity: %d/%d, err: %v", + user.Username, inactivityDays, config.DeleteThreshold, err) + if err != nil { + return fmt.Errorf("unable to delete inactive user %q", user.Username) + } + return fmt.Errorf("inactive user %q deleted. Number of days of inactivity: %d", user.Username, inactivityDays) + } + } + if config.DisableThreshold > 0 && user.Status > 0 { + if inactivityDays := user.InactivityDays(when); inactivityDays > config.DisableThreshold { + user.Status = 0 + err := dataprovider.UpdateUser(user, dataprovider.ActionExecutorSystem, "", "") + eventManagerLog(logger.LevelInfo, "disabling inactive user %q, days of inactivity: %d/%d, err: %v", + user.Username, inactivityDays, config.DisableThreshold, err) + if err != nil { + return fmt.Errorf("unable to disable inactive user %q", user.Username) + } + return fmt.Errorf("inactive user %q disabled. Number of days of inactivity: %d", user.Username, inactivityDays) + } + } + + return nil +} + +func executeUserInactivityCheckRuleAction(config dataprovider.EventActionUserInactivity, + conditions dataprovider.ConditionOptions, + params *EventParams, + when time.Time, +) error { + users, err := params.getUsers() + if err != nil { + return fmt.Errorf("unable to get users: %w", err) + } + var failures []string + for _, user := range users { + // if sender is set, the conditions have already been evaluated + if params.sender == "" { + if !checkUserConditionOptions(&user, &conditions) { + eventManagerLog(logger.LevelDebug, "skipping inactivity check for user %q, condition options don't match", + user.Username) + continue + } + } + if err = executeInactivityCheckForUser(&user, config, when); err != nil { + params.AddError(err) + failures = append(failures, user.Username) + } + } + if len(failures) > 0 { + return fmt.Errorf("executed inactivity check actions for users: %s", strings.Join(failures, ", ")) + } + + return nil +} + func executePwdExpirationCheckForUser(user *dataprovider.User, config dataprovider.EventActionPasswordExpiration) error { if err := user.LoadAndApplyGroupSettings(); err != nil { eventManagerLog(logger.LevelError, "skipping password expiration check for user %q, cannot apply group settings: %v", @@ -2523,6 +2582,8 @@ func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams, err = executePwdExpirationCheckRuleAction(action.Options.PwdExpirationConfig, conditions, params) case dataprovider.ActionTypeUserExpirationCheck: err = executeUserExpirationCheckRuleAction(conditions, params) + case dataprovider.ActionTypeUserInactivityCheck: + err = executeUserInactivityCheckRuleAction(action.Options.UserInactivityConfig, conditions, params, time.Now()) default: err = fmt.Errorf("unsupported action type: %d", action.Type) } diff --git a/internal/common/eventmanager_test.go b/internal/common/eventmanager_test.go index c9a32b2e..47ddd08c 100644 --- a/internal/common/eventmanager_test.go +++ b/internal/common/eventmanager_test.go @@ -607,6 +607,9 @@ func TestEventManagerErrors(t *testing.T) { assert.Error(t, err) err = executeUserExpirationCheckRuleAction(dataprovider.ConditionOptions{}, &EventParams{}) assert.Error(t, err) + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{}, + dataprovider.ConditionOptions{}, &EventParams{}, time.Time{}) + assert.Error(t, err) err = executeDeleteFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{}) assert.Error(t, err) err = executeMkdirFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{}) @@ -2254,3 +2257,173 @@ func TestMetadataReplacement(t *testing.T) { require.NoError(t, err) assert.Equal(t, `{"key":"value"} {\"key\":\"value\"}`, string(data)) } + +func TestUserInactivityCheck(t *testing.T) { + username1 := "user1" + username2 := "user2" + user1 := dataprovider.User{ + BaseUser: sdk.BaseUser{ + Username: username1, + HomeDir: filepath.Join(os.TempDir(), username1), + Status: 1, + Permissions: map[string][]string{ + "/": {dataprovider.PermAny}, + }, + }, + } + user2 := dataprovider.User{ + BaseUser: sdk.BaseUser{ + Username: username2, + HomeDir: filepath.Join(os.TempDir(), username2), + Status: 1, + Permissions: map[string][]string{ + "/": {dataprovider.PermAny}, + }, + }, + } + days := user1.InactivityDays(time.Now().Add(10*24*time.Hour + 5*time.Second)) + assert.Equal(t, 0, days) + + user2.LastLogin = util.GetTimeAsMsSinceEpoch(time.Now()) + err := executeInactivityCheckForUser(&user2, dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + }, time.Now().Add(12*24*time.Hour)) + assert.Error(t, err) + user2.LastLogin = util.GetTimeAsMsSinceEpoch(time.Now()) + err = executeInactivityCheckForUser(&user2, dataprovider.EventActionUserInactivity{ + DeleteThreshold: 10, + }, time.Now().Add(12*24*time.Hour)) + assert.Error(t, err) + + err = dataprovider.AddUser(&user1, "", "", "") + assert.NoError(t, err) + err = dataprovider.AddUser(&user2, "", "", "") + assert.NoError(t, err) + user1, err = dataprovider.UserExists(username1, "") + assert.NoError(t, err) + assert.Equal(t, 1, user1.Status) + days = user1.InactivityDays(time.Now().Add(10*24*time.Hour + 5*time.Second)) + assert.Equal(t, 10, days) + days = user1.InactivityDays(time.Now().Add(-10*24*time.Hour + 5*time.Second)) + assert.Equal(t, -9, days) + + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: "not matching", + }, + }, + }, &EventParams{}, time.Now().Add(12*24*time.Hour)) + assert.NoError(t, err) + + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user1.Username, + }, + }, + }, &EventParams{}, time.Now()) + assert.NoError(t, err) // no action + + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user1.Username, + }, + }, + }, &EventParams{}, time.Now().Add(-12*24*time.Hour)) + assert.NoError(t, err) // no action + + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 20, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user1.Username, + }, + }, + }, &EventParams{}, time.Now().Add(30*24*time.Hour)) + // both thresholds exceeded, the user will be disabled + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "executed inactivity check actions for users") + } + user1, err = dataprovider.UserExists(username1, "") + assert.NoError(t, err) + assert.Equal(t, 0, user1.Status) + + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user1.Username, + }, + }, + }, &EventParams{}, time.Now().Add(30*24*time.Hour)) + assert.NoError(t, err) // already disabled, no action + + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 20, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user1.Username, + }, + }, + }, &EventParams{}, time.Now().Add(-30*24*time.Hour)) + assert.NoError(t, err) + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 20, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user1.Username, + }, + }, + }, &EventParams{}, time.Now()) + assert.NoError(t, err) + user1, err = dataprovider.UserExists(username1, "") + assert.NoError(t, err) + assert.Equal(t, 0, user1.Status) + + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 20, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user1.Username, + }, + }, + }, &EventParams{}, time.Now().Add(30*24*time.Hour)) // the user is disabled, will be now deleted + assert.Error(t, err) + _, err = dataprovider.UserExists(username1, "") + assert.ErrorIs(t, err, util.ErrNotFound) + + err = executeUserInactivityCheckRuleAction(dataprovider.EventActionUserInactivity{ + DeleteThreshold: 20, + }, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user2.Username, + }, + }, + }, &EventParams{}, time.Now().Add(30*24*time.Hour)) // no disable threshold, user deleted + assert.Error(t, err) + _, err = dataprovider.UserExists(username2, "") + assert.ErrorIs(t, err, util.ErrNotFound) + + err = dataprovider.DeleteUser(username1, "", "", "") + assert.Error(t, err) + err = dataprovider.DeleteUser(username2, "", "", "") + assert.Error(t, err) +} diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index 38060f23..d7f504cb 100644 --- a/internal/common/protocol_test.go +++ b/internal/common/protocol_test.go @@ -7171,6 +7171,109 @@ func TestEventRuleIPBlocked(t *testing.T) { assert.NoError(t, err) } +func TestEventRuleInactivityCheck(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) + + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + a1 := dataprovider.BaseEventAction{ + Name: "a1", + Type: dataprovider.ActionTypeUserInactivityCheck, + Options: dataprovider.BaseEventActionOptions{ + UserInactivityConfig: dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 20, + }, + }, + } + action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated) + assert.NoError(t, err) + a2 := dataprovider.BaseEventAction{ + Name: "a2", + Type: dataprovider.ActionTypeEmail, + Options: dataprovider.BaseEventActionOptions{ + EmailConfig: dataprovider.EventActionEmailConfig{ + Recipients: []string{"success@example.net"}, + Subject: `OK`, + Body: "OK action", + }, + }, + } + action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated) + assert.NoError(t, err) + + r1 := dataprovider.EventRule{ + Name: "rule1", + Status: 1, + Trigger: dataprovider.EventTriggerFsEvent, + Conditions: dataprovider.EventConditions{ + FsEvents: []string{"mkdir"}, + Options: dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: user.Username, + }, + }, + }, + }, + Actions: []dataprovider.EventAction{ + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action1.Name, + }, + Order: 1, + }, + { + BaseEventAction: dataprovider.BaseEventAction{ + Name: action2.Name, + }, + Order: 2, + }, + }, + } + rule1, resp, err := httpdtest.AddEventRule(r1, http.StatusCreated) + assert.NoError(t, err, string(resp)) + conn, client, err := getSftpClient(user) + if assert.NoError(t, err) { + defer conn.Close() + defer client.Close() + + lastReceivedEmail.reset() + err := client.Mkdir("just a test dir") + assert.NoError(t, err) + // just check that the action is executed + assert.Eventually(t, func() bool { + return lastReceivedEmail.get().From != "" + }, 1500*time.Millisecond, 100*time.Millisecond) + email := lastReceivedEmail.get() + assert.Len(t, email.To, 1) + assert.Contains(t, email.To, "success@example.net") + } + + _, err = httpdtest.RemoveEventRule(rule1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action1, http.StatusOK) + assert.NoError(t, err) + _, err = httpdtest.RemoveEventAction(action2, 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 TestEventRulePasswordExpiration(t *testing.T) { smtpCfg := smtp.Config{ Host: "127.0.0.1", diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index 1563cc6c..a46d9c6f 100644 --- a/internal/dataprovider/eventrule.go +++ b/internal/dataprovider/eventrule.go @@ -48,13 +48,14 @@ const ( ActionTypePasswordExpirationCheck ActionTypeUserExpirationCheck ActionTypeIDPAccountCheck + ActionTypeUserInactivityCheck ) var ( supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeFilesystem, ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, ActionTypeDataRetentionCheck, ActionTypePasswordExpirationCheck, - ActionTypeUserExpirationCheck, ActionTypeIDPAccountCheck} + ActionTypeUserExpirationCheck, ActionTypeUserInactivityCheck, ActionTypeIDPAccountCheck} ) func isActionTypeValid(action int) bool { @@ -83,6 +84,8 @@ func getActionTypeAsString(action int) string { return util.I18nActionTypePwdExpirationCheck case ActionTypeUserExpirationCheck: return util.I18nActionTypeUserExpirationCheck + case ActionTypeUserInactivityCheck: + return util.I18nActionTypeUserInactivityCheck case ActionTypeIDPAccountCheck: return util.I18nActionTypeIDPCheck default: @@ -915,6 +918,38 @@ func (c *EventActionPasswordExpiration) validate() error { return nil } +// EventActionUserInactivity defines the configuration for user inactivity checks. +type EventActionUserInactivity struct { + // DisableThreshold defines inactivity in days, since the last login before disabling the account + DisableThreshold int `json:"disable_threshold,omitempty"` + // DeleteThreshold defines inactivity in days, since the last login before deleting the account + DeleteThreshold int `json:"delete_threshold,omitempty"` +} + +func (c *EventActionUserInactivity) validate() error { + if c.DeleteThreshold < 0 { + c.DeleteThreshold = 0 + } + if c.DisableThreshold < 0 { + c.DisableThreshold = 0 + } + if c.DisableThreshold == 0 && c.DeleteThreshold == 0 { + return util.NewI18nError( + util.NewValidationError("at least a threshold must be defined"), + util.I18nActionThresholdRequired, + ) + } + if c.DeleteThreshold > 0 && c.DisableThreshold > 0 { + if c.DeleteThreshold <= c.DisableThreshold { + return util.NewI18nError( + util.NewValidationError(fmt.Sprintf("deletion threshold %d must be greater than deactivation threshold: %d", c.DeleteThreshold, c.DisableThreshold)), + util.I18nActionThresholdsInvalid, + ) + } + } + return nil +} + // EventActionIDPAccountCheck defines the check to execute after a successful IDP login type EventActionIDPAccountCheck struct { // 0 create/update, 1 create the account if it doesn't exist @@ -938,13 +973,14 @@ func (c *EventActionIDPAccountCheck) validate() error { // BaseEventActionOptions defines the supported configuration options for a base event actions type BaseEventActionOptions struct { - HTTPConfig EventActionHTTPConfig `json:"http_config"` - CmdConfig EventActionCommandConfig `json:"cmd_config"` - EmailConfig EventActionEmailConfig `json:"email_config"` - RetentionConfig EventActionDataRetentionConfig `json:"retention_config"` - FsConfig EventActionFilesystemConfig `json:"fs_config"` - PwdExpirationConfig EventActionPasswordExpiration `json:"pwd_expiration_config"` - IDPConfig EventActionIDPAccountCheck `json:"idp_config"` + HTTPConfig EventActionHTTPConfig `json:"http_config"` + CmdConfig EventActionCommandConfig `json:"cmd_config"` + EmailConfig EventActionEmailConfig `json:"email_config"` + RetentionConfig EventActionDataRetentionConfig `json:"retention_config"` + FsConfig EventActionFilesystemConfig `json:"fs_config"` + PwdExpirationConfig EventActionPasswordExpiration `json:"pwd_expiration_config"` + UserInactivityConfig EventActionUserInactivity `json:"user_inactivity_config"` + IDPConfig EventActionIDPAccountCheck `json:"idp_config"` } func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions { @@ -1009,6 +1045,10 @@ func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions { PwdExpirationConfig: EventActionPasswordExpiration{ Threshold: o.PwdExpirationConfig.Threshold, }, + UserInactivityConfig: EventActionUserInactivity{ + DisableThreshold: o.UserInactivityConfig.DisableThreshold, + DeleteThreshold: o.UserInactivityConfig.DeleteThreshold, + }, IDPConfig: EventActionIDPAccountCheck{ Mode: o.IDPConfig.Mode, TemplateUser: o.IDPConfig.TemplateUser, @@ -1047,6 +1087,7 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} + o.UserInactivityConfig = EventActionUserInactivity{} return o.HTTPConfig.validate(name) case ActionTypeCommand: o.HTTPConfig = EventActionHTTPConfig{} @@ -1055,6 +1096,7 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} + o.UserInactivityConfig = EventActionUserInactivity{} return o.CmdConfig.validate() case ActionTypeEmail: o.HTTPConfig = EventActionHTTPConfig{} @@ -1063,6 +1105,7 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} + o.UserInactivityConfig = EventActionUserInactivity{} return o.EmailConfig.validate() case ActionTypeDataRetentionCheck: o.HTTPConfig = EventActionHTTPConfig{} @@ -1071,6 +1114,7 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} + o.UserInactivityConfig = EventActionUserInactivity{} return o.RetentionConfig.validate() case ActionTypeFilesystem: o.HTTPConfig = EventActionHTTPConfig{} @@ -1079,6 +1123,7 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.RetentionConfig = EventActionDataRetentionConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} + o.UserInactivityConfig = EventActionUserInactivity{} return o.FsConfig.validate() case ActionTypePasswordExpirationCheck: o.HTTPConfig = EventActionHTTPConfig{} @@ -1087,7 +1132,17 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} o.IDPConfig = EventActionIDPAccountCheck{} + o.UserInactivityConfig = EventActionUserInactivity{} return o.PwdExpirationConfig.validate() + case ActionTypeUserInactivityCheck: + o.HTTPConfig = EventActionHTTPConfig{} + o.CmdConfig = EventActionCommandConfig{} + o.EmailConfig = EventActionEmailConfig{} + o.RetentionConfig = EventActionDataRetentionConfig{} + o.FsConfig = EventActionFilesystemConfig{} + o.IDPConfig = EventActionIDPAccountCheck{} + o.PwdExpirationConfig = EventActionPasswordExpiration{} + return o.UserInactivityConfig.validate() case ActionTypeIDPAccountCheck: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} @@ -1095,6 +1150,7 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} + o.UserInactivityConfig = EventActionUserInactivity{} return o.IDPConfig.validate() default: o.HTTPConfig = EventActionHTTPConfig{} @@ -1104,6 +1160,7 @@ func (o *BaseEventActionOptions) validate(action int, name string) error { o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} + o.UserInactivityConfig = EventActionUserInactivity{} } return nil } diff --git a/internal/dataprovider/user.go b/internal/dataprovider/user.go index 20fb5c06..95c7c736 100644 --- a/internal/dataprovider/user.go +++ b/internal/dataprovider/user.go @@ -1130,6 +1130,22 @@ func (u *User) CanCopyFromWeb(src, dest string) bool { return u.HasPerm(PermCopy, src) && u.HasPerm(PermCopy, dest) } +// InactivityDays returns the number of days of inactivity +func (u *User) InactivityDays(when time.Time) int { + if when.IsZero() { + when = time.Now() + } + lastActivity := u.LastLogin + if lastActivity == 0 { + lastActivity = u.CreatedAt + } + if lastActivity == 0 { + // unable to determine inactivity + return 0 + } + return int(float64(when.Sub(util.GetTimeFromMsecSinceEpoch(lastActivity))) / float64(24*time.Hour)) +} + // PasswordExpiresIn returns the number of days before the password expires. // The returned value is negative if the password is expired. // The caller must ensure that a PasswordExpiration is set diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index 9aa04c97..cbfa4966 100644 --- a/internal/httpd/httpd_test.go +++ b/internal/httpd/httpd_test.go @@ -1827,6 +1827,16 @@ func TestBasicActionRulesHandling(t *testing.T) { _, _, err = httpdtest.UpdateEventAction(a, http.StatusOK) assert.NoError(t, err) + a.Type = dataprovider.ActionTypeUserInactivityCheck + a.Options = dataprovider.BaseEventActionOptions{ + UserInactivityConfig: dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 20, + }, + } + _, _, err = httpdtest.UpdateEventAction(a, http.StatusOK) + assert.NoError(t, err) + a.Type = dataprovider.ActionTypeHTTP a.Options = dataprovider.BaseEventActionOptions{ HTTPConfig: dataprovider.EventActionHTTPConfig{ @@ -2501,6 +2511,25 @@ func TestEventActionValidation(t *testing.T) { _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) assert.NoError(t, err) assert.Contains(t, string(resp), "invalid account check mode") + action.Type = dataprovider.ActionTypeUserInactivityCheck + action.Options = dataprovider.BaseEventActionOptions{ + UserInactivityConfig: dataprovider.EventActionUserInactivity{ + DisableThreshold: 0, + DeleteThreshold: 0, + }, + } + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "at least a threshold must be defined") + action.Options = dataprovider.BaseEventActionOptions{ + UserInactivityConfig: dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 10, + }, + } + _, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest) + assert.NoError(t, err) + assert.Contains(t, string(resp), "must be greater than deactivation threshold") } func TestEventRuleValidation(t *testing.T) { @@ -23137,6 +23166,28 @@ func TestWebEventAction(t *testing.T) { assert.Equal(t, 0, actionGet.Options.CmdConfig.Timeout) assert.Len(t, actionGet.Options.CmdConfig.EnvVars, 0) + action.Type = dataprovider.ActionTypeUserInactivityCheck + action.Options.UserInactivityConfig = dataprovider.EventActionUserInactivity{ + DisableThreshold: 10, + DeleteThreshold: 15, + } + form.Set("type", fmt.Sprintf("%d", action.Type)) + form.Set("inactivity_disable_threshold", strconv.Itoa(action.Options.UserInactivityConfig.DisableThreshold)) + form.Set("inactivity_delete_threshold", strconv.Itoa(action.Options.UserInactivityConfig.DeleteThreshold)) + req, err = http.NewRequest(http.MethodPost, path.Join(webAdminEventActionPath, action.Name), + bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, webToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + actionGet, _, err = httpdtest.GetEventActionByName(action.Name, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, action.Type, actionGet.Type) + assert.Equal(t, 0, actionGet.Options.PwdExpirationConfig.Threshold) + assert.Equal(t, action.Options.UserInactivityConfig.DisableThreshold, actionGet.Options.UserInactivityConfig.DisableThreshold) + assert.Equal(t, action.Options.UserInactivityConfig.DeleteThreshold, actionGet.Options.UserInactivityConfig.DeleteThreshold) + action.Type = dataprovider.ActionTypeIDPAccountCheck form.Set("type", fmt.Sprintf("%d", action.Type)) form.Set("idp_mode", "1") diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index f163d502..bbfd7994 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -2312,6 +2312,13 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven if err != nil { return dataprovider.BaseEventActionOptions{}, fmt.Errorf("invalid password expiration threshold: %w", err) } + var disableThreshold, deleteThreshold int + if val, err := strconv.Atoi(r.Form.Get("inactivity_disable_threshold")); err == nil { + disableThreshold = val + } + if val, err := strconv.Atoi(r.Form.Get("inactivity_delete_threshold")); err == nil { + deleteThreshold = val + } var emailAttachments []string if r.Form.Get("email_attachments") != "" { emailAttachments = getSliceFromDelimitedValues(r.Form.Get("email_attachments"), ",") @@ -2373,6 +2380,10 @@ func getEventActionOptionsFromPostFields(r *http.Request) (dataprovider.BaseEven PwdExpirationConfig: dataprovider.EventActionPasswordExpiration{ Threshold: pwdExpirationThreshold, }, + UserInactivityConfig: dataprovider.EventActionUserInactivity{ + DisableThreshold: disableThreshold, + DeleteThreshold: deleteThreshold, + }, IDPConfig: dataprovider.EventActionIDPAccountCheck{ Mode: idpMode, TemplateUser: strings.TrimSpace(r.Form.Get("idp_user")), diff --git a/internal/httpdtest/httpdtest.go b/internal/httpdtest/httpdtest.go index 71ffa12b..0db93a8e 100644 --- a/internal/httpdtest/httpdtest.go +++ b/internal/httpdtest/httpdtest.go @@ -1600,6 +1600,12 @@ func checkEventAction(expected, actual dataprovider.BaseEventAction) error { if expected.Options.PwdExpirationConfig.Threshold != actual.Options.PwdExpirationConfig.Threshold { return errors.New("password expiration threshold mismatch") } + if expected.Options.UserInactivityConfig.DisableThreshold != actual.Options.UserInactivityConfig.DisableThreshold { + return errors.New("user inactivity disable threshold mismatch") + } + if expected.Options.UserInactivityConfig.DeleteThreshold != actual.Options.UserInactivityConfig.DeleteThreshold { + return errors.New("user inactivity delete threshold mismatch") + } if err := compareEventActionIDPConfigFields(expected.Options.IDPConfig, actual.Options.IDPConfig); err != nil { return err } diff --git a/internal/util/i18n.go b/internal/util/i18n.go index 94fb90f4..b2c760d7 100644 --- a/internal/util/i18n.go +++ b/internal/util/i18n.go @@ -276,6 +276,7 @@ const ( I18nActionTypeFilesystem = "actions.types.filesystem" I18nActionTypePwdExpirationCheck = "actions.types.password_expiration_check" I18nActionTypeUserExpirationCheck = "actions.types.user_expiration_check" + I18nActionTypeUserInactivityCheck = "actions.types.user_inactivity_check" I18nActionTypeIDPCheck = "actions.types.idp_check" I18nActionTypeCommand = "actions.types.command" I18nActionFsTypeRename = "actions.fs_types.rename" @@ -284,6 +285,8 @@ const ( I18nActionFsTypeCompress = "actions.fs_types.compress" I18nActionFsTypeCopy = "actions.fs_types.copy" I18nActionFsTypeCreateDirs = "actions.fs_types.create_dirs" + I18nActionThresholdRequired = "actions.inactivity_threshold_required" + I18nActionThresholdsInvalid = "actions.inactivity_thresholds_invalid" I18nTriggerFsEvent = "rules.triggers.fs_event" I18nTriggerProviderEvent = "rules.triggers.provider_event" I18nTriggerIPBlockedEvent = "rules.triggers.ip_blocked" diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 0c2234d7..d43dc039 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -5004,10 +5004,10 @@ components: - 7 - 8 - 9 - - 10 - 11 - 12 - 13 + - 14 description: | Supported event action types: * `1` - HTTP @@ -5022,6 +5022,7 @@ components: * `11` - Password expiration check * `12` - User expiration check * `13` - Identity Provider account check + * `14` - User inactivity check FilesystemActionTypes: type: integer enum: @@ -7087,6 +7088,15 @@ components: threshold: type: integer description: 'An email notification will be generated for users whose password expires in a number of days less than or equal to this threshold' + EventActionUserInactivity: + type: object + properties: + disable_threshold: + type: integer + description: 'Inactivity threshold, in days, before disabling the account' + delete_threshold: + type: integer + description: 'Inactivity threshold, in days, before deleting the account' EventActionIDPAccountCheck: type: object properties: @@ -7120,6 +7130,8 @@ components: $ref: '#/components/schemas/EventActionFilesystemConfig' pwd_expiration_config: $ref: '#/components/schemas/EventActionPasswordExpiration' + user_inactivity_config: + $ref: '#/components/schemas/EventActionUserInactivity' idp_config: $ref: '#/components/schemas/EventActionIDPAccountCheck' BaseEventAction: diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index e6758e7b..da447cce 100644 --- a/static/locales/en/translation.json +++ b/static/locales/en/translation.json @@ -934,6 +934,12 @@ "idp_template_required": "A user or admin template is required", "threshold": "Threshold", "threshold_help": "An email notification will be generated for users whose password expires in a number of days less than or equal to this threshold", + "disable_threshold": "Disable threshold", + "disable_threshold_help": "Inactivity in days, since last login or creation before disabling users", + "delete_threshold": "Delete threshold", + "delete_threshold_help": "Inactivity in days, since last login or creation before deleting users", + "inactivity_threshold_required": "At least one inactivity threshold must be defined", + "inactivity_thresholds_invalid": "The deletion threshold must be greater than the deactivation threshold", "idp_mode_add_update": "Create or update", "idp_mode_add": "Create if it doesn't exist", "template_user_help": "Template for SFTPGo users in JSON format. Placeholders are supported", @@ -984,6 +990,7 @@ "filesystem": "Filesystem", "password_expiration_check": "Password expiration check", "user_expiration_check": "User expiration check", + "user_inactivity_check": "User inactivity check", "idp_check": "Identity Provider account check", "command": "Command" }, diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index 6463bf80..cf253c1b 100644 --- a/static/locales/it/translation.json +++ b/static/locales/it/translation.json @@ -934,6 +934,12 @@ "idp_template_required": "Un modello di utenti o amministratori è obbligatorio", "threshold": "Soglia", "threshold_help": "Verrà generata una notifica email per gli utenti la cui password scade tra un numero di giorni inferiore o uguale a questa soglia", + "disable_threshold": "Soglia disabilitazione", + "disable_threshold_help": "Inattività in giorni, dall'ultimo login o dalla creazione prima della disabilitazione degli utenti", + "delete_threshold": "Soglia eliminazione", + "delete_threshold_help": "Inattività in giorni, dall'ultimo login o dalla creazione prima dell'eliminazione degli utenti", + "inactivity_threshold_required": "È necessario definire almeno una soglia di inattività", + "inactivity_thresholds_invalid": "La soglia di eliminazione deve essere maggiore della soglia di disattivazione", "idp_mode_add_update": "Crea o aggiorna", "idp_mode_add": "Crea se non esiste", "template_user_help": "Modello per gli utenti SFTPGo in formato JSON. I segnaposto sono supportati", @@ -984,6 +990,7 @@ "filesystem": "Filesystem", "password_expiration_check": "Controllo password scadute", "user_expiration_check": "Controllo utenti scaduti", + "user_inactivity_check": "Controllo inattività utente", "idp_check": "Controllo account Identity Provider", "command": "Comando" }, diff --git a/templates/webadmin/eventaction.html b/templates/webadmin/eventaction.html index 1e6ca149..1cad592f 100644 --- a/templates/webadmin/eventaction.html +++ b/templates/webadmin/eventaction.html @@ -58,6 +58,22 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). +
+ +
+ +
+
+
+ +
+ +
+ +
+
+
+
@@ -976,6 +992,9 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). case '13': $('.action-idp').show(); break; + case '14': + $('.action-user-inactivity').show(); + break; } } diff --git a/templates/webadmin/eventactions.html b/templates/webadmin/eventactions.html index 0b8dd7b0..e78dba57 100644 --- a/templates/webadmin/eventactions.html +++ b/templates/webadmin/eventactions.html @@ -190,6 +190,8 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). return $.t('actions.types.user_expiration_check'); case 13: return $.t('actions.types.idp_check'); + case 14: + return $.t('actions.types.user_inactivity_check'); default: return ""; }