From 495a49b8965ea553e99cfdad7ec5f9919376cca3 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 16 Apr 2025 09:34:18 -0400 Subject: [PATCH] Feature/audit certificate upload (#30223) * feat: Add certificate upload option for audit logging settings * Commit current changes * Additions * MM-62944 Fix fileupload settings not being clickable * Support for uploading a cert for experimental audit logging cert. Pre cloud implementation in the backend * Forgot to add new hook * Add support for setting custom audit log certifcates in Cloud * Permissions * I18n * Change order * Linter fixes * Linter fixes, add openapi spec * additions for openapi * More openapi fixes because it won't run locally * Undo, cursor went rogue * newline fix * Align types properly * Fix i18n * Fix i18n AGAIN * Fix error * Update api/v4/source/audit_logging.yaml --------- Co-authored-by: Harrison Healey Co-authored-by: Mattermost Build --- api/Makefile | 1 + api/v4/source/audit_logging.yaml | 68 ++++++++ api/v4/source/introduction.yaml | 5 +- server/channels/api4/api.go | 5 + server/channels/api4/audit_logging.go | 84 ++++++++++ server/channels/app/audit.go | 69 ++++++++ server/einterfaces/cloud.go | 5 + server/einterfaces/mocks/CloudInterface.go | 38 +++++ server/i18n/en.json | 12 ++ server/public/model/config.go | 5 + webapp/channels/src/actions/admin_actions.jsx | 18 +++ .../admin_console/admin_definition.tsx | 10 ++ .../admin_console/audit_logging/index.tsx | 152 ++++++++++++++++++ .../admin_console/file_upload_setting.tsx | 19 ++- .../src/components/admin_console/types.ts | 2 +- .../hooks/useGetCloudInstallationStatus.ts | 37 +++++ webapp/channels/src/i18n/en.json | 7 + .../mattermost-redux/src/actions/admin.ts | 15 ++ webapp/platform/client/src/client4.ts | 20 +++ 19 files changed, 567 insertions(+), 5 deletions(-) create mode 100644 api/v4/source/audit_logging.yaml create mode 100644 server/channels/api4/audit_logging.go create mode 100644 webapp/channels/src/components/admin_console/audit_logging/index.tsx create mode 100644 webapp/channels/src/components/common/hooks/useGetCloudInstallationStatus.ts diff --git a/api/Makefile b/api/Makefile index a5444bdd78..25f74b0b53 100644 --- a/api/Makefile +++ b/api/Makefile @@ -59,6 +59,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/metrics.yaml >> $(V4_YAML) @cat $(V4_SRC)/scheduled_post.yaml >> $(V4_YAML) @cat $(V4_SRC)/custom_profile_attributes.yaml >> $(V4_YAML) + @cat $(V4_SRC)/audit_logging.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi @echo Extracting code samples diff --git a/api/v4/source/audit_logging.yaml b/api/v4/source/audit_logging.yaml new file mode 100644 index 0000000000..72bbc8ce40 --- /dev/null +++ b/api/v4/source/audit_logging.yaml @@ -0,0 +1,68 @@ + /api/v4/audit_logs/certificate: + post: + tags: + - audit_logs + summary: Upload audit log certificate + description: | + Upload the certificate to be used for TLS verification with the audit log service. + + ##### Permissions + Must have `sysconsole_write_experimental_features` permission. + + __Minimum server version__: 10.9 + operationId: AddAuditLogCertificate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + certificate: + description: The certificate file + type: string + format: binary + required: + - certificate + responses: + "200": + description: Certificate upload successful + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "413": + $ref: "#/components/responses/TooLarge" + "501": + $ref: "#/components/responses/NotImplemented" + + delete: + tags: + - audit_logs + summary: Remove audit log certificate + description: | + Delete the current certificate being used with the audit log service. + + ##### Permissions + Must have `sysconsole_write_experimental_features` permission. + + __Minimum server version__: 9.5 + operationId: RemoveAuditLogCertificate + responses: + "200": + description: Certificate deletion successful + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "501": + $ref: "#/components/responses/NotImplemented" diff --git a/api/v4/source/introduction.yaml b/api/v4/source/introduction.yaml index abdf2907a1..86025dab77 100644 --- a/api/v4/source/introduction.yaml +++ b/api/v4/source/introduction.yaml @@ -80,7 +80,7 @@ tags: #### Session Token - Make an HTTP POST to `your-mattermost-url.com/api/v4/users/login` with a JSON body indicating the user’s `login_id`, `password` and optionally the MFA `token`. The `login_id` can be an email, username or an AD/LDAP ID depending on the system's configuration. + Make an HTTP POST to `your-mattermost-url.com/api/v4/users/login` with a JSON body indicating the user's `login_id`, `password` and optionally the MFA `token`. The `login_id` can be an email, username or an AD/LDAP ID depending on the system's configuration. ``` @@ -561,6 +561,8 @@ tags: description: Endpoints related to export files. - name: metrics description: Endpoints related to metrics, including the Client Performance Monitoring feature. + - name: audit_logs + description: Endpoints for managing audit log certificates and configuration. x-tagGroups: - name: Overview tags: @@ -618,6 +620,7 @@ x-tagGroups: - reports - custom profile attributes - metrics + - audit_logs servers: - url: http://your-mattermost-url.com - url: https://your-mattermost-url.com diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 4791bb976f..be834904f6 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -156,6 +156,8 @@ type Routes struct { CustomProfileAttributesFields *mux.Router // 'api/v4/custom_profile_attributes/fields' CustomProfileAttributesField *mux.Router // 'api/v4/custom_profile_attributes/fields/{field_id:[A-Za-z0-9]+}' CustomProfileAttributesValues *mux.Router // 'api/v4/custom_profile_attributes/values' + + AuditLogs *mux.Router // 'api/v4/audit_logs' } type API struct { @@ -298,6 +300,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.CustomProfileAttributesField = api.BaseRoutes.CustomProfileAttributesFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter() api.BaseRoutes.CustomProfileAttributesValues = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/values").Subrouter() + api.BaseRoutes.AuditLogs = api.BaseRoutes.APIRoot.PathPrefix("/audit_logs").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -349,6 +353,7 @@ func Init(srv *app.Server) (*API, error) { api.InitClientPerformanceMetrics() api.InitScheduledPost() api.InitCustomProfileAttributes() + api.InitAuditLogging() // If we allow testing then listen for manual testing URL hits if *srv.Config().ServiceSettings.EnableTesting { diff --git a/server/channels/api4/audit_logging.go b/server/channels/api4/audit_logging.go new file mode 100644 index 0000000000..e86c972361 --- /dev/null +++ b/server/channels/api4/audit_logging.go @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "mime/multipart" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/audit" +) + +func (api *API) InitAuditLogging() { + api.BaseRoutes.AuditLogs.Handle("/certificate", api.APISessionRequired(addAuditLogCertificate)).Methods(http.MethodPost) + api.BaseRoutes.AuditLogs.Handle("/certificate", api.APISessionRequired(removeAuditLogCertificate)).Methods(http.MethodDelete) +} + +func parseAuditLogCertificateRequest(r *http.Request, maxFileSize int64) (*multipart.FileHeader, *model.AppError) { + err := r.ParseMultipartForm(maxFileSize) + if err != nil { + return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.no_file.app_error", nil, "", http.StatusBadRequest).Wrap(err) + } + + m := r.MultipartForm + + fileArray, ok := m.File["certificate"] + if !ok || len(fileArray) == 0 { + return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.no_file.app_error", nil, "", http.StatusBadRequest) + } + + if len(fileArray) > 1 { + return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.multiple_files.app_error", nil, "", http.StatusBadRequest) + } + + return fileArray[0], nil +} + +func addAuditLogCertificate(c *Context, w http.ResponseWriter, r *http.Request) { + c.Logger.Debug("addAuditLogCertificate") + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteExperimentalFeatures) { + c.SetPermissionError(model.PermissionSysconsoleWriteExperimentalFeatures) + return + } + + fileData, err := parseAuditLogCertificateRequest(r, *c.App.Config().FileSettings.MaxFileSize) + if err != nil { + c.Err = err + return + } + + auditRec := c.MakeAuditRecord("addAuditLogCertificate", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "filename", fileData.Filename) + + if err := c.App.AddAuditLogCertificate(c.AppContext, fileData); err != nil { + c.Err = err + return + } + + auditRec.Success() + ReturnStatusOK(w) +} + +func removeAuditLogCertificate(c *Context, w http.ResponseWriter, r *http.Request) { + c.Logger.Debug("removeAuditLogCertificate") + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteExperimentalFeatures) { + c.SetPermissionError(model.PermissionSysconsoleWriteExperimentalFeatures) + return + } + + auditRec := c.MakeAuditRecord("removeAuditLogCertificate", audit.Fail) + defer c.LogAuditRec(auditRec) + + if err := c.App.RemoveAuditLogCertificate(c.AppContext); err != nil { + c.Err = err + return + } + + auditRec.Success() + ReturnStatusOK(w) +} diff --git a/server/channels/app/audit.go b/server/channels/app/audit.go index 4b2dfb321c..beaf0de56d 100644 --- a/server/channels/app/audit.go +++ b/server/channels/app/audit.go @@ -7,6 +7,8 @@ import ( "encoding/json" "errors" "fmt" + "io" + "mime/multipart" "net/http" "os" "os/user" @@ -28,6 +30,10 @@ var ( LevelCLI = mlog.LvlAuditCLI ) +const ( + AuditCertificateFilename = "audit_certificate.pem" +) + func (a *App) GetAudits(rctx request.CTX, userID string, limit int) (model.Audits, *model.AppError) { audits, err := a.Srv().Store().Audit().Get(userID, 0, limit) if err != nil { @@ -156,3 +162,66 @@ func (s *Server) onAuditTargetQueueFull(qname string, maxQSize int) bool { func (s *Server) onAuditError(err error) { s.Log().Error("Audit Error", mlog.Err(err)) } + +func (a *App) AddAuditLogCertificate(rctx request.CTX, fileData *multipart.FileHeader) *model.AppError { + file, err := fileData.Open() + if err != nil { + return model.NewAppError("AddAuditLogCertificate", "api.admin.add_certificate.open.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + return model.NewAppError("AddAuditLogCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + err = a.Srv().platform.SetConfigFile(AuditCertificateFilename, data) + if err != nil { + return model.NewAppError("AddAuditLogCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + cfg := a.Config().Clone() + + *cfg.ExperimentalAuditSettings.Certificate = AuditCertificateFilename + + if err := cfg.IsValid(); err != nil { + return err + } + + a.UpdateConfig(func(dest *model.Config) { *dest = *cfg }) + + if a.License().IsCloud() { + err = a.Cloud().CreateAuditLoggingCert(rctx.Session().UserId, fileData) + if err != nil { + return model.NewAppError("AddAuditLogCertificate", "api.admin.add_certificate.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + return nil +} + +func (a *App) RemoveAuditLogCertificate(rctx request.CTX) *model.AppError { + err := a.Srv().platform.RemoveConfigFile(AuditCertificateFilename) + if err != nil { + return model.NewAppError("RemoveAuditLogCertificate", "api.admin.remove_certificate.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + cfg := a.Config().Clone() + + *cfg.ExperimentalAuditSettings.Certificate = "" + + if err := cfg.IsValid(); err != nil { + return model.NewAppError("RemoveAuditLogCertificate", "api.admin.remove_certificate.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + a.UpdateConfig(func(dest *model.Config) { *dest = *cfg }) + + if a.License().IsCloud() { + err = a.Cloud().RemoveAuditLoggingCert(rctx.Session().UserId) + if err != nil { + return model.NewAppError("RemoveAuditLogCertificate", "api.admin.remove_certificate.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + return nil +} diff --git a/server/einterfaces/cloud.go b/server/einterfaces/cloud.go index 47a5b36ab3..b3eab4d425 100644 --- a/server/einterfaces/cloud.go +++ b/server/einterfaces/cloud.go @@ -4,6 +4,8 @@ package einterfaces import ( + "mime/multipart" + "github.com/mattermost/mattermost/server/public/model" ) @@ -37,4 +39,7 @@ type CloudInterface interface { ApplyIPFilters(userID string, ranges *model.AllowedIPRanges) (*model.AllowedIPRanges, error) GetIPFilters(userID string) (*model.AllowedIPRanges, error) GetInstallation(userID string) (*model.Installation, error) + + RemoveAuditLoggingCert(userID string) error + CreateAuditLoggingCert(userID string, fileData *multipart.FileHeader) error } diff --git a/server/einterfaces/mocks/CloudInterface.go b/server/einterfaces/mocks/CloudInterface.go index e3faf5ee27..ee392464e6 100644 --- a/server/einterfaces/mocks/CloudInterface.go +++ b/server/einterfaces/mocks/CloudInterface.go @@ -5,6 +5,8 @@ package mocks import ( + multipart "mime/multipart" + model "github.com/mattermost/mattermost/server/public/model" mock "github.com/stretchr/testify/mock" ) @@ -92,6 +94,24 @@ func (_m *CloudInterface) CheckCWSConnection(userId string) error { return r0 } +// CreateAuditLoggingCert provides a mock function with given fields: userID, fileData +func (_m *CloudInterface) CreateAuditLoggingCert(userID string, fileData *multipart.FileHeader) error { + ret := _m.Called(userID, fileData) + + if len(ret) == 0 { + panic("no return value specified for CreateAuditLoggingCert") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, *multipart.FileHeader) error); ok { + r0 = rf(userID, fileData) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // CreateOrUpdateSubscriptionHistoryEvent provides a mock function with given fields: userID, userCount func (_m *CloudInterface) CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) { ret := _m.Called(userID, userCount) @@ -465,6 +485,24 @@ func (_m *CloudInterface) InvalidateCaches() error { return r0 } +// RemoveAuditLoggingCert provides a mock function with given fields: userID +func (_m *CloudInterface) RemoveAuditLoggingCert(userID string) error { + ret := _m.Called(userID) + + if len(ret) == 0 { + panic("no return value specified for RemoveAuditLoggingCert") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(userID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SubscribeToNewsletter provides a mock function with given fields: userID, req func (_m *CloudInterface) SubscribeToNewsletter(userID string, req *model.SubscribeNewsletterRequest) error { ret := _m.Called(userID, req) diff --git a/server/i18n/en.json b/server/i18n/en.json index a6982b860a..0548803f32 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -59,10 +59,18 @@ "id": "api.acknowledgement.save.archived_channel.app_error", "translation": "You cannot save an acknowledgment in an archived channel." }, + { + "id": "api.admin.add_certificate.app_error", + "translation": "Failed to add certificate." + }, { "id": "api.admin.add_certificate.array.app_error", "translation": "No file under 'certificate' in request." }, + { + "id": "api.admin.add_certificate.multiple_files.app_error", + "translation": "Too many files under 'certificate' in request." + }, { "id": "api.admin.add_certificate.no_file.app_error", "translation": "No file under 'certificate' in request." @@ -95,6 +103,10 @@ "id": "api.admin.ldap.not_available.app_error", "translation": "LDAP is not available." }, + { + "id": "api.admin.remove_certificate.app_error", + "translation": "Failed to remove certificate." + }, { "id": "api.admin.remove_certificate.delete.app_error", "translation": "An error occurred while deleting the certificate." diff --git a/server/public/model/config.go b/server/public/model/config.go index 47af9775fc..165f7b8598 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -1538,6 +1538,7 @@ type ExperimentalAuditSettings struct { FileCompress *bool `access:"experimental_features,write_restrictable,cloud_restrictable"` FileMaxQueueSize *int `access:"experimental_features,write_restrictable,cloud_restrictable"` AdvancedLoggingJSON json.RawMessage `access:"experimental_features"` + Certificate *string `access:"experimental_features"` // telemetry: none } func (s *ExperimentalAuditSettings) SetDefaults() { @@ -1572,6 +1573,10 @@ func (s *ExperimentalAuditSettings) SetDefaults() { if utils.IsEmptyJSON(s.AdvancedLoggingJSON) { s.AdvancedLoggingJSON = []byte("{}") } + + if s.Certificate == nil { + s.Certificate = NewPointer("") + } } // GetAdvancedLoggingConfig returns the advanced logging config as a []byte. diff --git a/webapp/channels/src/actions/admin_actions.jsx b/webapp/channels/src/actions/admin_actions.jsx index 92f8f67f23..5c95db8618 100644 --- a/webapp/channels/src/actions/admin_actions.jsx +++ b/webapp/channels/src/actions/admin_actions.jsx @@ -234,6 +234,24 @@ export async function uploadIdpSamlCertificate(file, success, error) { } } +export async function uploadAuditCertificate(fileData, success, error) { + const {data, error: err} = await dispatch(AdminActions.uploadAuditCertificate(fileData)); + if (data && success) { + success('audit.crt'); + } else if (err && error) { + error({id: err.server_error_id, ...err}); + } +} + +export async function removeAuditCertificate(success, error) { + const {data, error: err} = await dispatch(AdminActions.removeAuditCertificate()); + if (data && success) { + success(data); + } else if (err && error) { + error({id: err.server_error_id, ...err}); + } +} + export async function removePublicSamlCertificate(success, error) { const {data, error: err} = await dispatch(AdminActions.removePublicSamlCertificate()); if (data && success) { diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 54053a0c70..e988a4c262 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -42,6 +42,7 @@ import {ID_PATH_PATTERN} from 'utils/path'; import {getSiteURL} from 'utils/url'; import * as DefinitionConstants from './admin_definition_constants'; +import AuditLoggingCertificateUploadSetting from './audit_logging'; import Audits from './audits'; import {searchableStrings as auditSearchableStrings} from './audits/audits'; import BillingHistory, {searchableStrings as billingHistorySearchableStrings} from './billing/billing_history'; @@ -6715,6 +6716,15 @@ const AdminDefinition: AdminDefinitionType = { return JSON.parse(displayVal); }, }, + { + type: 'custom', + component: AuditLoggingCertificateUploadSetting, + label: defineMessage({id: 'admin.audit_logging_experimental.certificate.title', defaultMessage: 'Certificate'}), + key: 'ExperimentalAuditSettings.Certificate', + help_text: defineMessage({id: 'admin.audit_logging_experimental.certificate.help_text', defaultMessage: 'The certificate file used for audit logging encryption.'}), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), + isHidden: it.not(it.licensedForFeature('Cloud')), + }, ], }, }, diff --git a/webapp/channels/src/components/admin_console/audit_logging/index.tsx b/webapp/channels/src/components/admin_console/audit_logging/index.tsx new file mode 100644 index 0000000000..cbe44169c0 --- /dev/null +++ b/webapp/channels/src/components/admin_console/audit_logging/index.tsx @@ -0,0 +1,152 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ComponentType} from 'react'; +import React from 'react'; +import type {IntlShape} from 'react-intl'; +import {useIntl} from 'react-intl'; + +import {removeAuditCertificate, uploadAuditCertificate} from 'actions/admin_actions'; + +import useGetCloudInstallationStatus from 'components/common/hooks/useGetCloudInstallationStatus'; +import WithTooltip from 'components/with_tooltip'; + +import FileUploadSetting from '../file_upload_setting'; +import RemoveFileSetting from '../remove_file_setting'; + +type Props = { + id?: string; + config: any; + license: any; + intl: IntlShape; + value: any; + onChange: (id: string, value: string) => void; + disabled: boolean; + setByEnv: boolean; + label: string; + helpText: React.JSX.Element; +}; + +const AuditLoggingCertificateUploadSetting: React.FC = (props: Props) => { + const { + id, + onChange, + disabled, + setByEnv, + label, + helpText, + value, + } = props; + + const {status: installationStatus, refetchStatus} = useGetCloudInstallationStatus(true); + + const {formatMessage} = useIntl(); + + const [fileValue, setFileValue] = React.useState(value || null); // State for the file name + const [fileError, setFileError] = React.useState(null); //State for file error + + React.useEffect(() => { + if (value) { + setFileValue(value); + } + }, [value]); + + if (!id) { + return (<>); + } + + const handleChange = (id: string, value: string) => { + onChange(id, value); + }; + + const removeAction = (successCallback: () => void, errorCallback: (error: any) => void) => { + removeAuditCertificate(successCallback, errorCallback); + }; + + const uploadAction = (file: File, successCallback: (filename: string) => void, errorCallback: (error: any) => void) => { + uploadAuditCertificate(file, successCallback, errorCallback); + }; + + const withTooltip =

(Component: ComponentType

, tooltipText: string): React.FC

=> { + if (disabled || installationStatus === 'stable') { + return (props: P) => ; + } + + return (props: P) => ( + +

+ +
+ + ); + }; + + const tooltipText = formatMessage({id: 'admin.audit_logging_experimental.certificate.tooltip', defaultMessage: 'A previous update is still in progress. Please wait.'}); + + const WrappedRemoveFileSetting = withTooltip(RemoveFileSetting, tooltipText); + const WrappedFileUploadSetting = withTooltip(FileUploadSetting, tooltipText); + + if (fileValue) { + const removeFile = (id: string, callback: () => void) => { + const successCallback = () => { + handleChange(id, ''); + setFileValue(null); + setFileError(null); + refetchStatus(); + }; + const errorCallback = (error: any) => { + callback(); + setFileValue(null); + setFileError(error.message); + refetchStatus(); + }; + removeAction(successCallback, errorCallback); + }; + return ( + + ); + } + + const uploadFile = (id: string, file: File, callback: (error?: string) => void) => { + const successCallback = (filename: string) => { + handleChange(id, filename); + setFileValue(filename); + setFileError(null); + refetchStatus(); + if (callback && typeof callback === 'function') { + callback(); + } + }; + const errorCallback = (error: any) => { + if (callback && typeof callback === 'function') { + callback(error.message); + } + }; + uploadAction(file, successCallback, errorCallback); + }; + + return ( + + ); +}; + +export default AuditLoggingCertificateUploadSetting; diff --git a/webapp/channels/src/components/admin_console/file_upload_setting.tsx b/webapp/channels/src/components/admin_console/file_upload_setting.tsx index 5b956d6003..9a0ec7c8b2 100644 --- a/webapp/channels/src/components/admin_console/file_upload_setting.tsx +++ b/webapp/channels/src/components/admin_console/file_upload_setting.tsx @@ -29,6 +29,9 @@ type State = { export default class FileUploadSetting extends React.PureComponent { fileInputRef = React.createRef(); + // Helps prevent setting state after component is unmounted, for usage when this component is wrapped by a custom setting + isMounted = false; + constructor(props: Props) { super(props); @@ -40,6 +43,14 @@ export default class FileUploadSetting extends React.PureComponent }; } + componentDidMount() { + this.isMounted = true; + } + + componentWillUnmount() { + this.isMounted = false; + } + handleChooseClick = () => { this.fileInputRef.current?.click(); }; @@ -58,9 +69,11 @@ export default class FileUploadSetting extends React.PureComponent const file = this.fileInputRef.current?.files?.[0]; if (file) { this.props.onSubmit(this.props.id, file, (error) => { - this.setState({uploading: false}); - if (error && this.fileInputRef.current) { - Utils.clearFileInput(this.fileInputRef.current); + if (this.isMounted) { + this.setState({uploading: false}); + if (error && this.fileInputRef.current) { + Utils.clearFileInput(this.fileInputRef.current); + } } }); } diff --git a/webapp/channels/src/components/admin_console/types.ts b/webapp/channels/src/components/admin_console/types.ts index 2e24871912..ad3730a5fe 100644 --- a/webapp/channels/src/components/admin_console/types.ts +++ b/webapp/channels/src/components/admin_console/types.ts @@ -33,7 +33,7 @@ type AdminDefinitionSettingCustom = Omit & key: string; showTitle?: boolean; component: Component; - label?: string; + label?: string | MessageDescriptor; } type AdminDefinitionSettingBase = { diff --git a/webapp/channels/src/components/common/hooks/useGetCloudInstallationStatus.ts b/webapp/channels/src/components/common/hooks/useGetCloudInstallationStatus.ts new file mode 100644 index 0000000000..ce5e86f5fd --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useGetCloudInstallationStatus.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useState, useCallback} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {getLicense} from 'mattermost-redux/selectors/entities/general'; + +import {getInstallation} from 'actions/cloud'; + +export default function useGetCloudInstallationStatus(poll: boolean = false) { + const [status, setStatus] = useState(''); + const dispatch = useDispatch(); + const license = useSelector(getLicense); + + const fetchStatus = useCallback(async () => { + if (license.Cloud === 'true') { + const result = await dispatch(getInstallation()); + if (result.data) { + setStatus(result.data.state); + } + } else { + setStatus('stable'); + } + }, [dispatch, license]); + + useEffect(() => { + fetchStatus(); + if (poll && license.Cloud === 'true') { + const interval = setInterval(fetchStatus, 5000); // Poll every 5 seconds + return () => clearInterval(interval); + } + return undefined; + }, [fetchStatus, poll, license]); + + return {status, refetchStatus: fetchStatus}; +} diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 84e8f14ae7..1b5fa601da 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -257,6 +257,13 @@ "admin.advance.metrics": "Performance Monitoring", "admin.announcement_banner_feature_discovery.copy": "Create announcement banners to notify all members of important information.", "admin.announcement_banner_feature_discovery.title": "Create custom announcement banners with Mattermost Professional", + "admin.audit_logging_experimental.certificate.help_text": "The certificate file used for audit logging encryption.", + "admin.audit_logging_experimental.certificate.remove_button": "Remove Certificate", + "admin.audit_logging_experimental.certificate.remove_help_text": "Remove the certificate used for audit logging encryption.", + "admin.audit_logging_experimental.certificate.removing": "Removing Certificate...", + "admin.audit_logging_experimental.certificate.title": "Certificate", + "admin.audit_logging_experimental.certificate.tooltip": "A previous update is still in progress. Please wait.", + "admin.audit_logging_experimental.certificate.uploading": "Uploading Certificate...", "admin.audit_logging_experimental.file_compress.help_text": "Choose whether enable or disable file compression.", "admin.audit_logging_experimental.file_compress.title": "File Compression", "admin.audit_logging_experimental.file_enabled.help_text": "Choose whether audit logs are written locally to a file or not.", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts index 62568dfe76..f3b0375cde 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts @@ -319,6 +319,21 @@ export function uploadIdpSamlCertificate(fileData: File) { }); } +export function uploadAuditCertificate(fileData: File) { + return bindClientFunc({ + clientFunc: Client4.uploadAuditLogCertificate, + params: [ + fileData, + ], + }); +} + +export function removeAuditCertificate() { + return bindClientFunc({ + clientFunc: Client4.removeAuditLogCertificate, + }); +} + export function removePublicSamlCertificate() { return bindClientFunc({ clientFunc: Client4.deletePublicSamlCertificate, diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 7a803a4911..9f9d961bdc 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3385,6 +3385,26 @@ export default class Client4 { ); }; + uploadAuditLogCertificate = (fileData: File) => { + const formData = new FormData(); + formData.append('certificate', fileData); + + return this.doFetch( + `${this.getBaseRoute()}/audit_logs/certificate`, + { + method: 'post', + body: formData, + }, + ); + }; + + removeAuditLogCertificate = () => { + return this.doFetch( + `${this.getBaseRoute()}/audit_logs/certificate`, + {method: 'delete'}, + ); + }; + deletePublicSamlCertificate = () => { return this.doFetch( `${this.getBaseRoute()}/saml/certificate/public`,