Ver código fonte

EventManager: allow to check for inactive users

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 1 ano atrás
pai
commit
4d357a6a57

+ 1 - 0
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.

+ 6 - 6
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

+ 12 - 11
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=

+ 61 - 0
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)
 	}

+ 173 - 0
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)
+}

+ 103 - 0
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",

+ 65 - 8
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
 }

+ 16 - 0
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

+ 51 - 0
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")

+ 11 - 0
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")),

+ 6 - 0
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
 	}

+ 3 - 0
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"

+ 13 - 1
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:

+ 7 - 0
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"
         },

+ 7 - 0
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"
         },

+ 19 - 0
templates/webadmin/eventaction.html

@@ -58,6 +58,22 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
                 </div>
             </div>
 
+            <div class="form-group row action-type action-user-inactivity mt-10">
+                <label for="idInactivityThresholdDisable" data-i18n="actions.disable_threshold" class="col-md-3 col-form-label">Disable Threshold</label>
+                <div class="col-md-9">
+                    <input id="idInactivityThresholdDisable" type="number" min="0" class="form-control" name="inactivity_disable_threshold" value="{{.Action.Options.UserInactivityConfig.DisableThreshold}}" aria-describedby="idInactivityThresholdDisableHelp" />
+                    <div id="idInactivityThresholdDisableHelp" class="form-text" data-i18n="actions.disable_threshold_help"></div>
+                </div>
+            </div>
+
+            <div class="form-group row action-type action-user-inactivity mt-10">
+                <label for="idInactivityThresholdDelete" data-i18n="actions.delete_threshold" class="col-md-3 col-form-label">Delete Threshold</label>
+                <div class="col-md-9">
+                    <input id="idInactivityThresholdDelete" type="number" min="0" class="form-control" name="inactivity_delete_threshold" value="{{.Action.Options.UserInactivityConfig.DeleteThreshold}}" aria-describedby="idInactivityThresholdDeleteHelp" />
+                    <div id="idInactivityThresholdDeleteHelp" class="form-text" data-i18n="actions.delete_threshold_help"></div>
+                </div>
+            </div>
+
             <div class="form-group action-type action-idp row mt-10">
                 <label for="idIDPMode" data-i18n="general.mode" class="col-md-3 col-form-label">Mode</label>
                 <div class="col-md-9">
@@ -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;
         }
     }
 

+ 2 - 0
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 "";
                                 }