From 36b17bf99ddd35c0c223722f8b6f4f1c71b2235e Mon Sep 17 00:00:00 2001 From: =Corey Hulen Date: Mon, 14 Mar 2016 16:07:58 -0700 Subject: [PATCH] PLT-2115 Adding compliance --- api/admin.go | 112 ++++- api/admin_test.go | 3 +- api/license.go | 20 + config/config.json | 34 +- einterfaces/compliance.go | 2 +- i18n/en.json | 37 +- mattermost.go | 22 +- model/client.go | 36 ++ model/compliance.go | 132 ++++++ model/compliance_post.go | 18 +- model/compliance_test.go | 19 + model/config.go | 46 ++- model/license.go | 12 +- store/sql_audit_store.go | 15 +- store/sql_compliance_store.go | 234 +++++++++++ store/sql_compliance_store_test.go | 210 ++++++++++ store/sql_post_store.go | 56 --- store/sql_post_store_test.go | 76 ---- store/sql_store.go | 8 + store/store.go | 10 +- utils/config.go | 2 + utils/html.go | 11 +- utils/i18n.go | 6 +- utils/license.go | 1 + .../admin_console/admin_controller.jsx | 3 + .../admin_console/admin_sidebar.jsx | 17 + web/react/components/admin_console/audits.jsx | 55 +-- .../admin_console/compliance_reports.jsx | 384 ++++++++++++++++++ .../admin_console/compliance_settings.jsx | 271 ++++++++++++ web/react/components/audit_table.jsx | 9 +- web/react/stores/admin_store.jsx | 30 ++ web/react/utils/async_client.jsx | 26 ++ web/react/utils/client.jsx | 29 ++ web/react/utils/constants.jsx | 1 + .../sass/partials/_admin-console.scss | 20 + web/static/i18n/en.json | 35 ++ 36 files changed, 1791 insertions(+), 211 deletions(-) create mode 100644 model/compliance.go create mode 100644 model/compliance_test.go create mode 100644 store/sql_compliance_store.go create mode 100644 store/sql_compliance_store_test.go create mode 100644 web/react/components/admin_console/compliance_reports.jsx create mode 100644 web/react/components/admin_console/compliance_settings.jsx diff --git a/api/admin.go b/api/admin.go index feb70aae3e..9de9f5dd89 100644 --- a/api/admin.go +++ b/api/admin.go @@ -5,15 +5,18 @@ package api import ( "bufio" + "io/ioutil" "net/http" "os" + "strconv" "strings" l4g "github.com/alecthomas/log4go" "github.com/gorilla/mux" - + "github.com/mattermost/platform/einterfaces" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" + "github.com/mssola/user_agent" ) func InitAdmin(r *mux.Router) { @@ -27,8 +30,11 @@ func InitAdmin(r *mux.Router) { sr.Handle("/test_email", ApiUserRequired(testEmail)).Methods("POST") sr.Handle("/client_props", ApiAppHandler(getClientConfig)).Methods("GET") sr.Handle("/log_client", ApiAppHandler(logClient)).Methods("POST") - sr.Handle("/analytics/{id:[A-Za-z0-9]+}/{name:[A-Za-z0-9_]+}", ApiAppHandler(getAnalytics)).Methods("GET") - sr.Handle("/analytics/{name:[A-Za-z0-9_]+}", ApiAppHandler(getAnalytics)).Methods("GET") + sr.Handle("/analytics/{id:[A-Za-z0-9]+}/{name:[A-Za-z0-9_]+}", ApiUserRequired(getAnalytics)).Methods("GET") + sr.Handle("/analytics/{name:[A-Za-z0-9_]+}", ApiUserRequired(getAnalytics)).Methods("GET") + sr.Handle("/save_compliance_report", ApiUserRequired(saveComplianceReport)).Methods("POST") + sr.Handle("/compliance_reports", ApiUserRequired(getComplianceReports)).Methods("GET") + sr.Handle("/download_compliance_report/{id:[A-Za-z0-9]+}", ApiUserRequired(downloadComplianceReport)).Methods("GET") } func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { @@ -142,6 +148,8 @@ func saveConfig(c *Context, w http.ResponseWriter, r *http.Request) { return } + c.LogAudit("") + utils.SaveConfig(utils.CfgFileName, cfg) utils.LoadConfig(utils.CfgFileName) json := utils.Cfg.ToJson() @@ -174,6 +182,104 @@ func testEmail(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(m))) } +func getComplianceReports(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("getComplianceReports") { + return + } + + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance { + c.Err = model.NewLocAppError("getComplianceReports", "ent.compliance.licence_disable.app_error", nil, "") + return + } + + if result := <-Srv.Store.Compliance().GetAll(); result.Err != nil { + c.Err = result.Err + return + } else { + crs := result.Data.(model.Compliances) + w.Write([]byte(crs.ToJson())) + } +} + +func saveComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("getComplianceReports") { + return + } + + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance || einterfaces.GetComplianceInterface() == nil { + c.Err = model.NewLocAppError("saveComplianceReport", "ent.compliance.licence_disable.app_error", nil, "") + return + } + + job := model.ComplianceFromJson(r.Body) + if job == nil { + c.SetInvalidParam("saveComplianceReport", "compliance") + return + } + + job.UserId = c.Session.UserId + job.Type = model.COMPLIANCE_TYPE_ADHOC + + if result := <-Srv.Store.Compliance().Save(job); result.Err != nil { + c.Err = result.Err + return + } else { + job = result.Data.(*model.Compliance) + go einterfaces.GetComplianceInterface().RunComplianceJob(job) + } + + w.Write([]byte(job.ToJson())) +} + +func downloadComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.HasSystemAdminPermissions("downloadComplianceReport") { + return + } + + if !*utils.Cfg.ComplianceSettings.Enable || !utils.IsLicensed || !*utils.License.Features.Compliance || einterfaces.GetComplianceInterface() == nil { + c.Err = model.NewLocAppError("downloadComplianceReport", "ent.compliance.licence_disable.app_error", nil, "") + return + } + + params := mux.Vars(r) + + id := params["id"] + if len(id) != 26 { + c.SetInvalidParam("downloadComplianceReport", "id") + return + } + + if result := <-Srv.Store.Compliance().Get(id); result.Err != nil { + c.Err = result.Err + return + } else { + job := result.Data.(*model.Compliance) + c.LogAudit("downloaded " + job.JobName()) + + if f, err := ioutil.ReadFile(*utils.Cfg.ComplianceSettings.Directory + "compliance/" + job.JobName() + ".zip"); err != nil { + c.Err = model.NewLocAppError("readFile", "api.file.read_file.reading_local.app_error", nil, err.Error()) + return + } else { + w.Header().Set("Cache-Control", "max-age=2592000, public") + w.Header().Set("Content-Length", strconv.Itoa(len(f))) + w.Header().Del("Content-Type") // Content-Type will be set automatically by the http writer + + // attach extra headers to trigger a download on IE, Edge, and Safari + ua := user_agent.New(r.UserAgent()) + bname, _ := ua.Browser() + + w.Header().Set("Content-Disposition", "attachment;filename=\""+job.JobName()+".zip\"") + + if bname == "Edge" || bname == "Internet Explorer" || bname == "Safari" { + // trim off anything before the final / so we just get the file's name + w.Header().Set("Content-Type", "application/octet-stream") + } + + w.Write(f) + } + } +} + func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { if !c.HasSystemAdminPermissions("getAnalytics") { return diff --git a/api/admin_test.go b/api/admin_test.go index bdea0bc5b5..67bc1d38b4 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -4,11 +4,10 @@ package api import ( - "testing" - "github.com/mattermost/platform/model" "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" + "testing" ) func TestGetLogs(t *testing.T) { diff --git a/api/license.go b/api/license.go index 542b45e266..ed0771d174 100644 --- a/api/license.go +++ b/api/license.go @@ -23,6 +23,26 @@ func InitLicense(r *mux.Router) { sr.Handle("/client_config", ApiAppHandler(getClientLicenceConfig)).Methods("GET") } +func LoadLicense() { + licenseId := "" + if result := <-Srv.Store.System().Get(); result.Err == nil { + props := result.Data.(model.StringMap) + licenseId = props[model.SYSTEM_ACTIVE_LICENSE_ID] + } + + if len(licenseId) != 26 { + l4g.Warn(utils.T("mattermost.load_license.find.warn")) + return + } + + if result := <-Srv.Store.License().Get(licenseId); result.Err == nil { + record := result.Data.(*model.LicenseRecord) + utils.LoadLicense([]byte(record.Bytes)) + } else { + l4g.Warn(utils.T("mattermost.load_license.find.warn")) + } +} + func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAudit("attempt") err := r.ParseMultipartForm(model.MAX_FILE_SIZE) diff --git a/config/config.json b/config/config.json index b211b16d31..5ed5d61bc4 100644 --- a/config/config.json +++ b/config/config.json @@ -19,7 +19,9 @@ "SessionLengthWebInDays": 30, "SessionLengthMobileInDays": 30, "SessionLengthSSOInDays": 30, - "SessionCacheInMinutes": 10 + "SessionCacheInMinutes": 10, + "WebsocketSecurePort": 443, + "WebsocketPort": 80 }, "TeamSettings": { "SiteName": "Mattermost", @@ -113,5 +115,33 @@ "AuthEndpoint": "", "TokenEndpoint": "", "UserApiEndpoint": "" + }, + "GoogleSettings": { + "Enable": false, + "Secret": "", + "Id": "", + "Scope": "", + "AuthEndpoint": "", + "TokenEndpoint": "", + "UserApiEndpoint": "" + }, + "LdapSettings": { + "Enable": false, + "LdapServer": "", + "LdapPort": 389, + "BaseDN": "", + "BindUsername": "", + "BindPassword": "", + "FirstNameAttribute": "", + "LastNameAttribute": "", + "EmailAttribute": "", + "UsernameAttribute": "", + "IdAttribute": "", + "QueryTimeout": 60 + }, + "ComplianceSettings": { + "Enable": true, + "Directory": "./data/", + "EnableDaily": false } -} +} \ No newline at end of file diff --git a/einterfaces/compliance.go b/einterfaces/compliance.go index cd43152da4..2e72c67d32 100644 --- a/einterfaces/compliance.go +++ b/einterfaces/compliance.go @@ -9,7 +9,7 @@ import ( type ComplianceInterface interface { StartComplianceDailyJob() - RunComplianceJob(jobName string, dir string, filename string, startTime int64, endTime int64) *model.AppError + RunComplianceJob(job *model.Compliance) *model.AppError } var theComplianceInterface ComplianceInterface diff --git a/i18n/en.json b/i18n/en.json index e42ade162c..fb4cf50b4d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1811,7 +1811,10 @@ "id": "ent.compliance.run_finished.info", "translation": "Compliance export finished for job '{{.JobName}}' exported {{.Count}} records to '{{.FilePath}}'" }, - + { + "id": "ent.compliance.licence_disable.app_error", + "translation": "Compliance functionality disabled by current license. Please contact your system administrator about upgrading your enterprise license." + }, { "id": "mattermost.security_checks.debug", "translation": "Checking for security update from Mattermost" @@ -2232,6 +2235,30 @@ "id": "model.post.is_valid.user_id.app_error", "translation": "Invalid user id" }, + { + "id": "model.compliance.is_valid.id.app_error", + "translation": "Invalid Id" + }, + { + "id": "model.compliance.is_valid.create_at.app_error", + "translation": "Create at must be a valid time" + }, + { + "id": "model.compliance.is_valid.desc.app_error", + "translation": "Invalid description" + }, + { + "id": "model.compliance.is_valid.start_at.app_error", + "translation": "From must be a valid time" + }, + { + "id": "model.compliance.is_valid.end_at.app_error", + "translation": "To must be a valid time" + }, + { + "id": "model.compliance.is_valid.start_end_at.app_error", + "translation": "To must be greater than From" + }, { "id": "model.preference.is_valid.category.app_error", "translation": "Invalid category" @@ -2508,6 +2535,14 @@ "id": "store.sql_audit.save.saving.app_error", "translation": "We encountered an error saving the audit" }, + { + "id": "store.sql_compliance.save.saving.app_error", + "translation": "We encountered an error saving the compliance report" + }, + { + "id": "store.sql_compliance.get.finding.app_error", + "translation": "We encountered an error retrieving the compliance reports" + }, { "id": "store.sql_channel.analytics_type_count.app_error", "translation": "We couldn't get channel type counts" diff --git a/mattermost.go b/mattermost.go index de97d36a20..c555862e9e 100644 --- a/mattermost.go +++ b/mattermost.go @@ -70,7 +70,7 @@ func main() { web.InitWeb() if model.BuildEnterpriseReady == "true" { - loadLicense() + api.LoadLicense() } if !utils.IsLicensed && len(utils.Cfg.SqlSettings.DataSourceReplicas) > 1 { @@ -106,26 +106,6 @@ func main() { } } -func loadLicense() { - licenseId := "" - if result := <-api.Srv.Store.System().Get(); result.Err == nil { - props := result.Data.(model.StringMap) - licenseId = props[model.SYSTEM_ACTIVE_LICENSE_ID] - } - - if len(licenseId) != 26 { - l4g.Warn(utils.T("mattermost.load_license.find.warn")) - return - } - - if result := <-api.Srv.Store.License().Get(licenseId); result.Err == nil { - record := result.Data.(*model.LicenseRecord) - utils.LoadLicense([]byte(record.Bytes)) - } else { - l4g.Warn(utils.T("mattermost.load_license.find.warn")) - } -} - func setDiagnosticId() { if result := <-api.Srv.Store.System().Get(); result.Err == nil { props := result.Data.(model.StringMap) diff --git a/model/client.go b/model/client.go index 3adcb980d7..f5c8ad6417 100644 --- a/model/client.go +++ b/model/client.go @@ -471,6 +471,42 @@ func (c *Client) TestEmail(config *Config) (*Result, *AppError) { } } +func (c *Client) GetComplianceReports() (*Result, *AppError) { + if r, err := c.DoApiGet("/admin/compliance_reports", "", ""); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), CompliancesFromJson(r.Body)}, nil + } +} + +func (c *Client) SaveComplianceReport(job *Compliance) (*Result, *AppError) { + if r, err := c.DoApiPost("/admin/save_compliance_report", job.ToJson()); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), ComplianceFromJson(r.Body)}, nil + } +} + +func (c *Client) DownloadComplianceReport(id string) (*Result, *AppError) { + var rq *http.Request + rq, _ = http.NewRequest("GET", c.ApiUrl+"/admin/download_compliance_report/"+id, nil) + + if len(c.AuthToken) > 0 { + rq.Header.Set(HEADER_AUTH, "BEARER "+c.AuthToken) + } + + if rp, err := c.HttpClient.Do(rq); err != nil { + return nil, NewLocAppError("/admin/download_compliance_report", "model.client.connecting.app_error", nil, err.Error()) + } else if rp.StatusCode >= 300 { + return nil, AppErrorFromJson(rp.Body) + } else { + return &Result{rp.Header.Get(HEADER_REQUEST_ID), + rp.Header.Get(HEADER_ETAG_SERVER), rp.Body}, nil + } +} + func (c *Client) GetTeamAnalytics(teamId, name string) (*Result, *AppError) { if r, err := c.DoApiGet("/admin/analytics/"+teamId+"/"+name, "", ""); err != nil { return nil, err diff --git a/model/compliance.go b/model/compliance.go new file mode 100644 index 0000000000..4a96a597a8 --- /dev/null +++ b/model/compliance.go @@ -0,0 +1,132 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" + "strings" +) + +const ( + COMPLIANCE_STATUS_CREATED = "created" + COMPLIANCE_STATUS_RUNNING = "running" + COMPLIANCE_STATUS_FINISHED = "finished" + COMPLIANCE_STATUS_FAILED = "failed" + COMPLIANCE_STATUS_REMOVED = "removed" + + COMPLIANCE_TYPE_DAILY = "daily" + COMPLIANCE_TYPE_ADHOC = "adhoc" +) + +type Compliance struct { + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + UserId string `json:"user_id"` + Status string `json:"status"` + Count int `json:"count"` + Desc string `json:"desc"` + Type string `json:"type"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` + Keywords string `json:"keywords"` + Emails string `json:"emails"` +} + +type Compliances []Compliance + +func (o *Compliance) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func (me *Compliance) PreSave() { + if me.Id == "" { + me.Id = NewId() + } + + if me.Status == "" { + me.Status = COMPLIANCE_STATUS_CREATED + } + + me.Count = 0 + me.Emails = strings.ToLower(me.Emails) + me.Keywords = strings.ToLower(me.Keywords) + + me.CreateAt = GetMillis() +} + +func (me *Compliance) JobName() string { + jobName := me.Type + if me.Type == COMPLIANCE_TYPE_DAILY { + jobName += "-" + me.Desc + } + + jobName += "-" + me.Id + + return jobName +} + +func (me *Compliance) IsValid() *AppError { + + if len(me.Id) != 26 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.id.app_error", nil, "") + } + + if me.CreateAt == 0 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.create_at.app_error", nil, "") + } + + if len(me.Desc) > 512 || len(me.Desc) == 0 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.desc.app_error", nil, "") + } + + if me.StartAt == 0 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.start_at.app_error", nil, "") + } + + if me.EndAt == 0 { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.end_at.app_error", nil, "") + } + + if me.EndAt <= me.StartAt { + return NewLocAppError("Compliance.IsValid", "model.compliance.is_valid.start_end_at.app_error", nil, "") + } + + return nil +} + +func ComplianceFromJson(data io.Reader) *Compliance { + decoder := json.NewDecoder(data) + var o Compliance + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +func (o Compliances) ToJson() string { + if b, err := json.Marshal(o); err != nil { + return "[]" + } else { + return string(b) + } +} + +func CompliancesFromJson(data io.Reader) Compliances { + decoder := json.NewDecoder(data) + var o Compliances + err := decoder.Decode(&o) + if err == nil { + return o + } else { + return nil + } +} diff --git a/model/compliance_post.go b/model/compliance_post.go index 636be8f17f..ce26a3660e 100644 --- a/model/compliance_post.go +++ b/model/compliance_post.go @@ -65,6 +65,17 @@ func CompliancePostHeader() []string { } func (me *CompliancePost) Row() []string { + + postDeleteAt := "" + if me.PostDeleteAt > 0 { + postDeleteAt = time.Unix(0, me.PostDeleteAt*int64(1000*1000)).Format(time.RFC3339) + } + + postUpdateAt := "" + if me.PostUpdateAt != me.PostCreateAt { + postUpdateAt = time.Unix(0, me.PostUpdateAt*int64(1000*1000)).Format(time.RFC3339) + } + return []string{ me.TeamName, me.TeamDisplayName, @@ -77,9 +88,10 @@ func (me *CompliancePost) Row() []string { me.UserNickname, me.PostId, - time.Unix(0, me.PostCreateAt*1000).Format(time.RFC3339), - time.Unix(0, me.PostUpdateAt*1000).Format(time.RFC3339), - time.Unix(0, me.PostDeleteAt*1000).Format(time.RFC3339), + time.Unix(0, me.PostCreateAt*int64(1000*1000)).Format(time.RFC3339), + postUpdateAt, + postDeleteAt, + me.PostRootId, me.PostParentId, me.PostOriginalId, diff --git a/model/compliance_test.go b/model/compliance_test.go new file mode 100644 index 0000000000..6acc5a8828 --- /dev/null +++ b/model/compliance_test.go @@ -0,0 +1,19 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestCompliance(t *testing.T) { + o := Compliance{Desc: "test", CreateAt: GetMillis()} + json := o.ToJson() + result := ComplianceFromJson(strings.NewReader(json)) + + if o.Desc != result.Desc { + t.Fatal("JobName do not match") + } +} diff --git a/model/config.go b/model/config.go index 82c51224e2..8f5865f283 100644 --- a/model/config.go +++ b/model/config.go @@ -170,19 +170,26 @@ type LdapSettings struct { QueryTimeout *int } +type ComplianceSettings struct { + Enable *bool + Directory *string + EnableDaily *bool +} + type Config struct { - ServiceSettings ServiceSettings - TeamSettings TeamSettings - SqlSettings SqlSettings - LogSettings LogSettings - FileSettings FileSettings - EmailSettings EmailSettings - RateLimitSettings RateLimitSettings - PrivacySettings PrivacySettings - SupportSettings SupportSettings - GitLabSettings SSOSettings - GoogleSettings SSOSettings - LdapSettings LdapSettings + ServiceSettings ServiceSettings + TeamSettings TeamSettings + SqlSettings SqlSettings + LogSettings LogSettings + FileSettings FileSettings + EmailSettings EmailSettings + RateLimitSettings RateLimitSettings + PrivacySettings PrivacySettings + SupportSettings SupportSettings + GitLabSettings SSOSettings + GoogleSettings SSOSettings + LdapSettings LdapSettings + ComplianceSettings ComplianceSettings } func (o *Config) ToJson() string { @@ -383,6 +390,21 @@ func (o *Config) SetDefaults() { o.ServiceSettings.AllowCorsFrom = new(string) *o.ServiceSettings.AllowCorsFrom = "" } + + if o.ComplianceSettings.Enable == nil { + o.ComplianceSettings.Enable = new(bool) + *o.ComplianceSettings.Enable = false + } + + if o.ComplianceSettings.Directory == nil { + o.ComplianceSettings.Directory = new(string) + *o.ComplianceSettings.Directory = "./data/" + } + + if o.ComplianceSettings.EnableDaily == nil { + o.ComplianceSettings.EnableDaily = new(bool) + *o.ComplianceSettings.EnableDaily = false + } } func (o *Config) IsValid() *AppError { diff --git a/model/license.go b/model/license.go index ea66fef0de..8461c9f764 100644 --- a/model/license.go +++ b/model/license.go @@ -32,9 +32,10 @@ type Customer struct { } type Features struct { - Users *int `json:"users"` - LDAP *bool `json:"ldap"` - GoogleSSO *bool `json:"google_sso"` + Users *int `json:"users"` + LDAP *bool `json:"ldap"` + GoogleSSO *bool `json:"google_sso"` + Compliance *bool `json:"compliance"` } func (f *Features) SetDefaults() { @@ -52,6 +53,11 @@ func (f *Features) SetDefaults() { f.GoogleSSO = new(bool) *f.GoogleSSO = true } + + if f.Compliance == nil { + f.Compliance = new(bool) + *f.Compliance = true + } } func (l *License) IsExpired() bool { diff --git a/store/sql_audit_store.go b/store/sql_audit_store.go index dbcb9a616c..7609ebc256 100644 --- a/store/sql_audit_store.go +++ b/store/sql_audit_store.go @@ -18,8 +18,8 @@ func NewSqlAuditStore(sqlStore *SqlStore) AuditStore { table := db.AddTableWithName(model.Audit{}, "Audits").SetKeys(false, "Id") table.ColMap("Id").SetMaxSize(26) table.ColMap("UserId").SetMaxSize(26) - table.ColMap("Action").SetMaxSize(64) - table.ColMap("ExtraInfo").SetMaxSize(128) + table.ColMap("Action").SetMaxSize(512) + table.ColMap("ExtraInfo").SetMaxSize(1024) table.ColMap("IpAddress").SetMaxSize(64) table.ColMap("SessionId").SetMaxSize(26) } @@ -28,6 +28,17 @@ func NewSqlAuditStore(sqlStore *SqlStore) AuditStore { } func (s SqlAuditStore) UpgradeSchemaIfNeeded() { + // ADDED for 2.2 REMOVE for 2.6 + extraLength := s.GetMaxLengthOfColumnIfExists("Audits", "ExtraInfo") + if len(extraLength) > 0 && extraLength != "1024" { + s.AlterColumnTypeIfExists("Audits", "ExtraInfo", "VARCHAR(1024)", "VARCHAR(1024)") + } + + // ADDED for 2.2 REMOVE for 2.6 + actionLength := s.GetMaxLengthOfColumnIfExists("Audits", "Action") + if len(actionLength) > 0 && actionLength != "512" { + s.AlterColumnTypeIfExists("Audits", "Action", "VARCHAR(512)", "VARCHAR(512)") + } } func (s SqlAuditStore) CreateIndexesIfNotExists() { diff --git a/store/sql_compliance_store.go b/store/sql_compliance_store.go new file mode 100644 index 0000000000..57872aef49 --- /dev/null +++ b/store/sql_compliance_store.go @@ -0,0 +1,234 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "strconv" + "strings" +) + +type SqlComplianceStore struct { + *SqlStore +} + +func NewSqlComplianceStore(sqlStore *SqlStore) ComplianceStore { + s := &SqlComplianceStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.Compliance{}, "Compliances").SetKeys(false, "Id") + table.ColMap("Id").SetMaxSize(26) + table.ColMap("UserId").SetMaxSize(26) + table.ColMap("Status").SetMaxSize(64) + table.ColMap("Desc").SetMaxSize(512) + table.ColMap("Type").SetMaxSize(64) + table.ColMap("Keywords").SetMaxSize(512) + table.ColMap("Emails").SetMaxSize(1024) + } + + return s +} + +func (s SqlComplianceStore) UpgradeSchemaIfNeeded() { +} + +func (s SqlComplianceStore) CreateIndexesIfNotExists() { +} + +func (s SqlComplianceStore) Save(compliance *model.Compliance) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + compliance.PreSave() + if result.Err = compliance.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + if err := s.GetMaster().Insert(compliance); err != nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Save", "store.sql_compliance.save.saving.app_error", nil, err.Error()) + } else { + result.Data = compliance + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (us SqlComplianceStore) Update(compliance *model.Compliance) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if result.Err = compliance.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + if _, err := us.GetMaster().Update(compliance); err != nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Update", "store.sql_compliance.save.saving.app_error", nil, err.Error()) + } else { + result.Data = compliance + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlComplianceStore) GetAll() StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + query := "SELECT * FROM Compliances ORDER BY CreateAt DESC" + + var compliances model.Compliances + if _, err := s.GetReplica().Select(&compliances, query); err != nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Get", "store.sql_compliance.get.finding.app_error", nil, err.Error()) + } else { + result.Data = compliances + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (us SqlComplianceStore) Get(id string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if obj, err := us.GetReplica().Get(model.Compliance{}, id); err != nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Get", "store.sql_compliance.get.finding.app_error", nil, err.Error()) + } else if obj == nil { + result.Err = model.NewLocAppError("SqlComplianceStore.Get", "store.sql_compliance.get.finding.app_error", nil, err.Error()) + } else { + result.Data = obj.(*model.Compliance) + } + + storeChannel <- result + close(storeChannel) + + }() + + return storeChannel +} + +func (s SqlComplianceStore) ComplianceExport(job *model.Compliance) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + props := map[string]interface{}{"StartTime": job.StartAt, "EndTime": job.EndAt} + + keywordQuery := "" + keywords := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(job.Keywords, ",", " ", -1)))) + if len(keywords) > 0 { + + keywordQuery = "AND (" + + for index, keyword := range keywords { + if index >= 1 { + keywordQuery += " OR LOWER(Posts.Message) LIKE :Keyword" + strconv.Itoa(index) + } else { + keywordQuery += "LOWER(Posts.Message) LIKE :Keyword" + strconv.Itoa(index) + } + + props["Keyword"+strconv.Itoa(index)] = "%" + keyword + "%" + } + + keywordQuery += ")" + } + + emailQuery := "" + emails := strings.Fields(strings.TrimSpace(strings.ToLower(strings.Replace(job.Emails, ",", " ", -1)))) + if len(emails) > 0 { + + emailQuery = "AND (" + + for index, email := range emails { + if index >= 1 { + emailQuery += " OR Users.Email = :Email" + strconv.Itoa(index) + } else { + emailQuery += "Users.Email = :Email" + strconv.Itoa(index) + } + + props["Email"+strconv.Itoa(index)] = email + } + + emailQuery += ")" + } + + query := + `SELECT + Teams.Name AS TeamName, + Teams.DisplayName AS TeamDisplayName, + Channels.Name AS ChannelName, + Channels.DisplayName AS ChannelDisplayName, + Users.Username AS UserUsername, + Users.Email AS UserEmail, + Users.Nickname AS UserNickname, + Posts.Id AS PostId, + Posts.CreateAt AS PostCreateAt, + Posts.UpdateAt AS PostUpdateAt, + Posts.DeleteAt AS PostDeleteAt, + Posts.RootId AS PostRootId, + Posts.ParentId AS PostParentId, + Posts.OriginalId AS PostOriginalId, + Posts.Message AS PostMessage, + Posts.Type AS PostType, + Posts.Props AS PostProps, + Posts.Hashtags AS PostHashtags, + Posts.Filenames AS PostFilenames + FROM + Teams, + Channels, + Users, + Posts + WHERE + Teams.Id = Channels.TeamId + AND Posts.ChannelId = Channels.Id + AND Posts.UserId = Users.Id + AND Posts.CreateAt > :StartTime + AND Posts.CreateAt <= :EndTime + ` + emailQuery + ` + ` + keywordQuery + ` + ORDER BY Posts.CreateAt + LIMIT 30000` + + var cposts []*model.CompliancePost + + if _, err := s.GetReplica().Select(&cposts, query, props); err != nil { + result.Err = model.NewLocAppError("SqlPostStore.ComplianceExport", "store.sql_post.compliance_export.app_error", nil, err.Error()) + } else { + result.Data = cposts + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_compliance_store_test.go b/store/sql_compliance_store_test.go new file mode 100644 index 0000000000..2f3ef35693 --- /dev/null +++ b/store/sql_compliance_store_test.go @@ -0,0 +1,210 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "testing" + "time" +) + +func TestSqlComplianceStore(t *testing.T) { + Setup() + + compliance1 := &model.Compliance{Desc: "Desc", UserId: model.NewId(), Status: "TestStatus1", StartAt: model.GetMillis() - 1, EndAt: model.GetMillis() + 1} + Must(store.Compliance().Save(compliance1)) + time.Sleep(100 * time.Millisecond) + + compliance2 := &model.Compliance{Desc: "Desc", UserId: model.NewId(), Status: "TestStatus2", StartAt: model.GetMillis() - 1, EndAt: model.GetMillis() + 1} + Must(store.Compliance().Save(compliance2)) + time.Sleep(100 * time.Millisecond) + + c := store.Compliance().GetAll() + result := <-c + compliances := result.Data.(model.Compliances) + + if compliances[0].Status != "TestStatus2" && compliance2.Id != compliances[0].Id { + t.Fatal() + } + + compliance2.Status = "TestUpdateStatus2" + Must(store.Compliance().Update(compliance2)) + + c = store.Compliance().GetAll() + result = <-c + compliances = result.Data.(model.Compliances) + + if compliances[0].Status != "TestUpdateStatus2" && compliance2.Id != compliances[0].Id { + t.Fatal() + } + + rc2 := (<-store.Compliance().Get(compliance2.Id)).Data.(*model.Compliance) + if rc2.Status != compliance2.Status { + t.Fatal() + } +} + +func TestComplianceExport(t *testing.T) { + Setup() + + time.Sleep(100 * time.Millisecond) + + t1 := &model.Team{} + t1.DisplayName = "DisplayName" + t1.Name = "a" + model.NewId() + "b" + t1.Email = model.NewId() + "@nowhere.com" + t1.Type = model.TEAM_OPEN + t1 = Must(store.Team().Save(t1)).(*model.Team) + + u1 := &model.User{} + u1.TeamId = t1.Id + u1.Email = model.NewId() + u1.Username = model.NewId() + u1 = Must(store.User().Save(u1)).(*model.User) + + u2 := &model.User{} + u2.TeamId = t1.Id + u2.Email = model.NewId() + u2.Username = model.NewId() + u2 = Must(store.User().Save(u2)).(*model.User) + + c1 := &model.Channel{} + c1.TeamId = t1.Id + c1.DisplayName = "Channel2" + c1.Name = "a" + model.NewId() + "b" + c1.Type = model.CHANNEL_OPEN + c1 = Must(store.Channel().Save(c1)).(*model.Channel) + + o1 := &model.Post{} + o1.ChannelId = c1.Id + o1.UserId = u1.Id + o1.CreateAt = model.GetMillis() + o1.Message = "a" + model.NewId() + "b" + o1 = Must(store.Post().Save(o1)).(*model.Post) + + o1a := &model.Post{} + o1a.ChannelId = c1.Id + o1a.UserId = u1.Id + o1a.CreateAt = o1.CreateAt + 10 + o1a.Message = "a" + model.NewId() + "b" + o1a = Must(store.Post().Save(o1a)).(*model.Post) + + o2 := &model.Post{} + o2.ChannelId = c1.Id + o2.UserId = u1.Id + o2.CreateAt = o1.CreateAt + 20 + o2.Message = "a" + model.NewId() + "b" + o2 = Must(store.Post().Save(o2)).(*model.Post) + + o2a := &model.Post{} + o2a.ChannelId = c1.Id + o2a.UserId = u2.Id + o2a.CreateAt = o1.CreateAt + 30 + o2a.Message = "a" + model.NewId() + "b" + o2a = Must(store.Post().Save(o2a)).(*model.Post) + + time.Sleep(100 * time.Millisecond) + + cr1 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1} + if r1 := <-store.Compliance().ComplianceExport(cr1); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 4 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o1.Id { + t.Fatal("Wrong sort") + } + + if cposts[3].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } + + cr2 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Emails: u2.Email} + if r1 := <-store.Compliance().ComplianceExport(cr2); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 1 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } + + cr3 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Emails: u2.Email + ", " + u1.Email} + if r1 := <-store.Compliance().ComplianceExport(cr3); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 4 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o1.Id { + t.Fatal("Wrong sort") + } + + if cposts[3].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } + + cr4 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Keywords: o2a.Message} + if r1 := <-store.Compliance().ComplianceExport(cr4); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 1 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } + + cr5 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Keywords: o2a.Message + " " + o1.Message} + if r1 := <-store.Compliance().ComplianceExport(cr5); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 2 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o1.Id { + t.Fatal("Wrong sort") + } + } + + cr6 := &model.Compliance{Desc: "test" + model.NewId(), StartAt: o1.CreateAt - 1, EndAt: o2a.CreateAt + 1, Emails: u2.Email + ", " + u1.Email, Keywords: o2a.Message + " " + o1.Message} + if r1 := <-store.Compliance().ComplianceExport(cr6); r1.Err != nil { + t.Fatal(r1.Err) + } else { + cposts := r1.Data.([]*model.CompliancePost) + + if len(cposts) != 2 { + t.Fatal("return wrong results length") + } + + if cposts[0].PostId != o1.Id { + t.Fatal("Wrong sort") + } + + if cposts[1].PostId != o2a.Id { + t.Fatal("Wrong sort") + } + } +} diff --git a/store/sql_post_store.go b/store/sql_post_store.go index 198347ff2d..3346534ab3 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -979,59 +979,3 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustH return storeChannel } - -func (s SqlPostStore) ComplianceExport(startTime int64, endTime int64) StoreChannel { - storeChannel := make(StoreChannel) - - go func() { - result := StoreResult{} - - query := - `SELECT - Teams.Name AS TeamName, - Teams.DisplayName AS TeamDisplayName, - Channels.Name AS ChannelName, - Channels.DisplayName AS ChannelDisplayName, - Users.Username AS UserUsername, - Users.Email AS UserEmail, - Users.Nickname AS UserNickname, - Posts.Id AS PostId, - Posts.CreateAt AS PostCreateAt, - Posts.UpdateAt AS PostUpdateAt, - Posts.DeleteAt AS PostDeleteAt, - Posts.RootId AS PostRootId, - Posts.ParentId AS PostParentId, - Posts.OriginalId AS PostOriginalId, - Posts.Message AS PostMessage, - Posts.Type AS PostType, - Posts.Props AS PostProps, - Posts.Hashtags AS PostHashtags, - Posts.Filenames AS PostFilenames - FROM - Teams, - Channels, - Users, - Posts - WHERE - Teams.Id = Channels.TeamId - AND Posts.ChannelId = Channels.Id - AND Posts.UserId = Users.Id - AND Posts.CreateAt > :StartTime - AND Posts.CreateAt <= :EndTime - ORDER BY Posts.CreateAt - LIMIT 30000` - - var cposts []*model.CompliancePost - - if _, err := s.GetReplica().Select(&cposts, query, map[string]interface{}{"StartTime": startTime, "EndTime": endTime}); err != nil { - result.Err = model.NewLocAppError("SqlPostStore.ComplianceExport", "store.sql_post.compliance_export.app_error", nil, err.Error()) - } else { - result.Data = cposts - } - - storeChannel <- result - close(storeChannel) - }() - - return storeChannel -} diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go index 512c27ee4a..d69f7906c6 100644 --- a/store/sql_post_store_test.go +++ b/store/sql_post_store_test.go @@ -895,79 +895,3 @@ func TestPostCountsByDay(t *testing.T) { } } } - -func TestComplianceExport(t *testing.T) { - Setup() - - time.Sleep(100 * time.Millisecond) - - t1 := &model.Team{} - t1.DisplayName = "DisplayName" - t1.Name = "a" + model.NewId() + "b" - t1.Email = model.NewId() + "@nowhere.com" - t1.Type = model.TEAM_OPEN - t1 = Must(store.Team().Save(t1)).(*model.Team) - - u1 := &model.User{} - u1.TeamId = t1.Id - u1.Email = model.NewId() - u1.Username = model.NewId() - u1 = Must(store.User().Save(u1)).(*model.User) - - c1 := &model.Channel{} - c1.TeamId = t1.Id - c1.DisplayName = "Channel2" - c1.Name = "a" + model.NewId() + "b" - c1.Type = model.CHANNEL_OPEN - c1 = Must(store.Channel().Save(c1)).(*model.Channel) - - o1 := &model.Post{} - o1.ChannelId = c1.Id - o1.UserId = u1.Id - o1.CreateAt = model.GetMillis() - o1.Message = "a" + model.NewId() + "b" - o1 = Must(store.Post().Save(o1)).(*model.Post) - - o1a := &model.Post{} - o1a.ChannelId = c1.Id - o1a.UserId = u1.Id - o1a.CreateAt = o1.CreateAt + 10 - o1a.Message = "a" + model.NewId() + "b" - o1a = Must(store.Post().Save(o1a)).(*model.Post) - - o2 := &model.Post{} - o2.ChannelId = c1.Id - o2.UserId = u1.Id - o2.CreateAt = o1.CreateAt + 20 - o2.Message = "a" + model.NewId() + "b" - o2 = Must(store.Post().Save(o2)).(*model.Post) - - o2a := &model.Post{} - o2a.ChannelId = c1.Id - o2a.UserId = u1.Id - o2a.CreateAt = o1.CreateAt + 30 - o2a.Message = "a" + model.NewId() + "b" - o2a = Must(store.Post().Save(o2a)).(*model.Post) - - time.Sleep(100 * time.Millisecond) - - if r1 := <-store.Post().ComplianceExport(o1.CreateAt-1, o2a.CreateAt+1); r1.Err != nil { - t.Fatal(r1.Err) - } else { - cposts := r1.Data.([]*model.CompliancePost) - t.Log(cposts) - - if len(cposts) != 4 { - t.Fatal("return wrong results length") - } - - if cposts[0].PostId != o1.Id { - t.Fatal("Wrong sort") - } - - if cposts[3].PostId != o2a.Id { - t.Fatal("Wrong sort") - } - - } -} diff --git a/store/sql_store.go b/store/sql_store.go index de23f4db3f..8ff5da6f74 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -43,6 +43,7 @@ type SqlStore struct { post PostStore user UserStore audit AuditStore + compliance ComplianceStore session SessionStore oauth OAuthStore system SystemStore @@ -98,6 +99,7 @@ func NewSqlStore() Store { sqlStore.post = NewSqlPostStore(sqlStore) sqlStore.user = NewSqlUserStore(sqlStore) sqlStore.audit = NewSqlAuditStore(sqlStore) + sqlStore.compliance = NewSqlComplianceStore(sqlStore) sqlStore.session = NewSqlSessionStore(sqlStore) sqlStore.oauth = NewSqlOAuthStore(sqlStore) sqlStore.system = NewSqlSystemStore(sqlStore) @@ -116,6 +118,7 @@ func NewSqlStore() Store { sqlStore.post.(*SqlPostStore).UpgradeSchemaIfNeeded() sqlStore.user.(*SqlUserStore).UpgradeSchemaIfNeeded() sqlStore.audit.(*SqlAuditStore).UpgradeSchemaIfNeeded() + sqlStore.compliance.(*SqlComplianceStore).UpgradeSchemaIfNeeded() sqlStore.session.(*SqlSessionStore).UpgradeSchemaIfNeeded() sqlStore.oauth.(*SqlOAuthStore).UpgradeSchemaIfNeeded() sqlStore.system.(*SqlSystemStore).UpgradeSchemaIfNeeded() @@ -129,6 +132,7 @@ func NewSqlStore() Store { sqlStore.post.(*SqlPostStore).CreateIndexesIfNotExists() sqlStore.user.(*SqlUserStore).CreateIndexesIfNotExists() sqlStore.audit.(*SqlAuditStore).CreateIndexesIfNotExists() + sqlStore.compliance.(*SqlComplianceStore).CreateIndexesIfNotExists() sqlStore.session.(*SqlSessionStore).CreateIndexesIfNotExists() sqlStore.oauth.(*SqlOAuthStore).CreateIndexesIfNotExists() sqlStore.system.(*SqlSystemStore).CreateIndexesIfNotExists() @@ -591,6 +595,10 @@ func (ss SqlStore) Audit() AuditStore { return ss.audit } +func (ss SqlStore) Compliance() ComplianceStore { + return ss.compliance +} + func (ss SqlStore) OAuth() OAuthStore { return ss.oauth } diff --git a/store/store.go b/store/store.go index 7aef182036..94c4261171 100644 --- a/store/store.go +++ b/store/store.go @@ -33,6 +33,7 @@ type Store interface { Post() PostStore User() UserStore Audit() AuditStore + Compliance() ComplianceStore Session() SessionStore OAuth() OAuthStore System() SystemStore @@ -105,7 +106,6 @@ type PostStore interface { AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel AnalyticsPostCountsByDay(teamId string) StoreChannel AnalyticsPostCount(teamId string, mustHaveFile bool, mustHaveHashtag bool) StoreChannel - ComplianceExport(startTime int64, endTime int64) StoreChannel } type UserStore interface { @@ -152,6 +152,14 @@ type AuditStore interface { PermanentDeleteByUser(userId string) StoreChannel } +type ComplianceStore interface { + Save(compliance *model.Compliance) StoreChannel + Update(compliance *model.Compliance) StoreChannel + Get(id string) StoreChannel + GetAll() StoreChannel + ComplianceExport(compliance *model.Compliance) StoreChannel +} + type OAuthStore interface { SaveApp(app *model.OAuthApp) StoreChannel UpdateApp(app *model.OAuthApp) StoreChannel diff --git a/utils/config.go b/utils/config.go index 63906c3458..9624196bea 100644 --- a/utils/config.go +++ b/utils/config.go @@ -238,5 +238,7 @@ func getClientConfig(c *model.Config) map[string]string { props["AllowCorsFrom"] = *c.ServiceSettings.AllowCorsFrom + props["EnableCompliance"] = strconv.FormatBool(*c.ComplianceSettings.Enable) + return props } diff --git a/utils/html.go b/utils/html.go index 4203160d5d..e89cb12a0f 100644 --- a/utils/html.go +++ b/utils/html.go @@ -23,7 +23,16 @@ type HTMLTemplate struct { } func InitHTML() { - templatesDir := FindDir("templates") + InitHTMLWithDir("templates") +} + +func InitHTMLWithDir(dir string) { + + if htmlTemplates != nil { + return + } + + templatesDir := FindDir(dir) l4g.Debug(T("api.api.init.parsing_templates.debug"), templatesDir) var err error if htmlTemplates, err = template.ParseGlob(templatesDir + "*.html"); err != nil { diff --git a/utils/i18n.go b/utils/i18n.go index e809ae883c..2503cd5003 100644 --- a/utils/i18n.go +++ b/utils/i18n.go @@ -16,7 +16,11 @@ var T i18n.TranslateFunc var locales map[string]string = make(map[string]string) func InitTranslations() { - i18nDirectory := FindDir("i18n") + InitTranslationsWithDir("i18n") +} + +func InitTranslationsWithDir(dir string) { + i18nDirectory := FindDir(dir) files, _ := ioutil.ReadDir(i18nDirectory) for _, f := range files { if filepath.Ext(f.Name()) == ".json" { diff --git a/utils/license.go b/utils/license.go index b1f15ad928..1dc8bf0259 100644 --- a/utils/license.go +++ b/utils/license.go @@ -115,6 +115,7 @@ func getClientLicense(l *model.License) map[string]string { props["Users"] = strconv.Itoa(*l.Features.Users) props["LDAP"] = strconv.FormatBool(*l.Features.LDAP) props["GoogleSSO"] = strconv.FormatBool(*l.Features.GoogleSSO) + props["Compliance"] = strconv.FormatBool(*l.Features.Compliance) props["IssuedAt"] = strconv.FormatInt(l.IssuedAt, 10) props["StartsAt"] = strconv.FormatInt(l.StartsAt, 10) props["ExpiresAt"] = strconv.FormatInt(l.ExpiresAt, 10) diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index 4c4f21f08b..66b6eb71fb 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -22,6 +22,7 @@ import LegalAndSupportSettingsTab from './legal_and_support_settings.jsx'; import TeamUsersTab from './team_users.jsx'; import TeamAnalyticsTab from '../analytics/team_analytics.jsx'; import LdapSettingsTab from './ldap_settings.jsx'; +import ComplianceSettingsTab from './compliance_settings.jsx'; import LicenseSettingsTab from './license_settings.jsx'; import SystemAnalyticsTab from '../analytics/system_analytics.jsx'; @@ -156,6 +157,8 @@ export default class AdminController extends React.Component { tab = ; } else if (this.state.selected === 'ldap_settings') { tab = ; + } else if (this.state.selected === 'compliance_settings') { + tab = ; } else if (this.state.selected === 'license') { tab = ; } else if (this.state.selected === 'team_users') { diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index c2f31f5690..b4288d657a 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -176,6 +176,7 @@ export default class AdminSidebar extends React.Component { } let ldapSettings; + let complianceSettings; let licenseSettings; if (global.window.mm_config.BuildEnterpriseReady === 'true') { if (global.window.mm_license.IsLicensed === 'true') { @@ -193,6 +194,21 @@ export default class AdminSidebar extends React.Component { ); + + complianceSettings = ( +
  • + + + +
  • + ); } licenseSettings = ( @@ -386,6 +402,7 @@ export default class AdminSidebar extends React.Component { {ldapSettings} + {complianceSettings}
  • - + ); } return ( -
    -

    - -

    - -
    - {content} +
    + + +
    +

    + +

    + +
    + {content} +
    ); diff --git a/web/react/components/admin_console/compliance_reports.jsx b/web/react/components/admin_console/compliance_reports.jsx new file mode 100644 index 0000000000..2a94b6f1d3 --- /dev/null +++ b/web/react/components/admin_console/compliance_reports.jsx @@ -0,0 +1,384 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LoadingScreen from '../loading_screen.jsx'; +import * as Utils from '../../utils/utils.jsx'; +import AdminStore from '../../stores/admin_store.jsx'; +import UserStore from '../../stores/user_store.jsx'; + +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; + +import {FormattedMessage, FormattedDate, FormattedTime} from 'mm-intl'; + +export default class ComplianceReports extends React.Component { + constructor(props) { + super(props); + + this.onComplianceReportsListenerChange = this.onComplianceReportsListenerChange.bind(this); + this.reload = this.reload.bind(this); + this.runReport = this.runReport.bind(this); + this.getDateTime = this.getDateTime.bind(this); + + this.state = { + reports: AdminStore.getComplianceReports(), + serverError: null + }; + } + + componentDidMount() { + AdminStore.addComplianceReportsChangeListener(this.onComplianceReportsListenerChange); + + if (global.window.mm_license.IsLicensed !== 'true' || global.window.mm_config.EnableCompliance !== 'true') { + return; + } + + AsyncClient.getComplianceReports(); + } + + componentWillUnmount() { + AdminStore.removeComplianceReportsChangeListener(this.onComplianceReportsListenerChange); + } + + onComplianceReportsListenerChange() { + this.setState({ + reports: AdminStore.getComplianceReports() + }); + } + + reload() { + AdminStore.saveComplianceReports(null); + this.setState({ + reports: null, + serverError: null + }); + + AsyncClient.getComplianceReports(); + } + + runReport(e) { + e.preventDefault(); + $('#run-button').button('loading'); + + var job = {}; + job.desc = ReactDOM.findDOMNode(this.refs.desc).value; + job.emails = ReactDOM.findDOMNode(this.refs.emails).value; + job.keywords = ReactDOM.findDOMNode(this.refs.keywords).value; + job.start_at = Date.parse(ReactDOM.findDOMNode(this.refs.from).value); + job.end_at = Date.parse(ReactDOM.findDOMNode(this.refs.to).value); + + Client.saveComplianceReports( + job, + () => { + ReactDOM.findDOMNode(this.refs.emails).value = ''; + ReactDOM.findDOMNode(this.refs.keywords).value = ''; + ReactDOM.findDOMNode(this.refs.desc).value = ''; + ReactDOM.findDOMNode(this.refs.from).value = ''; + ReactDOM.findDOMNode(this.refs.to).value = ''; + this.reload(); + $('#run-button').button('reset'); + }, + (err) => { + this.setState({serverError: err.message}); + $('#run-button').button('reset'); + } + ); + } + + getDateTime(millis) { + const date = new Date(millis); + return ( + + + {' - '} + + + ); + } + + render() { + var content = null; + + if (global.window.mm_license.IsLicensed !== 'true' || global.window.mm_config.EnableCompliance !== 'true') { + return
    ; + } + + if (this.state.reports === null) { + content = ; + } else { + var list = []; + + for (var i = 0; i < this.state.reports.length; i++) { + const report = this.state.reports[i]; + + var params = ''; + if (report.type === 'adhoc') { + params = ( + + {' '}{this.getDateTime(report.start_at)} +
    + {' '}{this.getDateTime(report.end_at)} +
    + {' '}{report.emails} +
    + {' '}{report.keywords} +
    ); + } + + var download = ''; + if (report.status === 'finished') { + download = ( +
    + + + ); + } + + var status = report.status; + if (report.status === 'finished') { + status = ( + {report.status} + ); + } + + if (report.status === 'failed') { + status = ( + {report.status} + ); + } + + var user = report.user_id; + var profile = UserStore.getProfile(report.user_id); + if (profile) { + user = profile.email; + } + + list[i] = ( + + {download} + {this.getDateTime(report.create_at)} + {status} + {report.count} + {report.type} + {report.desc} + {user} + {params} + + ); + } + + content = ( +
    + + + + + + + + + + + + + + + {list} + +
    + + + + + + + + + + + + + +
    +
    + ); + } + + let serverError = ''; + if (this.state.serverError) { + serverError = ( +
    + +
    + ); + } + + return ( +
    +

    + +

    + + + + + + + + + + + + + + +
    + + +
    + + + + + + + + + + + + + +
    + {serverError} +
    + +
    +
    + {content} +
    +
    + ); + } +} diff --git a/web/react/components/admin_console/compliance_settings.jsx b/web/react/components/admin_console/compliance_settings.jsx new file mode 100644 index 0000000000..8e6ca63404 --- /dev/null +++ b/web/react/components/admin_console/compliance_settings.jsx @@ -0,0 +1,271 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; + +import {injectIntl, intlShape, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'mm-intl'; + +var holders = defineMessages({ + saving: { + id: 'admin.compliance.saving', + defaultMessage: 'Saving Config...' + }, + directoryExample: { + id: 'admin.compliance.directoryExample', + defaultMessage: 'Ex "./data/"' + } +}); + +class ComplianceSettings extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleEnable = this.handleEnable.bind(this); + this.handleDisable = this.handleDisable.bind(this); + + this.state = { + saveNeeded: false, + serverError: null, + enable: this.props.config.ComplianceSettings.Enable + }; + } + handleChange() { + this.setState({saveNeeded: true}); + } + handleEnable() { + this.setState({saveNeeded: true, enable: true}); + } + handleDisable() { + this.setState({saveNeeded: true, enable: false}); + } + handleSubmit(e) { + e.preventDefault(); + $('#save-button').button('loading'); + + const config = this.props.config; + config.ComplianceSettings.Enable = this.refs.Enable.checked; + config.ComplianceSettings.Directory = ReactDOM.findDOMNode(this.refs.Directory).value; + config.ComplianceSettings.EnableDaily = this.refs.EnableDaily.checked; + + Client.saveConfig( + config, + () => { + AsyncClient.getConfig(); + this.setState({ + serverError: null, + saveNeeded: false + }); + $('#save-button').button('reset'); + }, + (err) => { + this.setState({ + serverError: err.message, + saveNeeded: true + }); + $('#save-button').button('reset'); + } + ); + } + render() { + const {formatMessage} = this.props.intl; + let serverError = ''; + if (this.state.serverError) { + serverError =
    ; + } + + let saveClass = 'btn'; + if (this.state.saveNeeded) { + saveClass = 'btn btn-primary'; + } + + const licenseEnabled = global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.Compliance === 'true'; + + let bannerContent; + if (!licenseEnabled) { + bannerContent = ( +
    +
    + +
    +
    + ); + } + + return ( +
    + {bannerContent} +

    + +

    +
    + +
    + +
    + + +

    + +

    +
    +
    + +
    + +
    + +

    + +

    +
    +
    + +
    + +
    + + +

    + +

    +
    +
    + +
    +
    + {serverError} + +
    +
    +
    +
    + ); + } +} +ComplianceSettings.defaultProps = { +}; + +ComplianceSettings.propTypes = { + intl: intlShape.isRequired, + config: React.PropTypes.object +}; + +export default injectIntl(ComplianceSettings); diff --git a/web/react/components/audit_table.jsx b/web/react/components/audit_table.jsx index 917093840b..2465950ce2 100644 --- a/web/react/components/audit_table.jsx +++ b/web/react/components/audit_table.jsx @@ -217,7 +217,12 @@ class AuditTable extends React.Component { let uContent; if (this.props.showUserId) { - uContent = {auditInfo.userId}; + var profile = UserStore.getProfile(auditInfo.userId); + if (profile) { + uContent = {profile.email}; + } else { + uContent = {auditInfo.userId}; + } } let iContent; @@ -560,6 +565,8 @@ export function formatAuditInfo(audit, formatMessage) { default: break; } + } else if (actionURL.indexOf('/admin/download_compliance_report') === 0) { + auditDesc = Utils.toTitleCase(audit.extra_info); } else { switch (actionURL) { case '/logout': diff --git a/web/react/stores/admin_store.jsx b/web/react/stores/admin_store.jsx index 9f7f6e7ffc..8662723bea 100644 --- a/web/react/stores/admin_store.jsx +++ b/web/react/stores/admin_store.jsx @@ -13,6 +13,7 @@ const LOG_CHANGE_EVENT = 'log_change'; const SERVER_AUDIT_CHANGE_EVENT = 'server_audit_change'; const CONFIG_CHANGE_EVENT = 'config_change'; const ALL_TEAMS_EVENT = 'all_team_change'; +const SERVER_COMPLIANCE_REPORT_CHANGE_EVENT = 'server_compliance_reports_change'; class AdminStoreClass extends EventEmitter { constructor() { @@ -22,6 +23,7 @@ class AdminStoreClass extends EventEmitter { this.audits = null; this.config = null; this.teams = null; + this.complianceReports = null; this.emitLogChange = this.emitLogChange.bind(this); this.addLogChangeListener = this.addLogChangeListener.bind(this); @@ -31,6 +33,10 @@ class AdminStoreClass extends EventEmitter { this.addAuditChangeListener = this.addAuditChangeListener.bind(this); this.removeAuditChangeListener = this.removeAuditChangeListener.bind(this); + this.emitComplianceReportsChange = this.emitComplianceReportsChange.bind(this); + this.addComplianceReportsChangeListener = this.addComplianceReportsChangeListener.bind(this); + this.removeComplianceReportsChangeListener = this.removeComplianceReportsChangeListener.bind(this); + this.emitConfigChange = this.emitConfigChange.bind(this); this.addConfigChangeListener = this.addConfigChangeListener.bind(this); this.removeConfigChangeListener = this.removeConfigChangeListener.bind(this); @@ -64,6 +70,18 @@ class AdminStoreClass extends EventEmitter { this.removeListener(SERVER_AUDIT_CHANGE_EVENT, callback); } + emitComplianceReportsChange() { + this.emit(SERVER_COMPLIANCE_REPORT_CHANGE_EVENT); + } + + addComplianceReportsChangeListener(callback) { + this.on(SERVER_COMPLIANCE_REPORT_CHANGE_EVENT, callback); + } + + removeComplianceReportsChangeListener(callback) { + this.removeListener(SERVER_COMPLIANCE_REPORT_CHANGE_EVENT, callback); + } + emitConfigChange() { this.emit(CONFIG_CHANGE_EVENT); } @@ -104,6 +122,14 @@ class AdminStoreClass extends EventEmitter { this.audits = audits; } + getComplianceReports() { + return this.complianceReports; + } + + saveComplianceReports(complianceReports) { + this.complianceReports = complianceReports; + } + getConfig() { return this.config; } @@ -147,6 +173,10 @@ AdminStoreClass.dispatchToken = AppDispatcher.register((payload) => { AdminStore.saveAudits(action.audits); AdminStore.emitAuditChange(); break; + case ActionTypes.RECEIVED_SERVER_COMPLIANCE_REPORTS: + AdminStore.saveComplianceReports(action.complianceReports); + AdminStore.emitComplianceReportsChange(); + break; case ActionTypes.RECEIVED_CONFIG: AdminStore.saveConfig(action.config); AdminStore.emitConfigChange(); diff --git a/web/react/utils/async_client.jsx b/web/react/utils/async_client.jsx index b9770a6e99..c565e076a4 100644 --- a/web/react/utils/async_client.jsx +++ b/web/react/utils/async_client.jsx @@ -341,6 +341,32 @@ export function getServerAudits() { ); } +export function getComplianceReports() { + if (isCallInProgress('getComplianceReports')) { + return; + } + + callTracker.getComplianceReports = utils.getTimestamp(); + client.getComplianceReports( + (data, textStatus, xhr) => { + callTracker.getComplianceReports = 0; + + if (xhr.status === 304 || !data) { + return; + } + + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_SERVER_COMPLIANCE_REPORTS, + complianceReports: data + }); + }, + (err) => { + callTracker.getComplianceReports = 0; + dispatchError(err, 'getComplianceReports'); + } + ); +} + export function getConfig() { if (isCallInProgress('getConfig')) { return; diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index e00f28a14a..5607a4b60b 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -412,6 +412,35 @@ export function getAudits(userId, success, error) { }); } +export function getComplianceReports(success, error) { + $.ajax({ + url: '/api/v1/admin/compliance_reports', + dataType: 'json', + contentType: 'application/json', + type: 'GET', + success, + error: function onError(xhr, status, err) { + var e = handleError('getComplianceReports', xhr, status, err); + error(e); + } + }); +} + +export function saveComplianceReports(job, success, error) { + $.ajax({ + url: '/api/v1/admin/save_compliance_report', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(job), + success, + error: (xhr, status, err) => { + var e = handleError('saveComplianceReports', xhr, status, err); + error(e); + } + }); +} + export function getLogs(success, error) { $.ajax({ url: '/api/v1/admin/logs', diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 3de562b7b2..3d9bb7317b 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -47,6 +47,7 @@ export default { RECEIVED_CONFIG: null, RECEIVED_LOGS: null, RECEIVED_SERVER_AUDITS: null, + RECEIVED_SERVER_COMPLIANCE_REPORTS: null, RECEIVED_ALL_TEAMS: null, RECEIVED_LOCALE: null, diff --git a/web/sass-files/sass/partials/_admin-console.scss b/web/sass-files/sass/partials/_admin-console.scss index 76081710fd..4613ff6eec 100644 --- a/web/sass-files/sass/partials/_admin-console.scss +++ b/web/sass-files/sass/partials/_admin-console.scss @@ -125,6 +125,26 @@ background-color: white; } + .compliance__panel { + overflow: scroll; + width: 100%; + height: 400px; + border: 1px solid #ddd; + margin-top: 10px; + padding: 5px; + background-color: white; + } + + .audit__panel { + overflow: scroll; + width: 100%; + height: 400px; + border: 1px solid #ddd; + margin-top: 10px; + padding: 5px; + background-color: white; + } + .app__content { &.admin { overflow: auto; diff --git a/web/static/i18n/en.json b/web/static/i18n/en.json index 2a536925cf..ba54fbfbd6 100644 --- a/web/static/i18n/en.json +++ b/web/static/i18n/en.json @@ -191,6 +191,40 @@ "admin.ldap.uernameAttrDesc": "The attribute in the LDAP server that will be used to populate the username field in Mattermost. This may be the same as the ID Attribute.", "admin.ldap.usernameAttrEx": "Ex \"sAMAccountName\"", "admin.ldap.usernameAttrTitle": "Username Attribute:", + "admin.compliance.saving": "Saving Config...", + "admin.compliance.directoryExample": "Ex \"./data/\"", + "admin.compliance.noLicense": "

    Note:

    Compliance is an enterprise feature. Your current license does not support Compliance. Click here for information and pricing on enterprise licenses.

    ", + "admin.compliance.title": "Compliance Settings", + "admin.compliance.enableTitle": "Enable Compliance:", + "admin.compliance.true": "true", + "admin.compliance.false": "false", + "admin.compliance.enableDesc": "When true, Mattermost allows compliance reporting", + "admin.compliance.directoryTitle": "Compliance Directory Location:", + "admin.compliance.directoryDescription": "Directory to which compliance reports are written. If blank, will be set to ./data/.", + "admin.compliance.enableDailyTitle": "Enable Daily Report:", + "admin.compliance.enableDesc": "When true, Mattermost will generate a daily compliance report.", + "admin.compliance.save": "Save", + "admin.compliance_reports.from": "From:", + "admin.compliance_reports.to": "To:", + "admin.compliance_reports.emails": "Emails:", + "admin.compliance_reports.keywords": "Keywords:", + "admin.compliance_table.download": "Download", + "admin.compliance_table.timestamp": "Timestamp", + "admin.compliance_table.status": "Status", + "admin.compliance_table.records": "Records", + "admin.compliance_table.type": "Type", + "admin.compliance_table.desc": "Description", + "admin.compliance_table.userId": "Requested By", + "admin.compliance_table.params": "Params", + "admin.compliance_reports.title": "Compliance Reports", + "admin.compliance_reports.desc": "Job Name:", + "admin.compliance_reports.desc_placeholder": "Ex \"Audit 445 for HR\"", + "admin.compliance_reports.from_placeholder": "Ex \"2016-03-11\"", + "admin.compliance_reports.to_placeholder": "Ex \"2016-03-15\"", + "admin.compliance_reports.emails_placeholder": "Ex \"bill@example.com, bob@example.com\"", + "admin.compliance_reports.keywords_placeholder": "Ex \"shorting stock\"", + "admin.compliance_reports.run": "Run", + "admin.compliance_reports.reload": "Reload", "admin.licence.keyMigration": "If you’re migrating servers you may need to remove your license key from this server in order to install it on a new server. To start, disable all Enterprise Edition features on this server. This will enable the ability to remove the license key and downgrade this server from Enterprise Edition to Team Edition.", "admin.license.chooseFile": "Choose File", "admin.license.edition": "Edition: ", @@ -331,6 +365,7 @@ "admin.sidebar.gitlab": "GitLab Settings", "admin.sidebar.ldap": "LDAP Settings", "admin.sidebar.license": "Edition and License", + "admin.sidebar.compliance": "Compliance Settings", "admin.sidebar.loading": "Loading", "admin.sidebar.log": "Log Settings", "admin.sidebar.logs": "Logs",