// Copyright (C) 2019 Nicola Murino // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, version 3. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package dataprovider import ( "context" "crypto/tls" "encoding/json" "errors" "fmt" "net/http" "path" "path/filepath" "slices" "strings" "time" "github.com/robfig/cron/v3" "github.com/drakkan/sftpgo/v2/internal/kms" "github.com/drakkan/sftpgo/v2/internal/logger" "github.com/drakkan/sftpgo/v2/internal/util" ) // Supported event actions const ( ActionTypeHTTP = iota + 1 ActionTypeCommand ActionTypeEmail ActionTypeBackup ActionTypeUserQuotaReset ActionTypeFolderQuotaReset ActionTypeTransferQuotaReset ActionTypeDataRetentionCheck ActionTypeFilesystem actionTypeReserved ActionTypePasswordExpirationCheck ActionTypeUserExpirationCheck ActionTypeIDPAccountCheck ActionTypeUserInactivityCheck ActionTypeRotateLogs ) var ( supportedEventActions = []int{ActionTypeHTTP, ActionTypeCommand, ActionTypeEmail, ActionTypeFilesystem, ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, ActionTypeDataRetentionCheck, ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck, ActionTypeUserInactivityCheck, ActionTypeIDPAccountCheck, ActionTypeRotateLogs} // EnabledActionCommands defines the system commands that can be executed via EventManager, // an empty list means that no command is allowed to be executed. EnabledActionCommands []string ) func isActionTypeValid(action int) bool { return slices.Contains(supportedEventActions, action) } func getActionTypeAsString(action int) string { switch action { case ActionTypeHTTP: return util.I18nActionTypeHTTP case ActionTypeEmail: return util.I18nActionTypeEmail case ActionTypeBackup: return util.I18nActionTypeBackup case ActionTypeUserQuotaReset: return util.I18nActionTypeUserQuotaReset case ActionTypeFolderQuotaReset: return util.I18nActionTypeFolderQuotaReset case ActionTypeTransferQuotaReset: return util.I18nActionTypeTransferQuotaReset case ActionTypeDataRetentionCheck: return util.I18nActionTypeDataRetentionCheck case ActionTypeFilesystem: return util.I18nActionTypeFilesystem case ActionTypePasswordExpirationCheck: return util.I18nActionTypePwdExpirationCheck case ActionTypeUserExpirationCheck: return util.I18nActionTypeUserExpirationCheck case ActionTypeUserInactivityCheck: return util.I18nActionTypeUserInactivityCheck case ActionTypeIDPAccountCheck: return util.I18nActionTypeIDPCheck case ActionTypeRotateLogs: return util.I18nActionTypeRotateLogs default: return util.I18nActionTypeCommand } } // Supported event triggers const ( // Filesystem events such as upload, download, mkdir ... EventTriggerFsEvent = iota + 1 // Provider events such as add, update, delete EventTriggerProviderEvent EventTriggerSchedule EventTriggerIPBlocked EventTriggerCertificate EventTriggerOnDemand EventTriggerIDPLogin ) var ( supportedEventTriggers = []int{EventTriggerFsEvent, EventTriggerProviderEvent, EventTriggerSchedule, EventTriggerIPBlocked, EventTriggerCertificate, EventTriggerIDPLogin, EventTriggerOnDemand} ) func isEventTriggerValid(trigger int) bool { return slices.Contains(supportedEventTriggers, trigger) } func getTriggerTypeAsString(trigger int) string { switch trigger { case EventTriggerFsEvent: return util.I18nTriggerFsEvent case EventTriggerProviderEvent: return util.I18nTriggerProviderEvent case EventTriggerIPBlocked: return util.I18nTriggerIPBlockedEvent case EventTriggerCertificate: return util.I18nTriggerCertificateRenewEvent case EventTriggerOnDemand: return util.I18nTriggerOnDemandEvent case EventTriggerIDPLogin: return util.I18nTriggerIDPLoginEvent default: return util.I18nTriggerScheduleEvent } } // Supported IDP login events const ( IDPLoginAny = iota IDPLoginUser IDPLoginAdmin ) var ( supportedIDPLoginEvents = []int{IDPLoginAny, IDPLoginUser, IDPLoginAdmin} ) // Supported filesystem actions const ( FilesystemActionRename = iota + 1 FilesystemActionDelete FilesystemActionMkdirs FilesystemActionExist FilesystemActionCompress FilesystemActionCopy ) const ( // RetentionReportPlaceHolder defines the placeholder for data retention reports RetentionReportPlaceHolder = "{{RetentionReports}}" ) var ( supportedFsActions = []int{FilesystemActionRename, FilesystemActionDelete, FilesystemActionMkdirs, FilesystemActionCopy, FilesystemActionCompress, FilesystemActionExist} ) func isFilesystemActionValid(value int) bool { return slices.Contains(supportedFsActions, value) } func getFsActionTypeAsString(value int) string { switch value { case FilesystemActionRename: return util.I18nActionFsTypeRename case FilesystemActionDelete: return util.I18nActionFsTypeDelete case FilesystemActionExist: return util.I18nActionFsTypePathExists case FilesystemActionCompress: return util.I18nActionFsTypeCompress case FilesystemActionCopy: return util.I18nActionFsTypeCopy default: return util.I18nActionFsTypeCreateDirs } } // TODO: replace the copied strings with shared constants var ( // SupportedFsEvents defines the supported filesystem events SupportedFsEvents = []string{"upload", "pre-upload", "first-upload", "download", "pre-download", "first-download", "delete", "pre-delete", "rename", "mkdir", "rmdir", "copy", "ssh_cmd"} // SupportedProviderEvents defines the supported provider events SupportedProviderEvents = []string{operationAdd, operationUpdate, operationDelete} // SupportedRuleConditionProtocols defines the supported protcols for rule conditions SupportedRuleConditionProtocols = []string{"SFTP", "SCP", "SSH", "FTP", "DAV", "HTTP", "HTTPShare", "OIDC"} // SupporteRuleConditionProviderObjects defines the supported provider objects for rule conditions SupporteRuleConditionProviderObjects = []string{actionObjectUser, actionObjectFolder, actionObjectGroup, actionObjectAdmin, actionObjectAPIKey, actionObjectShare, actionObjectEventRule, actionObjectEventAction} // SupportedHTTPActionMethods defines the supported methods for HTTP actions SupportedHTTPActionMethods = []string{http.MethodPost, http.MethodGet, http.MethodPut, http.MethodDelete} allowedSyncFsEvents = []string{"upload", "pre-upload", "pre-download", "pre-delete"} mandatorySyncFsEvents = []string{"pre-upload", "pre-download", "pre-delete"} ) // enum mappings var ( EventActionTypes []EnumMapping EventTriggerTypes []EnumMapping FsActionTypes []EnumMapping ) func init() { for _, t := range supportedEventActions { EventActionTypes = append(EventActionTypes, EnumMapping{ Value: t, Name: getActionTypeAsString(t), }) } for _, t := range supportedEventTriggers { EventTriggerTypes = append(EventTriggerTypes, EnumMapping{ Value: t, Name: getTriggerTypeAsString(t), }) } for _, t := range supportedFsActions { FsActionTypes = append(FsActionTypes, EnumMapping{ Value: t, Name: getFsActionTypeAsString(t), }) } } // EnumMapping defines a mapping between enum values and names type EnumMapping struct { Name string Value int } // KeyValue defines a key/value pair type KeyValue struct { Key string `json:"key"` Value string `json:"value"` } func (k *KeyValue) isNotValid() bool { return k.Key == "" || k.Value == "" } // HTTPPart defines a part for HTTP multipart requests type HTTPPart struct { Name string `json:"name,omitempty"` Filepath string `json:"filepath,omitempty"` Headers []KeyValue `json:"headers,omitempty"` Body string `json:"body,omitempty"` Order int `json:"-"` } func (p *HTTPPart) validate() error { if p.Name == "" { return util.NewI18nError(util.NewValidationError("HTTP part name is required"), util.I18nErrorHTTPPartNameRequired) } for _, kv := range p.Headers { if kv.isNotValid() { return util.NewValidationError("invalid HTTP part headers") } } if p.Filepath == "" { if p.Body == "" { return util.NewI18nError( util.NewValidationError("HTTP part body is required if no file path is provided"), util.I18nErrorHTTPPartBodyRequired, ) } } else { p.Body = "" if p.Filepath != RetentionReportPlaceHolder { p.Filepath = util.CleanPath(p.Filepath) } } return nil } // EventActionHTTPConfig defines the configuration for an HTTP event target type EventActionHTTPConfig struct { Endpoint string `json:"endpoint,omitempty"` Username string `json:"username,omitempty"` Password *kms.Secret `json:"password,omitempty"` Headers []KeyValue `json:"headers,omitempty"` Timeout int `json:"timeout,omitempty"` SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` Method string `json:"method,omitempty"` QueryParameters []KeyValue `json:"query_parameters,omitempty"` Body string `json:"body,omitempty"` Parts []HTTPPart `json:"parts,omitempty"` } // HasJSONBody returns true if the content type header indicates a JSON body func (c *EventActionHTTPConfig) HasJSONBody() bool { for _, h := range c.Headers { if http.CanonicalHeaderKey(h.Key) == "Content-Type" { return strings.Contains(strings.ToLower(h.Value), "application/json") } } return false } func (c *EventActionHTTPConfig) isTimeoutNotValid() bool { if c.HasMultipartFiles() { return false } return c.Timeout < 1 || c.Timeout > 180 } func (c *EventActionHTTPConfig) validateMultiparts() error { filePaths := make(map[string]bool) for idx := range c.Parts { if err := c.Parts[idx].validate(); err != nil { return err } if filePath := c.Parts[idx].Filepath; filePath != "" { if filePaths[filePath] { return util.NewI18nError(fmt.Errorf("filepath %q is duplicated", filePath), util.I18nErrorPathDuplicated) } filePaths[filePath] = true } } if len(c.Parts) > 0 { if c.Body != "" { return util.NewI18nError( util.NewValidationError("multipart requests require no body. The request body is build from the specified parts"), util.I18nErrorMultipartBody, ) } for _, k := range c.Headers { if strings.ToLower(k.Key) == "content-type" { return util.NewI18nError( util.NewValidationError("content type is automatically set for multipart requests"), util.I18nErrorMultipartCType, ) } } } return nil } func (c *EventActionHTTPConfig) validate(additionalData string) error { if c.Endpoint == "" { return util.NewI18nError(util.NewValidationError("HTTP endpoint is required"), util.I18nErrorURLRequired) } if !util.IsStringPrefixInSlice(c.Endpoint, []string{"http://", "https://"}) { return util.NewI18nError( util.NewValidationError("invalid HTTP endpoint schema: http and https are supported"), util.I18nErrorURLInvalid, ) } if c.isTimeoutNotValid() { return util.NewValidationError(fmt.Sprintf("invalid HTTP timeout %d", c.Timeout)) } for _, kv := range c.Headers { if kv.isNotValid() { return util.NewValidationError("invalid HTTP headers") } } if err := c.validateMultiparts(); err != nil { return err } if c.Password.IsRedacted() { return util.NewValidationError("cannot save HTTP configuration with a redacted secret") } if c.Password.IsPlain() { c.Password.SetAdditionalData(additionalData) err := c.Password.Encrypt() if err != nil { return util.NewValidationError(fmt.Sprintf("could not encrypt HTTP password: %v", err)) } } if !slices.Contains(SupportedHTTPActionMethods, c.Method) { return util.NewValidationError(fmt.Sprintf("unsupported HTTP method: %s", c.Method)) } for _, kv := range c.QueryParameters { if kv.isNotValid() { return util.NewValidationError("invalid HTTP query parameters") } } return nil } // GetContext returns the context and the cancel func to use for the HTTP request func (c *EventActionHTTPConfig) GetContext() (context.Context, context.CancelFunc) { if c.HasMultipartFiles() { return context.WithCancel(context.Background()) } return context.WithTimeout(context.Background(), time.Duration(c.Timeout)*time.Second) } // HasObjectData returns true if the {{ObjectData}} placeholder is defined func (c *EventActionHTTPConfig) HasObjectData() bool { if strings.Contains(c.Body, "{{ObjectData}}") || strings.Contains(c.Body, "{{ObjectDataString}}") { return true } for _, part := range c.Parts { if strings.Contains(part.Body, "{{ObjectData}}") || strings.Contains(part.Body, "{{ObjectDataString}}") { return true } } return false } // HasMultipartFiles returns true if at least a file must be uploaded via a multipart request func (c *EventActionHTTPConfig) HasMultipartFiles() bool { for _, part := range c.Parts { if part.Filepath != "" && part.Filepath != RetentionReportPlaceHolder { return true } } return false } // TryDecryptPassword decrypts the password if encryptet func (c *EventActionHTTPConfig) TryDecryptPassword() error { if c.Password != nil && !c.Password.IsEmpty() { if err := c.Password.TryDecrypt(); err != nil { return fmt.Errorf("unable to decrypt HTTP password: %w", err) } } return nil } // GetHTTPClient returns an HTTP client based on the config func (c *EventActionHTTPConfig) GetHTTPClient() *http.Client { client := &http.Client{} if c.SkipTLSVerify { transport := http.DefaultTransport.(*http.Transport).Clone() if transport.TLSClientConfig != nil { transport.TLSClientConfig.InsecureSkipVerify = true } else { transport.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, } } client.Transport = transport } return client } // IsActionCommandAllowed returns true if the specified command is allowed func IsActionCommandAllowed(cmd string) bool { return slices.Contains(EnabledActionCommands, cmd) } // EventActionCommandConfig defines the configuration for a command event target type EventActionCommandConfig struct { Cmd string `json:"cmd,omitempty"` Args []string `json:"args,omitempty"` Timeout int `json:"timeout,omitempty"` EnvVars []KeyValue `json:"env_vars,omitempty"` } func (c *EventActionCommandConfig) validate() error { if c.Cmd == "" { return util.NewI18nError(util.NewValidationError("command is required"), util.I18nErrorCommandRequired) } if !IsActionCommandAllowed(c.Cmd) { return util.NewValidationError(fmt.Sprintf("command %q is not allowed", c.Cmd)) } if !filepath.IsAbs(c.Cmd) { return util.NewI18nError( util.NewValidationError("invalid command, it must be an absolute path"), util.I18nErrorCommandInvalid, ) } if c.Timeout < 1 || c.Timeout > 120 { return util.NewValidationError(fmt.Sprintf("invalid command action timeout %d", c.Timeout)) } for _, kv := range c.EnvVars { if kv.isNotValid() { return util.NewValidationError("invalid command env vars") } } c.Args = util.RemoveDuplicates(c.Args, true) for _, arg := range c.Args { if arg == "" { return util.NewValidationError("invalid command args") } } return nil } // GetArgumentsAsString returns the list of command arguments as comma separated string func (c EventActionCommandConfig) GetArgumentsAsString() string { return strings.Join(c.Args, ",") } // EventActionEmailConfig defines the configuration options for SMTP event actions type EventActionEmailConfig struct { Recipients []string `json:"recipients,omitempty"` Bcc []string `json:"bcc,omitempty"` Subject string `json:"subject,omitempty"` Body string `json:"body,omitempty"` Attachments []string `json:"attachments,omitempty"` ContentType int `json:"content_type,omitempty"` } // GetRecipientsAsString returns the list of recipients as comma separated string func (c EventActionEmailConfig) GetRecipientsAsString() string { return strings.Join(c.Recipients, ",") } // GetBccAsString returns the list of bcc as comma separated string func (c EventActionEmailConfig) GetBccAsString() string { return strings.Join(c.Bcc, ",") } // GetAttachmentsAsString returns the list of attachments as comma separated string func (c EventActionEmailConfig) GetAttachmentsAsString() string { return strings.Join(c.Attachments, ",") } func (c *EventActionEmailConfig) hasFilesAttachments() bool { for _, a := range c.Attachments { if a != RetentionReportPlaceHolder { return true } } return false } func (c *EventActionEmailConfig) validate() error { if len(c.Recipients) == 0 { return util.NewI18nError( util.NewValidationError("at least one email recipient is required"), util.I18nErrorEmailRecipientRequired, ) } c.Recipients = util.RemoveDuplicates(c.Recipients, false) for _, r := range c.Recipients { if r == "" { return util.NewValidationError("invalid email recipients") } } c.Bcc = util.RemoveDuplicates(c.Bcc, false) for _, r := range c.Bcc { if r == "" { return util.NewValidationError("invalid email bcc") } } if c.Subject == "" { return util.NewI18nError( util.NewValidationError("email subject is required"), util.I18nErrorEmailSubjectRequired, ) } if c.Body == "" { return util.NewI18nError( util.NewValidationError("email body is required"), util.I18nErrorEmailBodyRequired, ) } if c.ContentType < 0 || c.ContentType > 1 { return util.NewValidationError("invalid email content type") } for idx, val := range c.Attachments { val = strings.TrimSpace(val) if val == "" { return util.NewValidationError("invalid path to attach") } if val == RetentionReportPlaceHolder { c.Attachments[idx] = val } else { c.Attachments[idx] = util.CleanPath(val) } } c.Attachments = util.RemoveDuplicates(c.Attachments, false) return nil } // FolderRetention defines a folder retention configuration type FolderRetention struct { // Path is the virtual directory path, if no other specific retention is defined, // the retention applies for sub directories too. For example if retention is defined // for the paths "/" and "/sub" then the retention for "/" is applied for any file outside // the "/sub" directory Path string `json:"path"` // Retention time in hours. 0 means exclude this path Retention int `json:"retention"` // DeleteEmptyDirs defines if empty directories will be deleted. // The user need the delete permission DeleteEmptyDirs bool `json:"delete_empty_dirs,omitempty"` } // Validate returns an error if the configuration is not valid func (f *FolderRetention) Validate() error { f.Path = util.CleanPath(f.Path) if f.Retention < 0 { return util.NewValidationError(fmt.Sprintf("invalid folder retention %v, it must be greater or equal to zero", f.Retention)) } return nil } // EventActionDataRetentionConfig defines the configuration for a data retention check type EventActionDataRetentionConfig struct { Folders []FolderRetention `json:"folders,omitempty"` } func (c *EventActionDataRetentionConfig) validate() error { folderPaths := make(map[string]bool) nothingToDo := true for idx := range c.Folders { f := &c.Folders[idx] if err := f.Validate(); err != nil { return err } if f.Retention > 0 { nothingToDo = false } if _, ok := folderPaths[f.Path]; ok { return util.NewI18nError( util.NewValidationError(fmt.Sprintf("duplicated folder path %q", f.Path)), util.I18nErrorPathDuplicated, ) } folderPaths[f.Path] = true } if nothingToDo { return util.NewI18nError( util.NewValidationError("nothing to delete!"), util.I18nErrorRetentionDirRequired, ) } return nil } // EventActionFsCompress defines the configuration for the compress filesystem action type EventActionFsCompress struct { // Archive path Name string `json:"name,omitempty"` // Paths to compress Paths []string `json:"paths,omitempty"` } func (c *EventActionFsCompress) validate() error { if c.Name == "" { return util.NewI18nError(util.NewValidationError("archive name is mandatory"), util.I18nErrorArchiveNameRequired) } c.Name = util.CleanPath(strings.TrimSpace(c.Name)) if c.Name == "/" { return util.NewI18nError(util.NewValidationError("invalid archive name"), util.I18nErrorRootNotAllowed) } if len(c.Paths) == 0 { return util.NewI18nError(util.NewValidationError("no path to compress specified"), util.I18nErrorPathRequired) } for idx, val := range c.Paths { val = strings.TrimSpace(val) if val == "" { return util.NewValidationError("invalid path to compress") } c.Paths[idx] = util.CleanPath(val) } c.Paths = util.RemoveDuplicates(c.Paths, false) return nil } // RenameConfig defines the configuration for a filesystem rename type RenameConfig struct { // key is the source and target the value KeyValue // This setting only applies to storage providers that support // changing modification times. UpdateModTime bool `json:"update_modtime,omitempty"` } // EventActionFilesystemConfig defines the configuration for filesystem actions type EventActionFilesystemConfig struct { // Filesystem actions, see the above enum Type int `json:"type,omitempty"` // files/dirs to rename Renames []RenameConfig `json:"renames,omitempty"` // directories to create MkDirs []string `json:"mkdirs,omitempty"` // files/dirs to delete Deletes []string `json:"deletes,omitempty"` // file/dirs to check for existence Exist []string `json:"exist,omitempty"` // files/dirs to copy, key is the source and target the value Copy []KeyValue `json:"copy,omitempty"` // paths to compress and archive name Compress EventActionFsCompress `json:"compress"` } // GetDeletesAsString returns the list of items to delete as comma separated string. // Using a pointer receiver will not work in web templates func (c EventActionFilesystemConfig) GetDeletesAsString() string { return strings.Join(c.Deletes, ",") } // GetMkDirsAsString returns the list of directories to create as comma separated string. // Using a pointer receiver will not work in web templates func (c EventActionFilesystemConfig) GetMkDirsAsString() string { return strings.Join(c.MkDirs, ",") } // GetExistAsString returns the list of items to check for existence as comma separated string. // Using a pointer receiver will not work in web templates func (c EventActionFilesystemConfig) GetExistAsString() string { return strings.Join(c.Exist, ",") } // GetCompressPathsAsString returns the list of items to compress as comma separated string. // Using a pointer receiver will not work in web templates func (c EventActionFilesystemConfig) GetCompressPathsAsString() string { return strings.Join(c.Compress.Paths, ",") } func (c *EventActionFilesystemConfig) validateRenames() error { if len(c.Renames) == 0 { return util.NewI18nError(util.NewValidationError("no path to rename specified"), util.I18nErrorPathRequired) } for idx, cfg := range c.Renames { key := strings.TrimSpace(cfg.Key) value := strings.TrimSpace(cfg.Value) if key == "" || value == "" { return util.NewValidationError("invalid paths to rename") } key = util.CleanPath(key) value = util.CleanPath(value) if key == value { return util.NewI18nError( util.NewValidationError("rename source and target cannot be equal"), util.I18nErrorSourceDestMatch, ) } if key == "/" || value == "/" { return util.NewI18nError( util.NewValidationError("renaming the root directory is not allowed"), util.I18nErrorRootNotAllowed, ) } c.Renames[idx] = RenameConfig{ KeyValue: KeyValue{ Key: key, Value: value, }, UpdateModTime: cfg.UpdateModTime, } } return nil } func (c *EventActionFilesystemConfig) validateCopy() error { if len(c.Copy) == 0 { return util.NewI18nError(util.NewValidationError("no path to copy specified"), util.I18nErrorPathRequired) } for idx, kv := range c.Copy { key := strings.TrimSpace(kv.Key) value := strings.TrimSpace(kv.Value) if key == "" || value == "" { return util.NewValidationError("invalid paths to copy") } key = util.CleanPath(key) value = util.CleanPath(value) if key == value { return util.NewI18nError( util.NewValidationError("copy source and target cannot be equal"), util.I18nErrorSourceDestMatch, ) } if key == "/" || value == "/" { return util.NewI18nError( util.NewValidationError("copying the root directory is not allowed"), util.I18nErrorRootNotAllowed, ) } if strings.HasSuffix(c.Copy[idx].Key, "/") { key += "/" } if strings.HasSuffix(c.Copy[idx].Value, "/") { value += "/" } c.Copy[idx] = KeyValue{ Key: key, Value: value, } } return nil } func (c *EventActionFilesystemConfig) validateDeletes() error { if len(c.Deletes) == 0 { return util.NewI18nError(util.NewValidationError("no path to delete specified"), util.I18nErrorPathRequired) } for idx, val := range c.Deletes { val = strings.TrimSpace(val) if val == "" { return util.NewValidationError("invalid path to delete") } c.Deletes[idx] = util.CleanPath(val) } c.Deletes = util.RemoveDuplicates(c.Deletes, false) return nil } func (c *EventActionFilesystemConfig) validateMkdirs() error { if len(c.MkDirs) == 0 { return util.NewI18nError(util.NewValidationError("no directory to create specified"), util.I18nErrorPathRequired) } for idx, val := range c.MkDirs { val = strings.TrimSpace(val) if val == "" { return util.NewValidationError("invalid directory to create") } c.MkDirs[idx] = util.CleanPath(val) } c.MkDirs = util.RemoveDuplicates(c.MkDirs, false) return nil } func (c *EventActionFilesystemConfig) validateExist() error { if len(c.Exist) == 0 { return util.NewI18nError(util.NewValidationError("no path to check for existence specified"), util.I18nErrorPathRequired) } for idx, val := range c.Exist { val = strings.TrimSpace(val) if val == "" { return util.NewValidationError("invalid path to check for existence") } c.Exist[idx] = util.CleanPath(val) } c.Exist = util.RemoveDuplicates(c.Exist, false) return nil } func (c *EventActionFilesystemConfig) validate() error { if !isFilesystemActionValid(c.Type) { return util.NewValidationError(fmt.Sprintf("invalid filesystem action type: %d", c.Type)) } switch c.Type { case FilesystemActionRename: c.MkDirs = nil c.Deletes = nil c.Exist = nil c.Copy = nil c.Compress = EventActionFsCompress{} if err := c.validateRenames(); err != nil { return err } case FilesystemActionDelete: c.Renames = nil c.MkDirs = nil c.Exist = nil c.Copy = nil c.Compress = EventActionFsCompress{} if err := c.validateDeletes(); err != nil { return err } case FilesystemActionMkdirs: c.Renames = nil c.Deletes = nil c.Exist = nil c.Copy = nil c.Compress = EventActionFsCompress{} if err := c.validateMkdirs(); err != nil { return err } case FilesystemActionExist: c.Renames = nil c.Deletes = nil c.MkDirs = nil c.Copy = nil c.Compress = EventActionFsCompress{} if err := c.validateExist(); err != nil { return err } case FilesystemActionCompress: c.Renames = nil c.MkDirs = nil c.Deletes = nil c.Exist = nil c.Copy = nil if err := c.Compress.validate(); err != nil { return err } case FilesystemActionCopy: c.Renames = nil c.Deletes = nil c.MkDirs = nil c.Exist = nil c.Compress = EventActionFsCompress{} if err := c.validateCopy(); err != nil { return err } } return nil } func (c *EventActionFilesystemConfig) getACopy() EventActionFilesystemConfig { mkdirs := make([]string, len(c.MkDirs)) copy(mkdirs, c.MkDirs) deletes := make([]string, len(c.Deletes)) copy(deletes, c.Deletes) exist := make([]string, len(c.Exist)) copy(exist, c.Exist) compressPaths := make([]string, len(c.Compress.Paths)) copy(compressPaths, c.Compress.Paths) return EventActionFilesystemConfig{ Type: c.Type, Renames: cloneRenameConfigs(c.Renames), MkDirs: mkdirs, Deletes: deletes, Exist: exist, Copy: cloneKeyValues(c.Copy), Compress: EventActionFsCompress{ Paths: compressPaths, Name: c.Compress.Name, }, } } // EventActionPasswordExpiration defines the configuration for password expiration actions type EventActionPasswordExpiration struct { // An email notification will be generated for users whose password expires in a number // of days less than or equal to this threshold Threshold int `json:"threshold,omitempty"` } func (c *EventActionPasswordExpiration) validate() error { if c.Threshold <= 0 { return util.NewValidationError("threshold must be greater than 0") } 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 Mode int `json:"mode,omitempty"` TemplateUser string `json:"template_user,omitempty"` TemplateAdmin string `json:"template_admin,omitempty"` } func (c *EventActionIDPAccountCheck) validate() error { if c.TemplateAdmin == "" && c.TemplateUser == "" { return util.NewI18nError( util.NewValidationError("at least a template must be set"), util.I18nErrorIDPTemplateRequired, ) } if c.Mode < 0 || c.Mode > 1 { return util.NewValidationError(fmt.Sprintf("invalid account check mode: %d", c.Mode)) } return nil } // 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"` UserInactivityConfig EventActionUserInactivity `json:"user_inactivity_config"` IDPConfig EventActionIDPAccountCheck `json:"idp_config"` } func (o *BaseEventActionOptions) getACopy() BaseEventActionOptions { o.SetEmptySecretsIfNil() emailRecipients := make([]string, len(o.EmailConfig.Recipients)) copy(emailRecipients, o.EmailConfig.Recipients) emailBcc := make([]string, len(o.EmailConfig.Bcc)) copy(emailBcc, o.EmailConfig.Bcc) emailAttachments := make([]string, len(o.EmailConfig.Attachments)) copy(emailAttachments, o.EmailConfig.Attachments) cmdArgs := make([]string, len(o.CmdConfig.Args)) copy(cmdArgs, o.CmdConfig.Args) folders := make([]FolderRetention, 0, len(o.RetentionConfig.Folders)) for _, folder := range o.RetentionConfig.Folders { folders = append(folders, FolderRetention{ Path: folder.Path, Retention: folder.Retention, DeleteEmptyDirs: folder.DeleteEmptyDirs, }) } httpParts := make([]HTTPPart, 0, len(o.HTTPConfig.Parts)) for _, part := range o.HTTPConfig.Parts { httpParts = append(httpParts, HTTPPart{ Name: part.Name, Filepath: part.Filepath, Headers: cloneKeyValues(part.Headers), Body: part.Body, }) } return BaseEventActionOptions{ HTTPConfig: EventActionHTTPConfig{ Endpoint: o.HTTPConfig.Endpoint, Username: o.HTTPConfig.Username, Password: o.HTTPConfig.Password.Clone(), Headers: cloneKeyValues(o.HTTPConfig.Headers), Timeout: o.HTTPConfig.Timeout, SkipTLSVerify: o.HTTPConfig.SkipTLSVerify, Method: o.HTTPConfig.Method, QueryParameters: cloneKeyValues(o.HTTPConfig.QueryParameters), Body: o.HTTPConfig.Body, Parts: httpParts, }, CmdConfig: EventActionCommandConfig{ Cmd: o.CmdConfig.Cmd, Args: cmdArgs, Timeout: o.CmdConfig.Timeout, EnvVars: cloneKeyValues(o.CmdConfig.EnvVars), }, EmailConfig: EventActionEmailConfig{ Recipients: emailRecipients, Bcc: emailBcc, Subject: o.EmailConfig.Subject, ContentType: o.EmailConfig.ContentType, Body: o.EmailConfig.Body, Attachments: emailAttachments, }, RetentionConfig: EventActionDataRetentionConfig{ Folders: folders, }, 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, TemplateAdmin: o.IDPConfig.TemplateAdmin, }, FsConfig: o.FsConfig.getACopy(), } } // SetEmptySecretsIfNil sets the secrets to empty if nil func (o *BaseEventActionOptions) SetEmptySecretsIfNil() { if o.HTTPConfig.Password == nil { o.HTTPConfig.Password = kms.NewEmptySecret() } } func (o *BaseEventActionOptions) setNilSecretsIfEmpty() { if o.HTTPConfig.Password != nil && o.HTTPConfig.Password.IsEmpty() { o.HTTPConfig.Password = nil } } func (o *BaseEventActionOptions) hideConfidentialData() { if o.HTTPConfig.Password != nil { o.HTTPConfig.Password.Hide() } } func (o *BaseEventActionOptions) validate(action int, name string) error { o.SetEmptySecretsIfNil() switch action { case ActionTypeHTTP: o.CmdConfig = EventActionCommandConfig{} o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} o.UserInactivityConfig = EventActionUserInactivity{} return o.HTTPConfig.validate(name) case ActionTypeCommand: o.HTTPConfig = EventActionHTTPConfig{} o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} o.UserInactivityConfig = EventActionUserInactivity{} return o.CmdConfig.validate() case ActionTypeEmail: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} o.UserInactivityConfig = EventActionUserInactivity{} return o.EmailConfig.validate() case ActionTypeDataRetentionCheck: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.EmailConfig = EventActionEmailConfig{} o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} o.UserInactivityConfig = EventActionUserInactivity{} return o.RetentionConfig.validate() case ActionTypeFilesystem: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} o.UserInactivityConfig = EventActionUserInactivity{} return o.FsConfig.validate() case ActionTypePasswordExpirationCheck: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.EmailConfig = EventActionEmailConfig{} 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{} o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.UserInactivityConfig = EventActionUserInactivity{} return o.IDPConfig.validate() default: o.HTTPConfig = EventActionHTTPConfig{} o.CmdConfig = EventActionCommandConfig{} o.EmailConfig = EventActionEmailConfig{} o.RetentionConfig = EventActionDataRetentionConfig{} o.FsConfig = EventActionFilesystemConfig{} o.PwdExpirationConfig = EventActionPasswordExpiration{} o.IDPConfig = EventActionIDPAccountCheck{} o.UserInactivityConfig = EventActionUserInactivity{} } return nil } // BaseEventAction defines the common fields for an event action type BaseEventAction struct { // Data provider unique identifier ID int64 `json:"id"` // Action name Name string `json:"name"` // optional description Description string `json:"description,omitempty"` // ActionType, see the above enum Type int `json:"type"` // Configuration options specific for the action type Options BaseEventActionOptions `json:"options"` // list of rule names associated with this event action Rules []string `json:"rules,omitempty"` } func (a *BaseEventAction) getACopy() BaseEventAction { rules := make([]string, len(a.Rules)) copy(rules, a.Rules) return BaseEventAction{ ID: a.ID, Name: a.Name, Description: a.Description, Type: a.Type, Options: a.Options.getACopy(), Rules: rules, } } // GetTypeAsString returns the action type as string func (a *BaseEventAction) GetTypeAsString() string { return getActionTypeAsString(a.Type) } // GetRulesAsString returns the list of rules as comma separated string func (a *BaseEventAction) GetRulesAsString() string { return strings.Join(a.Rules, ",") } // PrepareForRendering prepares a BaseEventAction for rendering. // It hides confidential data and set to nil the empty secrets // so they are not serialized func (a *BaseEventAction) PrepareForRendering() { a.Options.setNilSecretsIfEmpty() a.Options.hideConfidentialData() } // RenderAsJSON implements the renderer interface used within plugins func (a *BaseEventAction) RenderAsJSON(reload bool) ([]byte, error) { if reload { action, err := provider.eventActionExists(a.Name) if err != nil { providerLog(logger.LevelError, "unable to reload event action before rendering as json: %v", err) return nil, err } action.PrepareForRendering() return json.Marshal(action) } a.PrepareForRendering() return json.Marshal(a) } func (a *BaseEventAction) validate() error { if a.Name == "" { return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired) } if !isActionTypeValid(a.Type) { return util.NewValidationError(fmt.Sprintf("invalid action type: %d", a.Type)) } return a.Options.validate(a.Type, a.Name) } // EventActionOptions defines the supported configuration options for an event action type EventActionOptions struct { IsFailureAction bool `json:"is_failure_action"` StopOnFailure bool `json:"stop_on_failure"` ExecuteSync bool `json:"execute_sync"` } // EventAction defines an event action type EventAction struct { BaseEventAction // Order defines the execution order Order int `json:"order,omitempty"` Options EventActionOptions `json:"relation_options"` } func (a *EventAction) getACopy() EventAction { return EventAction{ BaseEventAction: a.BaseEventAction.getACopy(), Order: a.Order, Options: EventActionOptions{ IsFailureAction: a.Options.IsFailureAction, StopOnFailure: a.Options.StopOnFailure, ExecuteSync: a.Options.ExecuteSync, }, } } func (a *EventAction) validateAssociation(trigger int, fsEvents []string) error { if a.Options.IsFailureAction { if a.Options.ExecuteSync { return util.NewI18nError( util.NewValidationError("sync execution is not supported for failure actions"), util.I18nErrorEvSyncFailureActions, ) } } if a.Options.ExecuteSync { if trigger != EventTriggerFsEvent && trigger != EventTriggerIDPLogin { return util.NewI18nError( util.NewValidationError("sync execution is only supported for some filesystem events and Identity Provider logins"), util.I18nErrorEvSyncUnsupported, ) } if trigger == EventTriggerFsEvent { for _, ev := range fsEvents { if !slices.Contains(allowedSyncFsEvents, ev) { return util.NewI18nError( util.NewValidationError("sync execution is only supported for upload and pre-* events"), util.I18nErrorEvSyncUnsupportedFs, ) } } } } return nil } // ConditionPattern defines a pattern for condition filters type ConditionPattern struct { Pattern string `json:"pattern,omitempty"` InverseMatch bool `json:"inverse_match,omitempty"` } func (p *ConditionPattern) validate() error { if p.Pattern == "" { return util.NewValidationError("empty condition pattern not allowed") } _, err := path.Match(p.Pattern, "abc") if err != nil { return util.NewValidationError(fmt.Sprintf("invalid condition pattern %q", p.Pattern)) } return nil } // ConditionOptions defines options for event conditions type ConditionOptions struct { // Usernames or folder names Names []ConditionPattern `json:"names,omitempty"` // Group names GroupNames []ConditionPattern `json:"group_names,omitempty"` // Role names RoleNames []ConditionPattern `json:"role_names,omitempty"` // Virtual paths FsPaths []ConditionPattern `json:"fs_paths,omitempty"` Protocols []string `json:"protocols,omitempty"` ProviderObjects []string `json:"provider_objects,omitempty"` MinFileSize int64 `json:"min_size,omitempty"` MaxFileSize int64 `json:"max_size,omitempty"` EventStatuses []int `json:"event_statuses,omitempty"` // allow to execute scheduled tasks concurrently from multiple instances ConcurrentExecution bool `json:"concurrent_execution,omitempty"` } func (f *ConditionOptions) getACopy() ConditionOptions { protocols := make([]string, len(f.Protocols)) copy(protocols, f.Protocols) providerObjects := make([]string, len(f.ProviderObjects)) copy(providerObjects, f.ProviderObjects) statuses := make([]int, len(f.EventStatuses)) copy(statuses, f.EventStatuses) return ConditionOptions{ Names: cloneConditionPatterns(f.Names), GroupNames: cloneConditionPatterns(f.GroupNames), RoleNames: cloneConditionPatterns(f.RoleNames), FsPaths: cloneConditionPatterns(f.FsPaths), Protocols: protocols, ProviderObjects: providerObjects, MinFileSize: f.MinFileSize, MaxFileSize: f.MaxFileSize, EventStatuses: statuses, ConcurrentExecution: f.ConcurrentExecution, } } func (f *ConditionOptions) validateStatuses() error { for _, status := range f.EventStatuses { if status < 0 || status > 3 { return util.NewValidationError(fmt.Sprintf("invalid event_status %d", status)) } } return nil } func (f *ConditionOptions) validate() error { if err := validateConditionPatterns(f.Names); err != nil { return err } if err := validateConditionPatterns(f.GroupNames); err != nil { return err } if err := validateConditionPatterns(f.RoleNames); err != nil { return err } if err := validateConditionPatterns(f.FsPaths); err != nil { return err } for _, p := range f.Protocols { if !slices.Contains(SupportedRuleConditionProtocols, p) { return util.NewValidationError(fmt.Sprintf("unsupported rule condition protocol: %q", p)) } } for _, p := range f.ProviderObjects { if !slices.Contains(SupporteRuleConditionProviderObjects, p) { return util.NewValidationError(fmt.Sprintf("unsupported provider object: %q", p)) } } if f.MinFileSize > 0 && f.MaxFileSize > 0 { if f.MaxFileSize <= f.MinFileSize { return util.NewValidationError(fmt.Sprintf("invalid max file size %s, it is lesser or equal than min file size %s", util.ByteCountSI(f.MaxFileSize), util.ByteCountSI(f.MinFileSize))) } } if err := f.validateStatuses(); err != nil { return err } if config.IsShared == 0 { f.ConcurrentExecution = false } return nil } // Schedule defines an event schedule type Schedule struct { Hours string `json:"hour"` DayOfWeek string `json:"day_of_week"` DayOfMonth string `json:"day_of_month"` Month string `json:"month"` } // GetCronSpec returns the cron compatible schedule string func (s *Schedule) GetCronSpec() string { return fmt.Sprintf("0 %s %s %s %s", s.Hours, s.DayOfMonth, s.Month, s.DayOfWeek) } func (s *Schedule) validate() error { _, err := cron.ParseStandard(s.GetCronSpec()) if err != nil { return util.NewValidationError(fmt.Sprintf("invalid schedule, hour: %q, day of month: %q, month: %q, day of week: %q", s.Hours, s.DayOfMonth, s.Month, s.DayOfWeek)) } return nil } // EventConditions defines the conditions for an event rule type EventConditions struct { // Only one between FsEvents, ProviderEvents and Schedule is allowed FsEvents []string `json:"fs_events,omitempty"` ProviderEvents []string `json:"provider_events,omitempty"` Schedules []Schedule `json:"schedules,omitempty"` // 0 any, 1 user, 2 admin IDPLoginEvent int `json:"idp_login_event,omitempty"` Options ConditionOptions `json:"options"` } func (c *EventConditions) getACopy() EventConditions { fsEvents := make([]string, len(c.FsEvents)) copy(fsEvents, c.FsEvents) providerEvents := make([]string, len(c.ProviderEvents)) copy(providerEvents, c.ProviderEvents) schedules := make([]Schedule, 0, len(c.Schedules)) for _, schedule := range c.Schedules { schedules = append(schedules, Schedule{ Hours: schedule.Hours, DayOfWeek: schedule.DayOfWeek, DayOfMonth: schedule.DayOfMonth, Month: schedule.Month, }) } return EventConditions{ FsEvents: fsEvents, ProviderEvents: providerEvents, Schedules: schedules, IDPLoginEvent: c.IDPLoginEvent, Options: c.Options.getACopy(), } } func (c *EventConditions) validateSchedules() error { if len(c.Schedules) == 0 { return util.NewI18nError( util.NewValidationError("at least one schedule is required"), util.I18nErrorRuleScheduleRequired, ) } for _, schedule := range c.Schedules { if err := schedule.validate(); err != nil { return util.NewI18nError(err, util.I18nErrorRuleScheduleInvalid) } } return nil } func (c *EventConditions) validate(trigger int) error { switch trigger { case EventTriggerFsEvent: c.ProviderEvents = nil c.Schedules = nil c.Options.ProviderObjects = nil c.IDPLoginEvent = 0 if len(c.FsEvents) == 0 { return util.NewI18nError( util.NewValidationError("at least one filesystem event is required"), util.I18nErrorRuleFsEventRequired, ) } for _, ev := range c.FsEvents { if !slices.Contains(SupportedFsEvents, ev) { return util.NewValidationError(fmt.Sprintf("unsupported fs event: %q", ev)) } } case EventTriggerProviderEvent: c.FsEvents = nil c.Schedules = nil c.Options.FsPaths = nil c.Options.Protocols = nil c.Options.EventStatuses = nil c.Options.MinFileSize = 0 c.Options.MaxFileSize = 0 c.IDPLoginEvent = 0 if len(c.ProviderEvents) == 0 { return util.NewI18nError( util.NewValidationError("at least one provider event is required"), util.I18nErrorRuleProviderEventRequired, ) } for _, ev := range c.ProviderEvents { if !slices.Contains(SupportedProviderEvents, ev) { return util.NewValidationError(fmt.Sprintf("unsupported provider event: %q", ev)) } } case EventTriggerSchedule: c.FsEvents = nil c.ProviderEvents = nil c.Options.FsPaths = nil c.Options.Protocols = nil c.Options.EventStatuses = nil c.Options.MinFileSize = 0 c.Options.MaxFileSize = 0 c.Options.ProviderObjects = nil c.IDPLoginEvent = 0 if err := c.validateSchedules(); err != nil { return err } case EventTriggerIPBlocked, EventTriggerCertificate: c.FsEvents = nil c.ProviderEvents = nil c.Options.Names = nil c.Options.GroupNames = nil c.Options.RoleNames = nil c.Options.FsPaths = nil c.Options.Protocols = nil c.Options.EventStatuses = nil c.Options.MinFileSize = 0 c.Options.MaxFileSize = 0 c.Schedules = nil c.IDPLoginEvent = 0 case EventTriggerOnDemand: c.FsEvents = nil c.ProviderEvents = nil c.Options.FsPaths = nil c.Options.Protocols = nil c.Options.EventStatuses = nil c.Options.MinFileSize = 0 c.Options.MaxFileSize = 0 c.Options.ProviderObjects = nil c.Schedules = nil c.IDPLoginEvent = 0 c.Options.ConcurrentExecution = false case EventTriggerIDPLogin: c.FsEvents = nil c.ProviderEvents = nil c.Options.GroupNames = nil c.Options.RoleNames = nil c.Options.FsPaths = nil c.Options.Protocols = nil c.Options.EventStatuses = nil c.Options.MinFileSize = 0 c.Options.MaxFileSize = 0 c.Schedules = nil if !slices.Contains(supportedIDPLoginEvents, c.IDPLoginEvent) { return util.NewValidationError(fmt.Sprintf("invalid Identity Provider login event %d", c.IDPLoginEvent)) } default: c.FsEvents = nil c.ProviderEvents = nil c.Options.GroupNames = nil c.Options.RoleNames = nil c.Options.FsPaths = nil c.Options.Protocols = nil c.Options.EventStatuses = nil c.Options.MinFileSize = 0 c.Options.MaxFileSize = 0 c.Schedules = nil c.IDPLoginEvent = 0 } return c.Options.validate() } // EventRule defines the trigger, conditions and actions for an event type EventRule struct { // Data provider unique identifier ID int64 `json:"id"` // Rule name Name string `json:"name"` // 1 enabled, 0 disabled Status int `json:"status"` // optional description Description string `json:"description,omitempty"` // Creation time as unix timestamp in milliseconds CreatedAt int64 `json:"created_at"` // last update time as unix timestamp in milliseconds UpdatedAt int64 `json:"updated_at"` // Event trigger Trigger int `json:"trigger"` // Event conditions Conditions EventConditions `json:"conditions"` // actions to execute Actions []EventAction `json:"actions"` // in multi node setups we mark the rule as deleted to be able to update the cache DeletedAt int64 `json:"-"` } func (r *EventRule) getACopy() EventRule { actions := make([]EventAction, 0, len(r.Actions)) for _, action := range r.Actions { actions = append(actions, action.getACopy()) } return EventRule{ ID: r.ID, Name: r.Name, Status: r.Status, Description: r.Description, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, Trigger: r.Trigger, Conditions: r.Conditions.getACopy(), Actions: actions, DeletedAt: r.DeletedAt, } } // GuardFromConcurrentExecution returns true if the rule cannot be executed concurrently // from multiple instances func (r *EventRule) GuardFromConcurrentExecution() bool { if config.IsShared == 0 { return false } return !r.Conditions.Options.ConcurrentExecution } // GetTriggerAsString returns the rule trigger as string func (r *EventRule) GetTriggerAsString() string { return getTriggerTypeAsString(r.Trigger) } // GetActionsAsString returns the list of action names as comma separated string func (r *EventRule) GetActionsAsString() string { actions := make([]string, 0, len(r.Actions)) for _, action := range r.Actions { actions = append(actions, action.Name) } return strings.Join(actions, ",") } func (r *EventRule) isStatusValid() bool { return r.Status >= 0 && r.Status <= 1 } func (r *EventRule) validate() error { if r.Name == "" { return util.NewI18nError(util.NewValidationError("name is mandatory"), util.I18nErrorNameRequired) } if !r.isStatusValid() { return util.NewValidationError(fmt.Sprintf("invalid event rule status: %d", r.Status)) } if !isEventTriggerValid(r.Trigger) { return util.NewValidationError(fmt.Sprintf("invalid event rule trigger: %d", r.Trigger)) } if err := r.Conditions.validate(r.Trigger); err != nil { return err } if len(r.Actions) == 0 { return util.NewI18nError(util.NewValidationError("at least one action is required"), util.I18nErrorRuleActionRequired) } actionNames := make(map[string]bool) actionOrders := make(map[int]bool) failureActions := 0 hasSyncAction := false for idx := range r.Actions { if r.Actions[idx].Name == "" { return util.NewValidationError(fmt.Sprintf("invalid action at position %d, name not specified", idx)) } if actionNames[r.Actions[idx].Name] { return util.NewI18nError( util.NewValidationError(fmt.Sprintf("duplicated action %q", r.Actions[idx].Name)), util.I18nErrorRuleDuplicateActions, ) } if actionOrders[r.Actions[idx].Order] { return util.NewValidationError(fmt.Sprintf("duplicated order %d for action %q", r.Actions[idx].Order, r.Actions[idx].Name)) } if err := r.Actions[idx].validateAssociation(r.Trigger, r.Conditions.FsEvents); err != nil { return err } if r.Actions[idx].Options.IsFailureAction { failureActions++ } if r.Actions[idx].Options.ExecuteSync { hasSyncAction = true } actionNames[r.Actions[idx].Name] = true actionOrders[r.Actions[idx].Order] = true } if len(r.Actions) == failureActions { return util.NewI18nError( util.NewValidationError("at least a non-failure action is required"), util.I18nErrorRuleFailureActionsOnly, ) } if !hasSyncAction { return r.validateMandatorySyncActions() } return nil } func (r *EventRule) validateMandatorySyncActions() error { if r.Trigger != EventTriggerFsEvent { return nil } for _, ev := range r.Conditions.FsEvents { if slices.Contains(mandatorySyncFsEvents, ev) { return util.NewI18nError( util.NewValidationError(fmt.Sprintf("event %q requires at least a sync action", ev)), util.I18nErrorRuleSyncActionRequired, util.I18nErrorArgs(map[string]any{ "val": ev, }), ) } } return nil } func (r *EventRule) checkIPBlockedAndCertificateActions() error { unavailableActions := []int{ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset, ActionTypeDataRetentionCheck, ActionTypeFilesystem, ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck} for _, action := range r.Actions { if slices.Contains(unavailableActions, action.Type) { return fmt.Errorf("action %q, type %q is not supported for event trigger %q", action.Name, getActionTypeAsString(action.Type), getTriggerTypeAsString(r.Trigger)) } } return nil } func (r *EventRule) checkProviderEventActions(providerObjectType string) error { // user quota reset, transfer quota reset, data retention check and filesystem actions // 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, ActionTypeFilesystem, ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck} for _, action := range r.Actions { if slices.Contains(userSpecificActions, action.Type) && providerObjectType != actionObjectUser { return fmt.Errorf("action %q, type %q is only supported for provider user events", action.Name, getActionTypeAsString(action.Type)) } if action.Type == ActionTypeFolderQuotaReset && providerObjectType != actionObjectFolder { return fmt.Errorf("action %q, type %q is only supported for provider folder events", action.Name, getActionTypeAsString(action.Type)) } } return nil } func (r *EventRule) hasUserAssociated(providerObjectType string) bool { switch r.Trigger { case EventTriggerProviderEvent: return providerObjectType == actionObjectUser case EventTriggerFsEvent: return true default: if len(r.Actions) > 0 { // should we allow schedules where backup is not the first action? // maybe we could pass the action index and check before that index return r.Actions[0].Type == ActionTypeBackup } } return false } func (r *EventRule) checkActions(providerObjectType string) error { numSyncAction := 0 hasIDPAccountCheck := false for _, action := range r.Actions { if action.Options.ExecuteSync { numSyncAction++ } if action.Type == ActionTypeEmail && action.BaseEventAction.Options.EmailConfig.hasFilesAttachments() { if !r.hasUserAssociated(providerObjectType) { return errors.New("cannot send an email with attachments for a rule with no user associated") } } if action.Type == ActionTypeHTTP && action.BaseEventAction.Options.HTTPConfig.HasMultipartFiles() { if !r.hasUserAssociated(providerObjectType) { return errors.New("cannot upload file/s for a rule with no user associated") } } if action.Type == ActionTypeIDPAccountCheck { if r.Trigger != EventTriggerIDPLogin { return errors.New("IDP account check action is only supported for IDP login trigger") } if !action.Options.ExecuteSync { return errors.New("IDP account check must be a sync action") } hasIDPAccountCheck = true } } if hasIDPAccountCheck && numSyncAction != 1 { return errors.New("IDP account check must be the only sync action") } return nil } // CheckActionsConsistency returns an error if the actions cannot be executed func (r *EventRule) CheckActionsConsistency(providerObjectType string) error { switch r.Trigger { case EventTriggerProviderEvent: if err := r.checkProviderEventActions(providerObjectType); err != nil { return err } case EventTriggerFsEvent: // folder quota reset cannot be executed for _, action := range r.Actions { if action.Type == ActionTypeFolderQuotaReset { return fmt.Errorf("action %q, type %q is not supported for filesystem events", action.Name, getActionTypeAsString(action.Type)) } } case EventTriggerIPBlocked, EventTriggerCertificate: if err := r.checkIPBlockedAndCertificateActions(); err != nil { return err } } return r.checkActions(providerObjectType) } // PrepareForRendering prepares an EventRule for rendering. // It hides confidential data and set to nil the empty secrets // so they are not serialized func (r *EventRule) PrepareForRendering() { for idx := range r.Actions { r.Actions[idx].PrepareForRendering() } } // RenderAsJSON implements the renderer interface used within plugins func (r *EventRule) RenderAsJSON(reload bool) ([]byte, error) { if reload { rule, err := provider.eventRuleExists(r.Name) if err != nil { providerLog(logger.LevelError, "unable to reload event rule before rendering as json: %v", err) return nil, err } rule.PrepareForRendering() return json.Marshal(rule) } r.PrepareForRendering() return json.Marshal(r) } func cloneRenameConfigs(renames []RenameConfig) []RenameConfig { res := make([]RenameConfig, 0, len(renames)) for _, c := range renames { res = append(res, RenameConfig{ KeyValue: KeyValue{ Key: c.Key, Value: c.Value, }, UpdateModTime: c.UpdateModTime, }) } return res } func cloneKeyValues(keyVals []KeyValue) []KeyValue { res := make([]KeyValue, 0, len(keyVals)) for _, kv := range keyVals { res = append(res, KeyValue{ Key: kv.Key, Value: kv.Value, }) } return res } func cloneConditionPatterns(patterns []ConditionPattern) []ConditionPattern { res := make([]ConditionPattern, 0, len(patterns)) for _, p := range patterns { res = append(res, ConditionPattern{ Pattern: p.Pattern, InverseMatch: p.InverseMatch, }) } return res } func validateConditionPatterns(patterns []ConditionPattern) error { for _, name := range patterns { if err := name.validate(); err != nil { return err } } return nil } // Task stores the state for a scheduled task type Task struct { Name string `json:"name"` UpdateAt int64 `json:"updated_at"` Version int64 `json:"version"` }