diff --git a/.gitignore b/.gitignore index 04e3085048..4c343021e7 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,5 @@ api/data/* .ctags tags +model/version.go model/version.go.bak diff --git a/api/admin.go b/api/admin.go index feb70aae3e..2990691a6b 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.Desc) + + 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 a0b50cbf2d..1735ca293d 100644 --- a/config/config.json +++ b/config/config.json @@ -129,16 +129,21 @@ }, "LdapSettings": { "Enable": false, - "LdapServer": null, + "LdapServer": "", "LdapPort": 389, - "BaseDN": null, - "BindUsername": null, - "BindPassword": null, - "FirstNameAttribute": null, - "LastNameAttribute": null, - "EmailAttribute": null, - "UsernameAttribute": null, - "IdAttribute": null, + "BaseDN": "", + "BindUsername": "", + "BindPassword": "", + "FirstNameAttribute": "", + "LastNameAttribute": "", + "EmailAttribute": "", + "UsernameAttribute": "", + "IdAttribute": "", "QueryTimeout": 60 + }, + "ComplianceSettings": { + "Enable": false, + "Directory": "./data/", + "EnableDaily": false } } diff --git a/einterfaces/compliance.go b/einterfaces/compliance.go new file mode 100644 index 0000000000..2e72c67d32 --- /dev/null +++ b/einterfaces/compliance.go @@ -0,0 +1,23 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package einterfaces + +import ( + "github.com/mattermost/platform/model" +) + +type ComplianceInterface interface { + StartComplianceDailyJob() + RunComplianceJob(job *model.Compliance) *model.AppError +} + +var theComplianceInterface ComplianceInterface + +func RegisterComplianceInterface(newInterface ComplianceInterface) { + theComplianceInterface = newInterface +} + +func GetComplianceInterface() ComplianceInterface { + return theComplianceInterface +} diff --git a/i18n/en.json b/i18n/en.json index 25a435580a..775e56cc4d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1799,6 +1799,26 @@ "id": "mattermost.security_bulletin_read.error", "translation": "Failed to read security bulletin details" }, + { + "id": "ent.compliance.run_started.info", + "translation": "Compliance export started for job '{{.JobName}}' at '{{.FilePath}}'" + }, + { + "id": "ent.compliance.run_failed.error", + "translation": "Compliance export failed for job '{{.JobName}}' at '{{.FilePath}}'" + }, + { + "id": "ent.compliance.run_limit.warning", + "translation": "Compliance export warning for job '{{.JobName}}' too many rows returned truncating to 30,000 at '{{.FilePath}}'" + }, + { + "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" @@ -2219,6 +2239,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" @@ -2495,6 +2539,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" @@ -2795,6 +2847,10 @@ "id": "store.sql_post.analytics_posts_count.app_error", "translation": "We couldn't get post counts" }, + { + "id": "store.sql_post.compliance_export.app_error", + "translation": "We couldn't get posts for compliance export" + }, { "id": "store.sql_post.analytics_posts_count_by_day.app_error", "translation": "We couldn't get post counts by day" diff --git a/mattermost.go b/mattermost.go index 45ffcc88fa..c555862e9e 100644 --- a/mattermost.go +++ b/mattermost.go @@ -19,6 +19,7 @@ import ( l4g "github.com/alecthomas/log4go" "github.com/mattermost/platform/api" + "github.com/mattermost/platform/einterfaces" "github.com/mattermost/platform/manualtesting" "github.com/mattermost/platform/model" "github.com/mattermost/platform/utils" @@ -69,7 +70,7 @@ func main() { web.InitWeb() if model.BuildEnterpriseReady == "true" { - loadLicense() + api.LoadLicense() } if !utils.IsLicensed && len(utils.Cfg.SqlSettings.DataSourceReplicas) > 1 { @@ -91,6 +92,10 @@ func main() { setDiagnosticId() runSecurityAndDiagnosticsJobAndForget() + if einterfaces.GetComplianceInterface() != nil { + einterfaces.GetComplianceInterface().StartComplianceDailyJob() + } + // wait for kill signal before attempting to gracefully shutdown // the running service c := make(chan os.Signal) @@ -101,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 14c175fc1c..68cf11414e 100644 --- a/model/client.go +++ b/model/client.go @@ -474,6 +474,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 new file mode 100644 index 0000000000..ce26a3660e --- /dev/null +++ b/model/compliance_post.go @@ -0,0 +1,104 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "time" +) + +type CompliancePost struct { + + // From Team + TeamName string + TeamDisplayName string + + // From Channel + ChannelName string + ChannelDisplayName string + + // From User + UserUsername string + UserEmail string + UserNickname string + + // From Post + PostId string + PostCreateAt int64 + PostUpdateAt int64 + PostDeleteAt int64 + PostRootId string + PostParentId string + PostOriginalId string + PostMessage string + PostType string + PostProps string + PostHashtags string + PostFilenames string +} + +func CompliancePostHeader() []string { + return []string{ + "TeamName", + "TeamDisplayName", + + "ChannelName", + "ChannelDisplayName", + + "UserUsername", + "UserEmail", + "UserNickname", + + "PostId", + "PostCreateAt", + "PostUpdateAt", + "PostDeleteAt", + "PostRootId", + "PostParentId", + "PostOriginalId", + "PostMessage", + "PostType", + "PostProps", + "PostHashtags", + "PostFilenames", + } +} + +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, + + me.ChannelName, + me.ChannelDisplayName, + + me.UserUsername, + me.UserEmail, + me.UserNickname, + + me.PostId, + time.Unix(0, me.PostCreateAt*int64(1000*1000)).Format(time.RFC3339), + postUpdateAt, + postDeleteAt, + + me.PostRootId, + me.PostParentId, + me.PostOriginalId, + me.PostMessage, + me.PostType, + me.PostProps, + me.PostHashtags, + me.PostFilenames, + } +} diff --git a/model/compliance_post_test.go b/model/compliance_post_test.go new file mode 100644 index 0000000000..28e20ba4bc --- /dev/null +++ b/model/compliance_post_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "testing" +) + +func TestCompliancePostHeader(t *testing.T) { + if CompliancePostHeader()[0] != "TeamName" { + t.Fatal() + } +} + +func TestCompliancePost(t *testing.T) { + o := CompliancePost{TeamName: "test", PostFilenames: "files", PostCreateAt: GetMillis()} + r := o.Row() + + if r[0] != "test" { + t.Fatal() + } + + if r[len(r)-1] != "files" { + t.Fatal() + } +} 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 d684c72b29..3ca2412756 100644 --- a/model/config.go +++ b/model/config.go @@ -179,19 +179,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 { @@ -402,6 +409,21 @@ func (o *Config) SetDefaults() { o.ServiceSettings.WebserverMode = new(string) *o.ServiceSettings.WebserverMode = "regular" } + + 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/model/system.go b/model/system.go index b387749f61..68d542c159 100644 --- a/model/system.go +++ b/model/system.go @@ -9,10 +9,11 @@ import ( ) const ( - SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" - SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" - SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" - SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" + SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" + SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" + SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" + SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" + SYSTEM_LAST_COMPLIANCE_TIME = "LastComplianceTime" ) type System struct { 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..1a41fa3891 --- /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: "Audit for federal subpoena case #22443", UserId: model.NewId(), Status: model.COMPLIANCE_STATUS_FAILED, StartAt: model.GetMillis() - 1, EndAt: model.GetMillis() + 1, Type: model.COMPLIANCE_TYPE_ADHOC} + Must(store.Compliance().Save(compliance1)) + time.Sleep(100 * time.Millisecond) + + compliance2 := &model.Compliance{Desc: "Audit for federal subpoena case #11458", UserId: model.NewId(), Status: model.COMPLIANCE_STATUS_RUNNING, StartAt: model.GetMillis() - 1, EndAt: model.GetMillis() + 1, Type: model.COMPLIANCE_TYPE_ADHOC} + 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 != model.COMPLIANCE_STATUS_RUNNING && compliance2.Id != compliances[0].Id { + t.Fatal() + } + + compliance2.Status = model.COMPLIANCE_STATUS_FAILED + Must(store.Compliance().Update(compliance2)) + + c = store.Compliance().GetAll() + result = <-c + compliances = result.Data.(model.Compliances) + + if compliances[0].Status != model.COMPLIANCE_STATUS_FAILED && 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_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 1738ba84ec..7ec5ac3a5f 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 @@ -153,6 +154,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/webapp/components/admin_console/admin_controller.jsx b/webapp/components/admin_console/admin_controller.jsx index e4a4e28fc6..aea2a0197b 100644 --- a/webapp/components/admin_console/admin_controller.jsx +++ b/webapp/components/admin_console/admin_controller.jsx @@ -23,6 +23,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'; @@ -159,6 +160,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/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx index 27d4a41124..8ee75e2efd 100644 --- a/webapp/components/admin_console/admin_sidebar.jsx +++ b/webapp/components/admin_console/admin_sidebar.jsx @@ -171,6 +171,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') { @@ -188,6 +189,21 @@ export default class AdminSidebar extends React.Component { ); + + complianceSettings = ( +
  • + + + +
  • + ); } licenseSettings = ( @@ -381,6 +397,7 @@ export default class AdminSidebar extends React.Component { {ldapSettings} + {complianceSettings}
  • - + ); } return ( -
    -

    - -

    - -
    - {content} +
    + + +
    +

    + +

    + +
    + {content} +
    ); diff --git a/webapp/components/admin_console/compliance_reports.jsx b/webapp/components/admin_console/compliance_reports.jsx new file mode 100644 index 0000000000..84def2bce6 --- /dev/null +++ b/webapp/components/admin_console/compliance_reports.jsx @@ -0,0 +1,388 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +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 'react-intl'; + +import React from 'react'; +import ReactDOM from 'react-dom'; + +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/webapp/components/admin_console/compliance_settings.jsx b/webapp/components/admin_console/compliance_settings.jsx new file mode 100644 index 0000000000..fb2ae26f95 --- /dev/null +++ b/webapp/components/admin_console/compliance_settings.jsx @@ -0,0 +1,260 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import * as Client from '../../utils/client.jsx'; +import * as AsyncClient from '../../utils/async_client.jsx'; +import * as Utils from '../../utils/utils.jsx'; + +import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; + +import React from 'react'; +import ReactDOM from 'react-dom'; + +export default 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() { + 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.propTypes = { + config: React.PropTypes.object +}; + diff --git a/webapp/components/audit_table.jsx b/webapp/components/audit_table.jsx index 73dcfccc3b..abf09dfaf6 100644 --- a/webapp/components/audit_table.jsx +++ b/webapp/components/audit_table.jsx @@ -219,7 +219,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; @@ -562,6 +567,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/webapp/i18n/en.json b/webapp/i18n/en.json index dc43cc0194..9a94775577 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -195,6 +195,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: ", @@ -335,6 +369,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", diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss index 63cf8eb135..0b47e5ab65 100644 --- a/webapp/sass/routes/_admin-console.scss +++ b/webapp/sass/routes/_admin-console.scss @@ -175,6 +175,26 @@ width: 100%; } + .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 { color: #333; diff --git a/webapp/stores/admin_store.jsx b/webapp/stores/admin_store.jsx index 0a4c8c442c..0f19dd4840 100644 --- a/webapp/stores/admin_store.jsx +++ b/webapp/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/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index 9a5869f9ae..2392b50b9b 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -342,6 +342,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/webapp/utils/client.jsx b/webapp/utils/client.jsx index ef6d496a25..69bda4303d 100644 --- a/webapp/utils/client.jsx +++ b/webapp/utils/client.jsx @@ -413,6 +413,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/webapp/utils/constants.jsx b/webapp/utils/constants.jsx index 29178aca63..4ee934e117 100644 --- a/webapp/utils/constants.jsx +++ b/webapp/utils/constants.jsx @@ -76,6 +76,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,