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 <harrisonmhealey@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Nick Misasi
2025-04-16 09:34:18 -04:00
коммит произвёл GitHub
родитель e113b3cfc8
Коммит 495a49b896
19 изменённых файлов: 567 добавлений и 5 удалений

Просмотреть файл

@@ -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) {

Просмотреть файл

@@ -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')),
},
],
},
},

Просмотреть файл

@@ -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: Props) => {
const {
id,
onChange,
disabled,
setByEnv,
label,
helpText,
value,
} = props;
const {status: installationStatus, refetchStatus} = useGetCloudInstallationStatus(true);
const {formatMessage} = useIntl();
const [fileValue, setFileValue] = React.useState<string | null>(value || null); // State for the file name
const [fileError, setFileError] = React.useState<string | null>(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 = <P extends object>(Component: ComponentType<P>, tooltipText: string): React.FC<P> => {
if (disabled || installationStatus === 'stable') {
return (props: P) => <Component {...props}/>;
}
return (props: P) => (
<WithTooltip title={tooltipText}>
<div>
<Component {...props}/>
</div>
</WithTooltip>
);
};
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 (
<WrappedRemoveFileSetting
id={id}
label={label}
helpText={formatMessage({id: 'admin.audit_logging_experimental.certificate.remove_help_text', defaultMessage: 'Remove the certificate used for audit logging encryption.'})}
removeButtonText={formatMessage({id: 'admin.audit_logging_experimental.certificate.remove_button', defaultMessage: 'Remove Certificate'})}
removingText={formatMessage({id: 'admin.audit_logging_experimental.certificate.removing', defaultMessage: 'Removing Certificate...'})}
fileName={fileValue}
onSubmit={removeFile}
disabled={disabled || installationStatus !== 'stable'}
setByEnv={setByEnv}
/>
);
}
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 (
<WrappedFileUploadSetting
id={id}
label={label}
helpText={helpText}
uploadingText={formatMessage({id: 'admin.audit_logging_experimental.certificate.uploading', defaultMessage: 'Uploading Certificate...'})}
disabled={disabled || installationStatus !== 'stable'}
fileType={'.crt,.cer,.cert,.pem'}
onSubmit={uploadFile}
error={fileError || undefined} //now passes local error state
/>
);
};
export default AuditLoggingCertificateUploadSetting;

Просмотреть файл

@@ -29,6 +29,9 @@ type State = {
export default class FileUploadSetting extends React.PureComponent<Props, State> {
fileInputRef = React.createRef<HTMLInputElement>();
// 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<Props, State>
};
}
componentDidMount() {
this.isMounted = true;
}
componentWillUnmount() {
this.isMounted = false;
}
handleChooseClick = () => {
this.fileInputRef.current?.click();
};
@@ -58,9 +69,11 @@ export default class FileUploadSetting extends React.PureComponent<Props, State>
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);
}
}
});
}

Просмотреть файл

@@ -33,7 +33,7 @@ type AdminDefinitionSettingCustom = Omit<AdminDefinitionSettingBase, 'label'> &
key: string;
showTitle?: boolean;
component: Component;
label?: string;
label?: string | MessageDescriptor;
}
type AdminDefinitionSettingBase = {

Просмотреть файл

@@ -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<string>('');
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};
}

Просмотреть файл

@@ -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.",

Просмотреть файл

@@ -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,

Просмотреть файл

@@ -3385,6 +3385,26 @@ export default class Client4 {
);
};
uploadAuditLogCertificate = (fileData: File) => {
const formData = new FormData();
formData.append('certificate', fileData);
return this.doFetch<StatusOK>(
`${this.getBaseRoute()}/audit_logs/certificate`,
{
method: 'post',
body: formData,
},
);
};
removeAuditLogCertificate = () => {
return this.doFetch<StatusOK>(
`${this.getBaseRoute()}/audit_logs/certificate`,
{method: 'delete'},
);
};
deletePublicSamlCertificate = () => {
return this.doFetch<StatusOK>(
`${this.getBaseRoute()}/saml/certificate/public`,