EventManager: allow to check for inactive users

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-03-04 19:48:10 +01:00
parent 8b2188fcb6
commit 4d357a6a57
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
17 changed files with 556 additions and 26 deletions

View file

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

12
go.mod
View file

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

23
go.sum
View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -944,6 +979,7 @@ type BaseEventActionOptions struct {
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"`
}
@ -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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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