diff --git a/docs/eventmanager.md b/docs/eventmanager.md index fdd5e5ec..be397393 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. - `Metadata check`. A metadata check requires a metadata plugin such as [this one](https://github.com/sftpgo/sftpgo-plugin-metadata) and removes the metadata associated to missing items (for example objects deleted outside SFTPGo). A metadata check does nothing is no metadata plugin is installed or external metadata are not supported for a filesystem. - `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. - `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. - `Delete`. You can delete one or more files and directories. diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index 3bcda315..bac0d584 100644 --- a/internal/common/eventmanager.go +++ b/internal/common/eventmanager.go @@ -1421,7 +1421,6 @@ func executeDeleteFsRuleAction(deletes []string, replacer *strings.Replacer, if err = executeDeleteFsActionForUser(deletes, replacer, user); err != nil { params.AddError(err) failures = append(failures, user.Username) - continue } } if len(failures) > 0 { @@ -1479,7 +1478,6 @@ func executeMkdirFsRuleAction(dirs []string, replacer *strings.Replacer, executed++ if err = executeMkDirsFsActionForUser(dirs, replacer, user); err != nil { failures = append(failures, user.Username) - continue } } if len(failures) > 0 { @@ -1593,7 +1591,6 @@ func executeRenameFsRuleAction(renames []dataprovider.KeyValue, replacer *string if err = executeRenameFsActionForUser(renames, replacer, user); err != nil { failures = append(failures, user.Username) params.AddError(err) - continue } } if len(failures) > 0 { @@ -1628,7 +1625,6 @@ func executeCopyFsRuleAction(copy []dataprovider.KeyValue, replacer *strings.Rep if err = executeCopyFsActionForUser(copy, replacer, user); err != nil { failures = append(failures, user.Username) params.AddError(err) - continue } } if len(failures) > 0 { @@ -1779,7 +1775,6 @@ func executeExistFsRuleAction(exist []string, replacer *strings.Replacer, condit if err = executeExistFsActionForUser(exist, replacer, user); err != nil { failures = append(failures, user.Username) params.AddError(err) - continue } } if len(failures) > 0 { @@ -1814,7 +1809,6 @@ func executeCompressFsRuleAction(c dataprovider.EventActionFsCompress, replacer if err = executeCompressFsActionForUser(c, replacer, user); err != nil { failures = append(failures, user.Username) params.AddError(err) - continue } } if len(failures) > 0 { @@ -1896,7 +1890,6 @@ func executeUsersQuotaResetRuleAction(conditions dataprovider.ConditionOptions, if err = executeQuotaResetForUser(&user); err != nil { params.AddError(err) failedResets = append(failedResets, user.Username) - continue } } if len(failedResets) > 0 { @@ -2045,7 +2038,6 @@ func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRete if err = executeDataRetentionCheckForUser(user, config.Folders, params, actionName); err != nil { failedChecks = append(failedChecks, user.Username) params.AddError(err) - continue } } if len(failedChecks) > 0 { @@ -2058,6 +2050,40 @@ func executeDataRetentionCheckRuleAction(config dataprovider.EventActionDataRete return nil } +func executeUserExpirationCheckRuleAction(conditions dataprovider.ConditionOptions, params *EventParams) error { + users, err := params.getUsers() + if err != nil { + return fmt.Errorf("unable to get users: %w", err) + } + var failures []string + var executed int + 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 expiration check for user %q, condition options don't match", + user.Username) + continue + } + } + executed++ + if user.ExpirationDate > 0 { + expDate := util.GetTimeFromMsecSinceEpoch(user.ExpirationDate) + if expDate.Before(time.Now()) { + failures = append(failures, user.Username) + } + } + } + if len(failures) > 0 { + return fmt.Errorf("expired users: %+v", failures) + } + if executed == 0 { + eventManagerLog(logger.LevelError, "no user expiration check executed") + return errors.New("no user expiration check executed") + } + return nil +} + func executeMetadataCheckForUser(user *dataprovider.User) error { if err := user.LoadAndApplyGroupSettings(); err != nil { eventManagerLog(logger.LevelError, "skipping scheduled quota reset for user %s, cannot apply group settings: %v", @@ -2097,7 +2123,6 @@ func executeMetadataCheckRuleAction(conditions dataprovider.ConditionOptions, pa if err = executeMetadataCheckForUser(&user); err != nil { params.AddError(err) failures = append(failures, user.Username) - continue } } if len(failures) > 0 { @@ -2166,7 +2191,6 @@ func executePwdExpirationCheckRuleAction(config dataprovider.EventActionPassword if err = executePwdExpirationCheckForUser(&user, config); err != nil { params.AddError(err) failures = append(failures, user.Username) - continue } } if len(failures) > 0 { @@ -2208,6 +2232,8 @@ func executeRuleAction(action dataprovider.BaseEventAction, params *EventParams, err = executeFsRuleAction(action.Options.FsConfig, conditions, params) case dataprovider.ActionTypePasswordExpirationCheck: err = executePwdExpirationCheckRuleAction(action.Options.PwdExpirationConfig, conditions, params) + case dataprovider.ActionTypeUserExpirationCheck: + err = executeUserExpirationCheckRuleAction(conditions, params) 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 b28a2ecb..fd666ec3 100644 --- a/internal/common/eventmanager_test.go +++ b/internal/common/eventmanager_test.go @@ -436,6 +436,8 @@ func TestEventManagerErrors(t *testing.T) { assert.Error(t, err) err = executeMetadataCheckRuleAction(dataprovider.ConditionOptions{}, &EventParams{}) assert.Error(t, err) + err = executeUserExpirationCheckRuleAction(dataprovider.ConditionOptions{}, &EventParams{}) + assert.Error(t, err) err = executeDeleteFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{}) assert.Error(t, err) err = executeMkdirFsRuleAction(nil, nil, dataprovider.ConditionOptions{}, &EventParams{}) @@ -844,6 +846,29 @@ func TestEventRuleActions(t *testing.T) { assert.Error(t, err) assert.True(t, ActiveMetadataChecks.Remove(username1)) + action = dataprovider.BaseEventAction{ + Type: dataprovider.ActionTypeUserExpirationCheck, + } + + err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: "don't match", + }, + }, + }) + assert.Error(t, err) + assert.Contains(t, getErrorString(err), "no user expiration check executed") + + err = executeRuleAction(action, &EventParams{}, dataprovider.ConditionOptions{ + Names: []dataprovider.ConditionPattern{ + { + Pattern: username1, + }, + }, + }) + assert.NoError(t, err) + dataRetentionAction := dataprovider.BaseEventAction{ Type: dataprovider.ActionTypeDataRetentionCheck, Options: dataprovider.BaseEventActionOptions{ @@ -1170,6 +1195,32 @@ func TestEventRuleActions(t *testing.T) { assert.NoError(t, err) } +func TestUserExpirationCheck(t *testing.T) { + username := "test_user_expiration_check" + user := dataprovider.User{ + BaseUser: sdk.BaseUser{ + Username: username, + Permissions: map[string][]string{ + "/": {dataprovider.PermAny}, + }, + HomeDir: filepath.Join(os.TempDir(), username), + ExpirationDate: util.GetTimeAsMsSinceEpoch(time.Now().Add(-24 * time.Hour)), + }, + } + err := dataprovider.AddUser(&user, "", "", "") + assert.NoError(t, err) + + err = executeUserExpirationCheckRuleAction(dataprovider.ConditionOptions{}, &EventParams{}) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "expired users") + } + + err = dataprovider.DeleteUser(username, "", "", "") + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestEventRuleActionsNoGroupMatching(t *testing.T) { username := "test_user_action_group_matching" user := dataprovider.User{ diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index 2b237d4c..2d926d0b 100644 --- a/internal/dataprovider/eventrule.go +++ b/internal/dataprovider/eventrule.go @@ -46,12 +46,14 @@ const ( ActionTypeFilesystem ActionTypeMetadataCheck ActionTypePasswordExpirationCheck + ActionTypeUserExpirationCheck ) var ( supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeFilesystem, ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, - ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypePasswordExpirationCheck} + ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypePasswordExpirationCheck, + ActionTypeUserExpirationCheck} ) func isActionTypeValid(action int) bool { @@ -80,6 +82,8 @@ func getActionTypeAsString(action int) string { return "Filesystem" case ActionTypePasswordExpirationCheck: return "Password expiration check" + case ActionTypeUserExpirationCheck: + return "User expiration check" default: return "Command" } @@ -1457,7 +1461,8 @@ func (r *EventRule) validateMandatorySyncActions() error { func (r *EventRule) checkIPBlockedAndCertificateActions() error { unavailableActions := []int{ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, - ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem, ActionTypePasswordExpirationCheck} + ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem, ActionTypePasswordExpirationCheck, + ActionTypeUserExpirationCheck} for _, action := range r.Actions { if util.Contains(unavailableActions, action.Type) { return fmt.Errorf("action %q, type %q is not supported for event trigger %q", @@ -1472,7 +1477,8 @@ func (r *EventRule) checkProviderEventActions(providerObjectType string) error { // can be executed only if we modify a user. They will be executed for the // affected user. Folder quota reset can be executed only for folders. userSpecificActions := []int{ActionTypeUserQuotaReset, ActionTypeTransferQuotaReset, - ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem, ActionTypePasswordExpirationCheck} + ActionTypeDataRetentionCheck, ActionTypeMetadataCheck, ActionTypeFilesystem, + ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck} for _, action := range r.Actions { if util.Contains(userSpecificActions, action.Type) && providerObjectType != actionObjectUser { return fmt.Errorf("action %q, type %q is only supported for provider user events", diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index d0e8bb25..5fb68a2f 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -4871,6 +4871,9 @@ components: - 7 - 8 - 9 + - 10 + - 11 + - 12 description: | Supported event action types: * `1` - HTTP @@ -4882,6 +4885,9 @@ components: * `7` - Transfer quota reset * `8` - Data retention check * `9` - Filesystem + * `10` - Metadata check + * `11` - Password expiration check + * `12` - User expiration check FilesystemActionTypes: type: integer enum: