Repository: mattermost/mattermost
Pull request #30223: Feature/audit certificate upload
Author: login: nickmisasi
Labels: name: 4: Reviews Complete
description: All reviewers have approved the pull request
name: Docs/Done
description: Required documentation has been written
name: Changelog/Not Needed
description: Does not require a changelog entry
name: EETests
name: QA - Deferred
description: Agreement with QA that these changes will be tested post-merge
name: release-note-none
description: Denotes a PR that doesn't merit a release note.
Created: 2025-02-14T19:02:16Z | Merged: 2025-04-16T13:34:18Z

#### Summary
Adds support for a UI that can accept a certificate to be used for audit logging egress. In a cloud context, this will also be written out to the CWS, for use by the Provisioner, where the secret itself will be mounted to the installation. 

#### Ticket Link
https://mattermost.atlassian.net/browse/CLD-8630

#### Screenshots
No cert attached
![image](https://github.com/user-attachments/assets/1742b87b-01e5-48f0-947e-e41e330dd625)
Cert attached, pods are rolling (disabled until the workspace is stable)
![image](https://github.com/user-attachments/assets/d79a860d-3851-47b6-b01c-42fd05296ffd)
Pod roll complete
![image](https://github.com/user-attachments/assets/e3a742cf-0b6f-45ea-a4fe-eb61731a1f32)


#### Release Note
<!--
Add a release note for each of the following conditions:

* Config changes (additions, deletions, updates).
* API additions—new endpoint, new response fields, or newly accepted request parameters.
* Database changes (any).
* Schema migration changes. Use the [Schema Migration Template](https://docs.google.com/document/d/18lD7N32oyMtYjFrJKwsNv8yn6Fe5QtF-eMm8nn0O8tk/edit?usp=sharing) as a starting point to capture these details as release notes. 
* Websocket additions or changes.
* Anything noteworthy to a Mattermost instance administrator (err on the side of over-communicating).
* New features and improvements, including behavioral changes, UI changes, and CLI changes.
* Bug fixes and fixes of previous known issues.
* Deprecation warnings, breaking changes, or compatibility notes.

If no release notes are required, write NONE. Use past-tense. Newlines are stripped.

Examples:

```
Added new API endpoints POST /api/v4/foo, GET api/v4/foo, and GET api/v4/foo/:foo_id.
```

```
Added a new config setting ServiceSettings.FooBar. Added a new column Foo to the Users table.
```

```release-note
NONE
```
-->
```release-note
None
```



Diff:
diff --git a/api/Makefile b/api/Makefile
index a5444bdd78c..25f74b0b535 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 00000000000..72bbc8ce404
--- /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 abdf2907a10..86025dab779 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 4791bb976fd..be834904f6b 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 00000000000..e86c972361a
--- /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 4b2dfb321cd..beaf0de56d6 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 47a5b36ab3a..b3eab4d425d 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 e3faf5ee27d..ee392464e61 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 7562bda0e09..3c1af3d28a4 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 47af9775fc2..165f7b85985 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 92f8f67f230..5c95db8618f 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 54053a0c707..e988a4c2623 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 00000000000..cbe44169c0b
--- /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: 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;
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 5b956d6003d..9a0ec7c8b26 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<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);
+                    }
                 }
             });
         }
diff --git a/webapp/channels/src/components/admin_console/types.ts b/webapp/channels/src/components/admin_console/types.ts
index 2e248719121..ad3730a5fee 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<AdminDefinitionSettingBase, 'label'> &
     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 00000000000..ce5e86f5fdb
--- /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<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};
+}
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index 84e8f14ae79..1b5fa601daf 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 62568dfe76a..f3b0375cdeb 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 7a803a4911c..9f9d961bdcf 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<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`,


Reviews:
user: login: gabrieljackson
body: LGTM
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-24T18:05:36Z
user: login: nickmisasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-24T19:41:44Z
user: login: nickmisasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-24T19:51:32Z
user: login: gabrieljackson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-24T20:19:59Z
user: login: gabrieljackson
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-02-24T20:22:25Z
user: login: BenCookie95
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:17:46Z
user: login: nickmisasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T17:48:46Z

Review comments:
diff_hunk: @@ -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("AddSamlCertificate", "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("AddSamlCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
path: server/channels/app/audit.go
user: login: gabrieljackson
body: Is `AddSamlCertificate` correct here? It may not really matter, but figured I would check.
created_at: 2025-02-24T17:58:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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("AddSamlCertificate", "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("AddSamlCertificate", "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("AddSamlCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
+	}
+
+	cfg := a.Config().Clone()
+
+	*cfg.ExperimentalAuditSettings.Certificate = AuditCertificateFilename
path: server/channels/app/audit.go
user: login: gabrieljackson
body: I reviewed our docs on this config and I am not exactly sure how `Certificate` interacts with the `AdvancedLoggingJSON `. Did you happen to come across what this value does when `AdvancedLoggingJSON` references a cert file?
created_at: 2025-02-24T18:05:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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("AddSamlCertificate", "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("AddSamlCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
path: server/channels/app/audit.go
user: login: nickmisasi
body: Fixed
created_at: 2025-02-24T19:41:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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("AddSamlCertificate", "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("AddSamlCertificate", "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("AddSamlCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
+	}
+
+	cfg := a.Config().Clone()
+
+	*cfg.ExperimentalAuditSettings.Certificate = AuditCertificateFilename
path: server/channels/app/audit.go
user: login: nickmisasi
body: The `Certificate` config is basically just facilitating the re-use of system console components. Otherwise I'd have had to do a fully custom page, and add a bunch of logic to Provisioner/CWS/Enterprise for listing/fetching certificates and such (only create/update/delete exist in provisioner right now). The advancedloggingjson should be using the mounted version from the provisioner, placed at either `/mattermost/custom-certs` or a different path from `AUDIT_LOGGING_CERT_MOUNT_PATH` env variable
created_at: 2025-02-24T19:51:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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("AddSamlCertificate", "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("AddSamlCertificate", "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("AddSamlCertificate", "api.admin.add_certificate.saving.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
+	}
+
+	cfg := a.Config().Clone()
+
+	*cfg.ExperimentalAuditSettings.Certificate = AuditCertificateFilename
path: server/channels/app/audit.go
user: login: gabrieljackson
body: Okay makes sense. Thanks!
created_at: 2025-02-24T20:19:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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__: 9.5
path: api/v4/source/audit_logging.yaml
user: login: BenCookie95
body: I guess this should be updated to 10.8 or 10.9?
created_at: 2025-04-14T14:11:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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__: 9.5
path: api/v4/source/audit_logging.yaml
user: login: nickmisasi
body: ```suggestion
        __Minimum server version__: 10.9
```
created_at: 2025-04-14T17:48:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: nickmisasi
created_at: 2025-02-19T15:58:06Z
body: /update-branch
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-02-24T20:58:41Z
body: #### ⚠️  One or more flaky tests detected ⚠️
* Failing job: [github.com/mattermost/mattermost:Postgres](https://github.com/mattermost/mattermost/actions/runs/13507086472)
* Double check your code to ensure you haven't introduced a flaky test.
* If this seems to be unrelated to your changes, submit a separate pull request to skip the flaky tests (e.g. [23360](https://github.com/mattermost/mattermost/pull/23360)) and file JIRA ticket (e.g. [MM-52743](https://mattermost.atlassian.net/browse/MM-52743)) for later investigation.

<table><tr><th>Test</th><th>Retries</th></tr><tr><td colspan="2"><strong>Postgres (Results)</strong></td></tr><tr><td>com/mattermost/mattermost/server/v8/channels/app.TestExportFileWarnings/s3</td><td>1</td></tr><tr><td>com/mattermost/mattermost/server/v8/channels/app.TestExportFileWarnings</td><td>1</td></tr></table>

[MM-52743]: https://mattermost.atlassian.net/browse/MM-52743?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
author_association: NONE
user: login: nickmisasi
created_at: 2025-04-14T18:53:28Z
body: /update-branch
author_association: CONTRIBUTOR
user: login: unified-ci-app[bot]
created_at: 2025-04-15T17:26:46Z
body: E2E test triggered successfully for PR #30223. The corresponding commit's status check will be available shortly.

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-15T17:27:33Z
body: E2E test run is starting for commit `e27b68a2ac4c4de6be478af93622c9bb4d045fd9`.
To check the run progress:
- Cypress: look for commit status `E2E Tests/test` or the access the [Automation Dashboard Cycle URL](https://automation-dashboard.vercel.app/cycle/14475621954_1-e27b68a-pr-onprem-ent).
- Playwright: look for commit status `E2E Tests/test-playwright`.

You can also look at the [E2E test's Workflow Run URL](https://github.com/mattermost/mattermost/actions/runs/14475621954) (run ID `14475621954`).

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-15T18:14:53Z
body: E2E test has completed for commit `e27b68a2ac4c4de6be478af93622c9bb4d045fd9`.
Results summary:
- Cypress: pass rate is `100.00%` (see [Automation Dashboard](https://automation-dashboard.vercel.app/cycle/14475621954_1-e27b68a-pr-onprem-ent) and commit status check `E2E Tests/test`).
- Playwright: pass rate is `100.00%` (see commit status check `E2E Tests/test-playwright`).

The run summary artifacts are available in the corresponding [Workflow Run](https://github.com/mattermost/mattermost/actions/runs/14475621954).

author_association: NONE
user: login: nickmisasi
created_at: 2025-04-16T13:32:32Z
body: From what I can tell the flaky test warning was related to rerunning because of out of sync enterprise branch. I'll keep an eye on it after merge.
author_association: CONTRIBUTOR